R-101 + F-DIAG: the restore dialog names the last SUCCESSFUL copy (v0.182.0)

Tier2LastRun is the attempt clock and was rendered as 'Legutóbbi másolat' in the restore
confirm dialog. New LastSuccess + SuccessTracked anchor; tier2Update makes the three
rebuild sites safe by construction. F-DIAG: six distinct causes, target-aware redaction.
This commit is contained in:
2026-07-28 16:36:47 +02:00
parent 3db8bfb953
commit 3f048e042b
12 changed files with 829 additions and 72 deletions
+12
View File
@@ -304,6 +304,18 @@ func (s *Server) templateFuncMap() template.FuncMap {
return fmt.Sprintf("%d napja", int(d.Hours()/24))
}
},
// fmtTimeStr renders an RFC3339 STRING as an absolute Budapest-local date-time. R-101: the
// Tier-2 restore confirm dialog printed a raw RFC3339 stamp ("2026-07-28T01:30:00Z") at the
// 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")
},
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return ""
+26 -9
View File
@@ -1022,15 +1022,24 @@ type AppBackupRow struct {
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
// Tier 2: Cross-drive backup (configurable for all apps)
Tier2Configured bool
Tier2Dest string // destination label
Tier2Schedule string // "Naponta", "Hetente"
Tier2LastRun string
Tier2LastStatus string // "ok", "error", "running", ""
Tier2LastError string
Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
Tier2SizeHuman string
Tier2Configured bool
Tier2Dest string // destination label
Tier2Schedule string // "Naponta", "Hetente"
Tier2LastRun string
Tier2LastStatus string // "ok", "error", "running", ""
// R-101 — the customer may only be shown a timestamp as evidence of a COPY when a copy actually
// succeeded. Tier2LastRun is the ATTEMPT clock (written on failure too), so these three drive the
// display instead:
// Tier2LastSuccess the anchor ("" = none known)
// Tier2SuccessTracked false = this row predates the anchor → render exactly as before, once-logged
// Tier2StaleCopy the newest attempt FAILED while an older success exists → disclose both
Tier2LastSuccess string
Tier2SuccessTracked bool
Tier2StaleCopy bool
Tier2LastError string
Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
Tier2SizeHuman string
// Drive disconnected — app's home drive is currently disconnected
DriveDisconnected bool
@@ -1180,6 +1189,14 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
row.Tier2Schedule = "Naponta"
row.Tier2LastRun = cd.LastRun
row.Tier2LastStatus = cd.LastStatus
row.Tier2LastSuccess = cd.LastSuccess
row.Tier2SuccessTracked = cd.SuccessTracked
// Disclose, do not hide: an older good copy AND the fact that the newest attempt failed.
// Suppressing the failure to keep the surface calm is a quieter version of the same lie.
row.Tier2StaleCopy = cd.SuccessTracked && cd.LastSuccess != "" && cd.LastStatus == "error"
if !cd.SuccessTracked {
s.noteTier2LegacyOnce(app.StackName)
}
row.Tier2LastError = cd.LastError
row.Tier2LastWarning = cd.LastWarning
row.Tier2SizeHuman = cd.LastSizeHuman
@@ -191,8 +191,12 @@
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="tag tag-warn">Cél meghajtó leválasztva</span>
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last" style="opacity:.6">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}</span>
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
@@ -202,8 +206,12 @@
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="tag tag-warn">Cél meghajtó inaktív</span>
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last" style="opacity:.6">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}</span>
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
@@ -213,23 +221,40 @@
<span class="layer-method">rsync</span>
<span class="layer-dest">→ {{.Tier2Dest}}</span>
<span class="layer-schedule">{{.Tier2Schedule}}</span>
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last">Utolsó: {{timeAgoStr .Tier2LastRun}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{else}}
<span class="layer-last">Még nincs sikeres másolat
<span class="{{if eq .Tier2LastStatus "error"}}text-error{{else}}text-muted{{end}}">{{.Tier2StatusBadge}}</span>
</span>
{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
{{if .Tier2LastWarning}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2LastWarning}}</span>{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<form method="POST" action="/backup/tier2/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{.Tier2LastRun}}">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{fmtTimeStr .Tier2LastRun}}">Fájlok visszaállítása</button>
</form>
{{end}}{{else if .Tier2LastSuccess}}
<form method="POST" action="/backup/tier2/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: {{fmtTimeStr .Tier2LastSuccess}}.{{if .Tier2StaleCopy}} Figyelem: a legutóbbi mentési kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek.{{end}}">Fájlok visszaállítása</button>
</form>
{{else}}
<span class="layer-reason" style="opacity:.85">Még nincs sikeres másolat, amiből vissza lehetne állítani.</span>
{{end}}
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
@@ -0,0 +1,169 @@
package web
import (
"strings"
"testing"
)
// R-101 — THE CUSTOMER MUST NOT BE TOLD A FAILED BACKUP IS A COPY.
//
// `Tier2LastRun` is the ATTEMPT clock: `recordTier2Failure` writes it too. It was rendered as
// „Legutóbbi másolat" in the restore confirm dialog — the exact moment a customer decides whether to
// restore, guarding a restore that fills in MISSING files without touching existing ones. So a
// customer whose Tier-2 had been failing was told a copy existed from last night, restored, and
// silently received OLDER files while believing they were recent. Misinformation at a decision point.
//
// These tests render the PRODUCTION template tree and assert on the STRING THE CUSTOMER READS. A test
// that asserted the template variable would prove nothing about the wording, which is the defect.
// tier2Row builds a configured Tier-2 row. Only the anchor fields vary between scenarios.
func tier2Row(lastRun, lastSuccess, status string, tracked, stale bool) AppBackupRow {
return AppBackupRow{
StackName: "calibre-web", DisplayName: "Calibre-Web",
Tier2Configured: true, Tier2Dest: "hdd_1", Tier2Schedule: "Naponta",
Tier2LastRun: lastRun, Tier2LastStatus: status,
Tier2LastSuccess: lastSuccess, Tier2SuccessTracked: tracked, Tier2StaleCopy: stale,
Tier2StatusBadge: "Sikeres",
}
}
func renderTier2(t *testing.T, row AppBackupRow) string {
t.Helper()
return renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{row}))
}
// SCENARIO A — the dialog names the last SUCCESSFUL copy, not last night's failed attempt.
//
// RED-PROOF: put `{{fmtTimeStr .Tier2LastRun}}` back into the data-confirm → this fails with
// "the dialog names the FAILED attempt (2026-07-28 03:30) as the latest copy".
func TestTier2Dialog_NamesTheLastSuccessfulCopy(t *testing.T) {
// succeeded 3 days ago; last night's attempt failed
html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-25T01:30:00Z", "error", true, true))
if !strings.Contains(html, "Legutóbbi sikeres másolat: 2026-07-25 03:30") {
t.Fatalf("the dialog does not name the last SUCCESSFUL copy:\n%s", confirmOf(t, html))
}
if strings.Contains(html, "2026-07-28 03:30") {
t.Errorf("the dialog names the FAILED attempt (2026-07-28 03:30) as the latest copy:\n%s", confirmOf(t, html))
}
// and it must not still say the bare „Legutóbbi másolat" of the old wording
if strings.Contains(html, "Legutóbbi másolat:") {
t.Errorf("the old bare `Legutóbbi másolat:` wording survives — that is the claim being fixed:\n%s", confirmOf(t, html))
}
}
// SCENARIO B — the failed attempt is DISCLOSED at the decision point. Showing only the old success and
// hiding the failure is a quieter lie: the customer would not know the files may be older than usual.
func TestTier2Dialog_DisclosesTheFailedAttempt(t *testing.T) {
html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-25T01:30:00Z", "error", true, true))
for _, want := range []string{
"a legutóbbi mentési kísérlet nem sikerült",
"régebbiek lehetnek",
} {
if !strings.Contains(html, want) {
t.Errorf("the dialog hides that the newest attempt failed (missing %q):\n%s", want, confirmOf(t, html))
}
}
}
// SCENARIO C — NEVER SUCCEEDED shows no copy at all. This is the worst case and the easiest to miss:
// today a tier that has attempted and never succeeded still renders a timestamp, so the dialog
// promises a copy that does not exist and the restore returns nothing.
//
// RED-PROOF: gate the restore form on `.Tier2LastRun` again instead of `.Tier2LastSuccess` → this
// fails with "a tier that has NEVER succeeded still offers a restore".
func TestTier2_NeverSucceededOffersNoCopy(t *testing.T) {
html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "", "error", true, false))
if !strings.Contains(html, "Még nincs sikeres másolat") {
t.Fatalf("a never-succeeded tier does not say so:\n%s", html[:min(len(html), 400)])
}
if strings.Contains(html, "Fájlok visszaállítása") {
t.Errorf("a tier that has NEVER succeeded still offers a restore — the dialog would promise a copy that does not exist")
}
if !strings.Contains(html, "amiből vissza lehetne állítani") {
t.Errorf("the restore action does not say plainly that there is nothing to restore from")
}
// no timestamp may be presented as a copy
if strings.Contains(html, "Legutóbbi sikeres másolat") || strings.Contains(html, "Utolsó sikeres:") {
t.Errorf("a timestamp is presented despite no successful copy existing")
}
}
// SCENARIO D — a HEALTHY tier is visually unchanged: no warning, no caution, no tonal shift. If every
// customer's dashboard grows a caution because the wording got defensive, the fix made things worse.
//
// RED-PROOF: make the stale-copy warning unconditional (drop `{{if .Tier2StaleCopy}}`) → this fails
// with "a HEALTHY tier shows the failed-attempt caution".
func TestTier2_HealthyTierIsUnchanged(t *testing.T) {
html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-28T01:30:00Z", "ok", true, false))
for _, forbidden := range []string{
"nem sikerült",
"régebbiek lehetnek",
"Még nincs sikeres másolat",
} {
if strings.Contains(html, forbidden) {
t.Errorf("a HEALTHY tier shows the failed-attempt caution (%q):\n%s", forbidden, confirmOf(t, html))
}
}
if !strings.Contains(html, "Legutóbbi sikeres másolat: 2026-07-28 03:30") {
t.Errorf("a healthy tier lost its copy timestamp:\n%s", confirmOf(t, html))
}
if !strings.Contains(html, "Fájlok visszaállítása") {
t.Error("a healthy tier lost its restore action")
}
}
// SCENARIO E — a LEGACY row (written before the anchor existed) renders EXACTLY as it did before.
// Every one of the 7 Tier-2 rows on the fleet was in this state at deploy; rendering
// „Még nincs sikeres másolat" for them would have told every customer at once that their backups do
// not exist.
func TestTier2_LegacyRowRendersAsBefore(t *testing.T) {
html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "", "ok", false /* not tracked */, false))
if strings.Contains(html, "Még nincs sikeres másolat") {
t.Fatalf("a legacy row was rendered as never-succeeded — this would frighten every existing customer at once")
}
if !strings.Contains(html, "Utolsó: ") {
t.Errorf("the legacy row lost today's rendering:\n%s", confirmOf(t, html))
}
if !strings.Contains(html, "Fájlok visszaállítása") {
t.Error("the legacy row lost its restore action — behaviour must be unchanged for it")
}
}
// The dialog must be human-readable. A raw RFC3339 stamp ("2026-07-25T01:30:00Z") is a machine
// timestamp in UTC, shown to a Hungarian customer deciding whether to restore.
func TestTier2Dialog_TimestampIsHumanReadable(t *testing.T) {
html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-25T01:30:00Z", "error", true, true))
if strings.Contains(html, "2026-07-25T01:30:00Z") {
t.Errorf("the dialog prints a raw RFC3339 UTC stamp:\n%s", confirmOf(t, html))
}
if !strings.Contains(html, "2026-07-25 03:30") { // Budapest local
t.Errorf("the dialog does not render a Budapest-local date-time:\n%s", confirmOf(t, html))
}
}
// confirmOf extracts the data-confirm attribute for error messages, so a failure shows the string the
// customer would actually read rather than a wall of HTML.
func confirmOf(t *testing.T, html string) string {
t.Helper()
i := strings.Index(html, "data-confirm=\"")
if i < 0 {
return "(no data-confirm rendered)"
}
rest := html[i+len("data-confirm=\""):]
j := strings.Index(rest, "\"")
if j < 0 {
return "(unterminated data-confirm)"
}
return rest[:j]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+29
View File
@@ -0,0 +1,29 @@
package web
import "sync"
// R-101: a Tier-2 row written before the success anchor existed renders exactly as it did before —
// „Utolsó: <idő>" from the ATTEMPT clock. That is the mandated degrade direction: showing
// „Még nincs sikeres másolat" for a row that merely predates the field would have told all seven rows
// on the fleet, at once, that their backups do not exist.
//
// It is logged ONCE per stack because it is a steady state until that app's next run (nightly), not an
// event — but it is logged at all, so a fleet quietly rendering on the old anchor is visible rather
// than assumed. Same shape as the hub's R-100 legacy degrade.
var tier2LegacyMu sync.Mutex
var tier2LegacyWarned map[string]bool
func (s *Server) noteTier2LegacyOnce(stackName string) {
tier2LegacyMu.Lock()
defer tier2LegacyMu.Unlock()
if tier2LegacyWarned == nil {
tier2LegacyWarned = map[string]bool{}
}
if tier2LegacyWarned[stackName] {
return
}
tier2LegacyWarned[stackName] = true
if s.logger != nil {
s.logger.Printf("[INFO] [tier2] %s: no success anchor yet (row predates R-101) — showing the last ATTEMPT time until this app's next run writes one", stackName)
}
}