From de57c090bf0693d0deb284d82590007ce4d0f7fb Mon Sep 17 00:00:00 2001
From: kisfenyo
Date: Tue, 27 Oct 2026 20:12:43 +0100
Subject: [PATCH] hub(R-1299): the window grant/cancel event is really stored
(0.174.0 wrote no row); the block moves to the Backup & DR tab
---
hub/CHANGELOG.md | 11 ++
hub/internal/web/offsite_window.go | 16 ++-
hub/internal/web/offsite_window_test.go | 40 +++++--
.../web/templates/customer_unified.html | 113 +++++++++---------
4 files changed, 113 insertions(+), 67 deletions(-)
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.
-
-
- {{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.
+
+
+ {{else}}
+ {{if .LimitError}}Not granted: {{.LimitError}}.
{{end}}
+
+ {{end}}
+ {{end}}
+
+ {{end}}
{{if .HasDRRecipe}}