diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 5540bcfd..d50f5435 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased (next: v0.174.1) — R-1299: the window event is really recorded; the block moves to the Backup & DR tab + +- **Defect in v0.174.0, found by the operator's live click on tester-1 (2026-10-27 20:07 CET):** a grant logged its line + but wrote NO event row — `emitWindowEvent` called only the dispatcher, which routes mail and writes no row (an info + event routes nowhere). It now writes the row with `store.SaveEvent` (as `offsitekeys.Service.event` does), then + hands it to the dispatcher. The tests asserted the dispatcher CALL (mechanism) and passed; they now read the STORED + rows (consequence) — red-proved by putting 0.174.0's dispatcher-only shape back (two tests fail). +- **The operator looked for the button under „Backup & DR" first:** the block is now its own card there („Off-site + clean-up window", only when off-site is enabled and reported); the Overview's Backup card points to it; every + redirect, the confirm's Back and the form land on `#tab=backup`. Pinned by the page test (red with 0.174.0's place). + ## v0.174.0 (2026-10-27) — R-1299: the one-time clean-up window gets a button on the customer page — DEPLOYED on the operator's yes (image sha256:4f9ee04a…, from `d85198b8`, CI 2433) - **R-1299:** the customer page's Backup card, in its off-site part, shows a „Clean-up window" block: the weekly diff --git a/hub/internal/web/offsite_window.go b/hub/internal/web/offsite_window.go index 380ef399..fa6f4bcb 100644 --- a/hub/internal/web/offsite_window.go +++ b/hub/internal/web/offsite_window.go @@ -129,7 +129,7 @@ func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request page := r.FormValue("from") == "page" answer := func(code int, flash, msg string) { if page { - http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#offsite-window", http.StatusSeeOther) + http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#tab=backup", http.StatusSeeOther) return } if code >= 400 { @@ -194,10 +194,16 @@ func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request answer(http.StatusOK, "window_granted", "") } +// emitWindowEvent WRITES the event row (the record the brief asks for) and then hands it to the dispatcher. The +// dispatcher only routes mail — it writes no row, and an info event routes nowhere — so the row is this function's job, +// as it is offsitekeys.Service.event's. Hub 0.174.0 called only the dispatcher and recorded nothing (seen live on +// tester-1 2026-10-27; the test then asserted the call, not the row). Pinned by the TestR1299_* row checks. func (s *Server) emitWindowEvent(customerID, msg string, details map[string]any) { - if s.emit == nil { - return - } b, _ := json.Marshal(details) - s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub") + if _, err := s.store.SaveEvent(customerID, eventWindowGrant, "info", msg, string(b), "hub"); err != nil { + s.logger.Printf("[WARN] offsite window: saving the %s event for %s: %v", eventWindowGrant, customerID, err) + } + if s.emit != nil { + s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub") + } } diff --git a/hub/internal/web/offsite_window_test.go b/hub/internal/web/offsite_window_test.go index d61faff0..1bd0e30b 100644 --- a/hub/internal/web/offsite_window_test.go +++ b/hub/internal/web/offsite_window_test.go @@ -44,6 +44,23 @@ func windowRig(t *testing.T, report string) (*Server, *store.Store, http.Handler return s, st, s.Handler(), &evs } +// windowRows reads the STORED offsite_window_grant rows — the consequence. Hub 0.174.0's test captured the dispatcher +// call instead, passed, and the live grant on tester-1 wrote no row (the dispatcher writes none). +func windowRows(t *testing.T, st *store.Store) []store.Event { + t.Helper() + all, err := st.GetRecentEvents("acme", 50) + if err != nil { + t.Fatal(err) + } + var out []store.Event + for _, e := range all { + if e.EventType == eventWindowGrant { + out = append(out, e) + } + } + return out +} + func windowPost(h http.Handler, form url.Values, csrf bool) *httptest.ResponseRecorder { if csrf { form.Set("_csrf", "tok1") @@ -74,6 +91,9 @@ func TestR1299_ThePageShowsTheWindowBlockPerBranch(t *testing.T) { _, st, h, _ := windowRig(t, windowReport) page := windowPage(t, h, "") + if b, w := strings.Index(page, `data-tab="backup">`+"\n"), strings.Index(page, `id="offsite-window"`); b < 0 || w < b { + t.Errorf("the window block must sit in the Backup & DR tab (panel at %d, block at %d) — where the operator looked first", b, w) + } for _, want := range []string{`id="offsite-window"`, `id="window-form"`, `name="window" value="confirm"`, "Normal cap per window", "20 (half the snapshots", "at most 40", "none waiting", "never"} { if !strings.Contains(page, want) { @@ -111,15 +131,18 @@ func TestR1299_ThePageShowsTheWindowBlockPerBranch(t *testing.T) { func TestR1299_AGrantThroughTheFormStoresOneGrantWithItsLimit(t *testing.T) { _, st, h, evs := windowRig(t, windowReport) w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"33"}}, true) - if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=window_granted") { + if w.Code != http.StatusSeeOther || !strings.HasSuffix(w.Header().Get("Location"), "flash=window_granted#tab=backup") { t.Fatalf("grant: %d Location=%q", w.Code, w.Header().Get("Location")) } if pending, max := st.PendingOffsiteWindowGrant("acme"); !pending || max != 33 { t.Fatalf("stored grant: pending=%v max=%d, want true 33", pending, max) } - if len(*evs) != 1 || (*evs)[0].typ != eventWindowGrant || (*evs)[0].sev != "info" || - !strings.Contains((*evs)[0].details, `"max_remove":33`) || !strings.Contains((*evs)[0].details, `"by":"operator session"`) { - t.Fatalf("events: %+v — want one info %s naming the limit and who", *evs, eventWindowGrant) + if rows := windowRows(t, st); len(rows) != 1 || rows[0].Severity != "info" || + !strings.Contains(rows[0].DetailsJSON, `"max_remove":33`) || !strings.Contains(rows[0].DetailsJSON, `"by":"operator session"`) { + t.Fatalf("stored rows: %+v — want ONE info %s row naming the limit and who", rows, eventWindowGrant) + } + if len(*evs) != 1 { + t.Fatalf("dispatcher calls: %d, want 1", len(*evs)) } if granted, max := st.TakeOffsiteWindowGrant("acme"); !granted || max != 33 { t.Fatalf("the window took granted=%v max=%d", granted, max) @@ -168,8 +191,8 @@ func TestR1299_RefusalsStoreNothing(t *testing.T) { if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending { t.Errorf("%s: a grant was stored", c.name) } - if len(*evs) != 0 { - t.Errorf("%s: %d event(s) for a refused request", c.name, len(*evs)) + if len(*evs) != 0 || len(windowRows(t, st)) != 0 { + t.Errorf("%s: an event for a refused request", c.name) } } } @@ -208,7 +231,8 @@ func TestR1299_CancelRemovesAWaitingGrant(t *testing.T) { if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted { t.Fatal("the box's window would still use the cancelled grant") } - if len(*evs) != 1 || !strings.Contains((*evs)[0].details, `"cancelled":true`) { - t.Fatalf("events: %+v — want one cancel event", *evs) + if rows := windowRows(t, st); len(rows) != 1 || !strings.Contains(rows[0].DetailsJSON, `"cancelled":true`) { + t.Fatalf("stored rows: %+v — want ONE cancel row", rows) } + _ = evs } diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 5629e381..4b00e035 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -344,61 +344,8 @@ claim the repository is empty and that is not what is known. They fill in after the next off-site run reads the repository.

{{end}} +

The one-time clean-up window is on the Backup & DR tab.

- - {{with $.WindowCard}} -

Clean-up window

-
-
- Weekly windows - {{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}} -
-
- Normal cap per window - {{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}— (half the snapshots; count not measured){{end}} -
-
- One-time window - {{if .Pending}}waiting — {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}} -
-
- Last window - {{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC — {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} → {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}} -
-
- {{if .Pending}} -
- - - - -
- {{else if .Confirm}} -
- Open one clean-up window for {{$.CustomerID}} with - {{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}? - The box uses it on its next off-site run; it removes only what its keep rule selects, and the - fake-snapshot guard still runs. One use, then it is gone. -
- - - - - - Back -
-
- {{else}} - {{if .LimitError}}

Not granted: {{.LimitError}}.

{{end}} -
- - - - -
- {{end}} - {{end}} {{end}} {{end}} @@ -1091,6 +1038,64 @@
{{if .HasReports}} + {{if and .BackupCard.OffsiteReported .BackupCard.OffsiteEnabled}} +
+

Off-site clean-up window

+ + {{with $.WindowCard}} +
+
+ Weekly windows + {{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}} +
+
+ Normal cap per window + {{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}— (half the snapshots; count not measured){{end}} +
+
+ One-time window + {{if .Pending}}waiting — {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}} +
+
+ Last window + {{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC — {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} → {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}} +
+
+ {{if .Pending}} +
+ + + + +
+ {{else if .Confirm}} +
+ Open one clean-up window for {{$.CustomerID}} with + {{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}? + The box uses it on its next off-site run; it removes only what its keep rule selects, and the + fake-snapshot guard still runs. One use, then it is gone. +
+ + + + + + Back +
+
+ {{else}} + {{if .LimitError}}

Not granted: {{.LimitError}}.

{{end}} +
+ + + + +
+ {{end}} + {{end}} +
+ {{end}} {{if .HasDRRecipe}}