R-103: the Tier-2 refusal becomes an action
gates / gates (push) Successful in 12s

An app whose Tier-2 copy holds no file legs but a full recovery-unit mirror - 45 of the 53 catalog
templates - was told to press a button on a DIFFERENT page. Since R-102 the data it is asking for is
restorable from the copy it is looking at.

New POST /backup/tier2/unit-restore and backupTier2UnitRestoreHandler: same guards, same
restoreOpBlocked() refusal (R-351b), same async shape as the file restore beside it, plus a
fail-closed pre-flight so the app is never stopped for a mirror that could not be opened. The
outcome reuses unitRestoreOutcomeMsg and adds which copy overwrote the live data.

The row offers the action where the refusal was, in a danger style, as a SEPARATE button. The two
are not merged: one adds what is missing, the other overwrites. The confirm carries that difference
in words and names the copy's date - and says so differently when that date is only an ATTEMPT
(R-101). It is built from named Go constants rather than assembled inside an HTML attribute, so a
test can assert it verbatim; fmtTimeStr now delegates to a package-level fmtRFC3339Local so the
confirm and the outcome cannot render the same date two ways.

tier2NoCoverageMsg is NARROWED to the case that remains - no legs and no openable unit - and still
names the route that works. tier2UnitNotCoveredMsg is NOT deleted: it is appended where the FILE
restore ran and is still exactly true of it.

Tests C1-C2 and D1-D6 plus four more. Red-proofs: C1 (widen CanRestore to include HasUnit -> the
unit-only cases fail), D6 (drop EndRestoreOp from the handler goroutine -> 'the restore never
published a result').
This commit is contained in:
2026-08-31 11:42:03 +02:00
parent 0f9b796615
commit 4c8f0d2919
6 changed files with 755 additions and 16 deletions
+17 -7
View File
@@ -309,13 +309,12 @@ func (s *Server) templateFuncMap() template.FuncMap {
// moment the customer decides whether to restore — a UTC machine timestamp is not something a
// customer can reason about. Absolute rather than relative here on purpose: "3 napja" is fine on
// a status card, but a restore decision deserves the actual date.
"fmtTimeStr": func(s string) string {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}
return t.In(loc).Format("2006-01-02 15:04")
},
//
// R-102: it delegates to the package-level fmtRFC3339Local so a HANDLER can render the same
// date the same way. The Tier-2 unit restore names the copy's date in its confirm (template)
// and again in its outcome (Go); two renderings of one decision that could disagree is how a
// customer ends up confirming one date and being told another.
"fmtTimeStr": fmtRFC3339Local,
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return "–"
@@ -497,3 +496,14 @@ func (s *Server) templateFuncMap() template.FuncMap {
},
}
}
// fmtRFC3339Local renders an RFC3339 STRING as an absolute Budapest-local date-time, returning the
// input unchanged when it does not parse. The ONE implementation behind the `fmtTimeStr` template
// helper and every Go-side caller. See the comment on the funcmap entry for why it is shared.
func fmtRFC3339Local(s string) string {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}
return t.In(getTimezone()).Format("2006-01-02 15:04")
}