D65 = A: the busy file the agent reads before an operator reboot, and the household's 'Felhom support restarted your box' line; R-1350: a hold clear records the wanted stop so no app_start_failed follows it
gates / gates (push) Successful in 32s
gates / gates (push) Successful in 32s
This commit is contained in:
1 parent
a28db2d17f
commit
b69e90bdad
22 files changed
+662
-11
No files matched your search
@@ -183,9 +183,10 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||||
|
||||
data := s.baseData("dashboard", "Vezérlőpult")
|
||||
data["TitleKey"] = "page.title.dashboard" // i18n: the Hungarian title above is what hu renders
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||||
data["TitleKey"] = "page.title.dashboard" // i18n: the Hungarian title above is what hu renders
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||||
data["OperatorRebootNotice"] = s.operatorRebootNotice(s.langFor(r), time.Now()) // D65: „Felhom support restarted your box"
|
||||
// R-560: the app rows carry catalog copy (each row's `.Meta.Description`), so the list the
|
||||
// TEMPLATE sees is the localised view. The lists the warning helpers below see are the
|
||||
// originals — they key on stack NAMES, and handing them a translated copy would only widen
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
)
|
||||
|
||||
// „Felhom support restarted your box at <time>" (D65 = A, operator ruling 2026-10-29 15:50; felhom.eu `04` §6.1).
|
||||
//
|
||||
// The operator can reboot the guest from the hub; the household must see afterwards that Felhom did it. The agent
|
||||
// writes its record BEFORE the reboot, so this controller — which starts with the guest — asks for it once at start
|
||||
// (GET /guest/operator-reboot) and the dashboard shows one neutral line until a day has passed since the reboot.
|
||||
// Pinned by operator_reboot_notice_test.go.
|
||||
|
||||
// operatorRebootNoticeFor is how long the line shows (the agent also stops answering after a day).
|
||||
const operatorRebootNoticeFor = 24 * time.Hour
|
||||
|
||||
// SetOperatorReboot records the last operator reboot (the zero time = none).
|
||||
func (s *Server) SetOperatorReboot(at time.Time) {
|
||||
s.opRebootMu.Lock()
|
||||
defer s.opRebootMu.Unlock()
|
||||
s.opRebootAt = at
|
||||
}
|
||||
|
||||
// operatorRebootNotice is the dashboard's line for lang at now ("" = none).
|
||||
func (s *Server) operatorRebootNotice(lang string, now time.Time) string {
|
||||
s.opRebootMu.Lock()
|
||||
at := s.opRebootAt
|
||||
s.opRebootMu.Unlock()
|
||||
if at.IsZero() || now.Sub(at) >= operatorRebootNoticeFor || now.Before(at) {
|
||||
return ""
|
||||
}
|
||||
return s.i18n.Msgf(lang, "dashboard.operator_reboot", at.In(budapestLocation()).Format("15:04"))
|
||||
}
|
||||
|
||||
// WatchOperatorReboot asks the agent once at start, retrying while the agent does not answer (it may start after us),
|
||||
// at most for opRebootAskFor. A 404 (an older agent) ends it at once.
|
||||
func (s *Server) WatchOperatorReboot(ctx context.Context) {
|
||||
fetch := s.opRebootFetch
|
||||
if fetch == nil {
|
||||
fetch = func(ctx context.Context) (time.Time, error) {
|
||||
c, err := s.agentClient()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return c.OperatorReboot(ctx)
|
||||
}
|
||||
}
|
||||
every := s.opRebootRetry
|
||||
if every <= 0 {
|
||||
every = 30 * time.Second
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Minute); ; {
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
at, err := fetch(cctx)
|
||||
cancel()
|
||||
var se *agentapi.StatusError
|
||||
switch {
|
||||
case err == nil:
|
||||
if !at.IsZero() {
|
||||
s.SetOperatorReboot(at)
|
||||
s.logger.Printf("[INFO] [web] the operator rebooted this box from the hub at %s — the dashboard says so for a day", at.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return
|
||||
case errors.As(err, &se) && se.Code == 404:
|
||||
return // an agent older than the route cannot reboot from the hub
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
s.logger.Printf("[WARN] [web] could not ask the agent about an operator reboot: %v — no notice on the dashboard", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(every):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
)
|
||||
|
||||
// D65 = A: after the operator rebooted the box from the hub, the household's dashboard says so — one neutral line,
|
||||
// for one day, then gone.
|
||||
|
||||
// The real dashboard, through ServeHTTP: the line is there after an operator reboot, in Hungarian, with the Budapest
|
||||
// time; it is gone a day later. RED-PROOF: drop the data["OperatorRebootNotice"] line from dashboardHandler → FAILS;
|
||||
// drop the 24-hour check → the day-later page still shows it → FAILS.
|
||||
func TestOperatorRebootNotice_OnTheDashboardForADay(t *testing.T) {
|
||||
s := newDashboardServer(t, time.Time{})
|
||||
if body := getPage(t, s, "/dashboard").Body.String(); strings.Contains(body, "data-operator-reboot") {
|
||||
t.Fatal("no reboot yet, but the dashboard shows the line")
|
||||
}
|
||||
at := time.Now().Add(-2 * time.Hour)
|
||||
s.SetOperatorReboot(at)
|
||||
body := getPage(t, s, "/dashboard").Body.String()
|
||||
want := "A Felhom ügyfélszolgálata " + at.In(budapestLocation()).Format("15:04") + "-kor újraindította a dobozt."
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("the dashboard lacks %q", want)
|
||||
}
|
||||
s.SetOperatorReboot(time.Now().Add(-25 * time.Hour))
|
||||
if body := getPage(t, s, "/dashboard").Body.String(); strings.Contains(body, "data-operator-reboot") {
|
||||
t.Fatal("a day later the line must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
// English: the reader's language.
|
||||
func TestOperatorRebootNotice_English(t *testing.T) {
|
||||
s := newDashboardServer(t, time.Time{})
|
||||
at := time.Date(2026, 10, 30, 9, 41, 0, 0, time.UTC)
|
||||
s.SetOperatorReboot(at)
|
||||
got := s.operatorRebootNotice("en", at.Add(time.Hour))
|
||||
if got != "Felhom support restarted your box at "+at.In(budapestLocation()).Format("15:04")+"." {
|
||||
t.Fatalf("en: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The start-up ask: it retries while the agent does not answer, keeps the time it gets, and stops at once on a 404
|
||||
// (an agent older than the route cannot reboot from the hub). RED-PROOF: drop SetOperatorReboot from the success
|
||||
// branch → FAILS; return on the first error → the retried answer is lost → FAILS.
|
||||
func TestWatchOperatorReboot_RetriesAndKeepsTheTime(t *testing.T) {
|
||||
s := &Server{logger: log.New(io.Discard, "", 0), opRebootRetry: time.Millisecond}
|
||||
at := time.Date(2026, 10, 30, 9, 41, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
s.opRebootFetch = func(context.Context) (time.Time, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return time.Time{}, errors.New("agentapi: GET /guest/operator-reboot: connection refused")
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
s.WatchOperatorReboot(context.Background())
|
||||
if calls != 3 || !s.opRebootAt.Equal(at) {
|
||||
t.Fatalf("calls=%d at=%v, want 3 and %v", calls, s.opRebootAt, at)
|
||||
}
|
||||
|
||||
s2 := &Server{logger: log.New(io.Discard, "", 0), opRebootRetry: time.Millisecond}
|
||||
calls = 0
|
||||
s2.opRebootFetch = func(context.Context) (time.Time, error) {
|
||||
calls++
|
||||
return time.Time{}, &agentapi.StatusError{Path: "/guest/operator-reboot", Code: 404}
|
||||
}
|
||||
s2.WatchOperatorReboot(context.Background())
|
||||
if calls != 1 || !s2.opRebootAt.IsZero() {
|
||||
t.Fatalf("a 404 must end the ask at once: calls=%d at=%v", calls, s2.opRebootAt)
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,11 @@ type Server struct {
|
||||
agentCli *agentapi.Client
|
||||
agentCliErr error
|
||||
agentCliOnce sync.Once
|
||||
// D65 = A: the last operator reboot of this guest (operator_reboot_notice.go); the fetch and retry are test seams.
|
||||
opRebootMu sync.Mutex
|
||||
opRebootAt time.Time
|
||||
opRebootFetch func(ctx context.Context) (time.Time, error)
|
||||
opRebootRetry time.Duration
|
||||
|
||||
// initialCredsFn is the R-254 read seam for an app's generated first-login credential. nil → the
|
||||
// real live container read (stackMgr.ReadInitialCredentials). ONE definition, used by BOTH the
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2>{{T "dashboard.vezerlopult"}}</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
{{template "media_notice" .}}
|
||||
{{template "media_notice" .}}{{if .OperatorRebootNotice}}<div class="operator-reboot-notice" data-operator-reboot>{{.OperatorRebootNotice}}</div>{{end}}
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="/stacks?filter=running" class="stat-card">
|
||||
|
||||
@@ -911,6 +911,8 @@ select.form-control option { background: var(--bg-2); color: var(--text-1); }
|
||||
|
||||
/* Inline storage warnings (under storage bars on dashboard and monitoring) */
|
||||
.inline-warnings { margin-top: 0.75rem; }
|
||||
/* D65: „Felhom support restarted your box" — neutral (a deliberate act, not a fault: the exception-colour rule). */
|
||||
.operator-reboot-notice { margin: 0 0 1rem; padding: 0.6rem 0.9rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); color: var(--text-2); }
|
||||
.inline-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user