diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go
index 2e5375d..7b09b0e 100644
--- a/controller/internal/backup/offbox.go
+++ b/controller/internal/backup/offbox.go
@@ -496,6 +496,7 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
// "never succeeded" every time the hub re-pushes the descriptor.
tgt.LastSuccess = cur.LastSuccess
tgt.RepoSizeHuman, tgt.RepoSizeBytes, tgt.SnapshotCount = cur.RepoSizeHuman, cur.RepoSizeBytes, cur.SnapshotCount
+ tgt.StatsKnown = cur.StatsKnown // R-225: carry the KNOWN-ness with the numbers
}
if tgt.EscrowState != "escrowed" {
tgt.EscrowState = "pending"
@@ -883,6 +884,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
}
o.LastError = ""
o.SnapshotCount = snapshots
+ o.StatsKnown = true // R-225: measured, even if the answer is zero
o.EnlargedBlocked = blockedNames // replace each run (sorted); empty slice clears it
var warns []string
// Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports
@@ -1419,6 +1421,7 @@ func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.RepoSizeHuman = human
o.RepoSizeBytes = st.TotalSize
+ o.StatsKnown = true // R-225
})
}
}
diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go
index e697278..846a5fe 100644
--- a/controller/internal/settings/settings.go
+++ b/controller/internal/settings/settings.go
@@ -225,6 +225,22 @@ type OffboxTarget struct {
// gate's input (last-known value; a failed stats call keeps the previous one — stale-but-safe).
RepoSizeBytes int64 `json:"repo_size_bytes,omitempty"`
SnapshotCount int `json:"snapshot_count,omitempty"`
+ // StatsKnown — R-225. Whether SnapshotCount / RepoSizeBytes above were ever actually READ from the
+ // repository, as opposed to never having been measured.
+ //
+ // ⚠ IT IS NAMED RATHER THAN INFERRED FROM THE COUNTS, for the same reason `OffsiteInventory.Empty`
+ // is: **zero is what an unread store and a genuinely empty one both look like**, and the two are
+ // opposite news. Both fields are `omitempty` ints, so on the wire and on disk "absent" and "0" are
+ // the same bytes — the distinction cannot be recovered downstream and has to be carried.
+ //
+ // Measured live on 2026-08-05 (CAMPAIGN-11, R-225): after a rebuild the box rendered
+ // „Tároló méret · 0 pillanatkép" and „Tárhelykeret: 0 / 50 GB (0%)" — directly above a card saying
+ // the store held backups made under another key. An SFTP listing found snapshot `f3d9cd67` and
+ // 12 535 KB really there. The keys were simply ABSENT from settings.json and the zero value spoke
+ // for them.
+ //
+ // Set true by the run that reads the stats, whatever it reads — a true zero is knowledge too.
+ StatsKnown bool `json:"stats_known,omitempty"`
// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
LastWarning string `json:"last_warning,omitempty"`
@@ -262,7 +278,19 @@ type OffboxTarget struct {
// OrphanedAt is the RFC3339 stamp of the orphan detection (drives the card copy).
OrphanedAt string `json:"orphaned_at,omitempty"`
// OrphanedRenamedTo records the move-aside path of the last reset (e.g. .orphaned-20260717),
- // so the card/log can name where the old (recovery-code-recoverable) history was set aside.
+ // so the card/log can name where the old history was set aside.
+ //
+ // ⚠ R-228 (2026-08-06) — THIS WAS WRITTEN AND READ BY NOBODY, and this comment used to call the
+ // set-aside history "recovery-code-recoverable", which is **not true today**: serving a superseded
+ // blob is an unbuilt link (R-199's inventory), so the old store cannot be opened by the customer,
+ // the operator, or anyone else. Measured on 2026-08-05 (CAMPAIGN-11 F7): after a customer chose to
+ // set their old backups aside, 12 535 KB sat at this exact path and `/backups/remote` mentioned it
+ // nowhere — a census found zero references to this field in any template or handler. The screen
+ // promised the data was kept and then showed nothing.
+ //
+ // It is surfaced now, as a plain statement that an earlier history is KEPT and NOT DELETED. It must
+ // NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise
+ // that turns out false is worse on this surface than saying less.
OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"`
}
diff --git a/controller/internal/web/backups_remote_statsknown_test.go b/controller/internal/web/backups_remote_statsknown_test.go
new file mode 100644
index 0000000..45d6534
--- /dev/null
+++ b/controller/internal/web/backups_remote_statsknown_test.go
@@ -0,0 +1,144 @@
+package web
+
+import (
+ "strings"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
+)
+
+// ── SCENARIO F (R-225) — AN UNREAD STORE SAYS "UNKNOWN", NEVER "ZERO" ───────────────────────────
+//
+// Measured live on 2026-08-05 (CAMPAIGN-11): after a rebuild this page rendered
+//
+// Tároló méret · 0 pillanatkép Tárhelykeret: 0 / 50 GB (0%)
+//
+// directly above a card stating the store contains backups made under another key. An SFTP listing
+// of the remote — read-only, no decryption — found snapshot `f3d9cd67` and **12 535 KB** really
+// there. `snapshot_count` and `repo_size_bytes` were simply ABSENT from settings.json, and the zero
+// value spoke for them.
+//
+// This is R-217's defect class one card over: a field whose zero is indistinguishable from a real
+// measurement, defaulted past on an unknown path. `StatsKnown` is the named state, for the same
+// reason `OffsiteInventory.Empty` is named rather than inferred from `len(Apps)==0`.
+//
+// Render tests per branch of the gate, because a template gate without one is the v0.70.1 lesson.
+
+func remoteStatsData(known bool, snaps int, human string) map[string]interface{} {
+ d := splitTestData()
+ d["Offbox"] = &settings.OffboxTarget{
+ Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
+ LastStatus: "error", EscrowState: "escrowed", QuotaGB: 50,
+ SnapshotCount: snaps, RepoSizeHuman: human, StatsKnown: known,
+ }
+ d["OffboxQuotaPct"] = 0
+ return d
+}
+
+// The branch the finding was measured on: nothing has ever been read.
+func TestBackupsRemote_UnknownStoreStatsSayUnknownNotZero(t *testing.T) {
+ html := renderBackupPage(t, "backups_remote", remoteStatsData(false, 0, ""))
+
+ // RED-PROOF: set StatsKnown true in this fixture (or drop the template guard) → the page renders
+ // "0 pillanatkép" and "0 / 50 GB (0%)" again → this FAILS. Demonstrated failing before it was kept.
+ if strings.Contains(html, "0 pillanatkép") {
+ t.Fatalf("R-225 RETURNED: an unread store reports a snapshot COUNT of zero")
+ }
+ if !strings.Contains(html, "a pillanatképek száma még ismeretlen") {
+ t.Fatalf("an unread store must say the count is not known")
+ }
+ if strings.Contains(html, "0 / 50 GB") {
+ t.Fatalf("R-225 RETURNED: an unread store reports a used figure of zero")
+ }
+ if !strings.Contains(html, "még nem tudjuk, mennyi van a tárolóban") {
+ t.Fatalf("an unread store must say the used figure is not known")
+ }
+ // A 0%-wide fill bar is a PICTURE of emptiness, and a picture is a claim. Scoped to the quota
+ // bar's own container — the page has other zero-width elements and a bare "width:0%" search
+ // would pass or fail for unrelated reasons.
+ if bar := between(html, `id="offbox-quota-bar"`, "\n "); strings.Contains(bar, "background:var(--border") {
+ t.Fatalf("the fill bar rendered over an unread store — a 0%% bar asserts emptiness")
+ }
+}
+
+// The other branch: a store that WAS read and is genuinely empty must still say zero. Without this,
+// the fix could be "never show a number", which loses real information.
+func TestBackupsRemote_KnownEmptyStoreStillSaysZero(t *testing.T) {
+ html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 0, ""))
+
+ if !strings.Contains(html, "0 pillanatkép") {
+ t.Fatalf("a store that was READ and holds nothing must say zero — that is knowledge")
+ }
+ if strings.Contains(html, "még ismeretlen") {
+ t.Fatalf("a measured zero must not be dressed up as unknown")
+ }
+}
+
+// And a store with real content renders it unchanged.
+func TestBackupsRemote_KnownNonEmptyStoreRendersTheNumbers(t *testing.T) {
+ html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 2, "12.0 MB"))
+
+ if !strings.Contains(html, "2 pillanatkép") {
+ t.Fatalf("a measured count must render")
+ }
+ if !strings.Contains(html, "12.0 MB / 50 GB") {
+ t.Fatalf("a measured size must render against the quota")
+ }
+ if strings.Contains(html, "még ismeretlen") || strings.Contains(html, "még nem tudjuk") {
+ t.Fatalf("measured stats must not read as unknown")
+ }
+}
+
+// between returns the slice of s after the first `from` and before the next `to` (empty when either
+// marker is missing) — so an assertion can be scoped to one card instead of the whole page.
+func between(s, from, to string) string {
+ i := strings.Index(s, from)
+ if i < 0 {
+ return ""
+ }
+ rest := s[i+len(from):]
+ if j := strings.Index(rest, to); j >= 0 {
+ return rest[:j]
+ }
+ return rest
+}
+
+// ── SCENARIO H (R-228) — THE SET-ASIDE HISTORY IS VISIBLE AND HONESTLY DESCRIBED ────────────────
+//
+// Measured 2026-08-05 (CAMPAIGN-11 F7): a customer chose "I do not want the old data", was told the
+// backups would be KEPT and not deleted, and the move-aside did exactly that — 12 535 KB, byte-exact,
+// at `/home/felhom-repo.orphaned-20260805`. The box recorded the path in `orphaned_renamed_to` and a
+// census found **zero** references to it in any template or handler. The promise was kept and shown
+// to nobody.
+func TestBackupsRemote_SetAsideHistoryIsSurfaced(t *testing.T) {
+ d := splitTestData()
+ d["Offbox"] = &settings.OffboxTarget{
+ Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
+ LastStatus: "ok", EscrowState: "escrowed", StatsKnown: true,
+ OrphanedRenamedTo: "/home/felhom-repo.orphaned-20260805",
+ }
+ html := renderBackupPage(t, "backups_remote", d)
+
+ // RED-PROOF: delete the {{if .Offbox.OrphanedRenamedTo}} block → this FAILS, and the set-aside
+ // history is invisible again. Demonstrated failing before this test was kept.
+ if !strings.Contains(html, "félre vannak téve") {
+ t.Fatalf("R-228 RETURNED: the set-aside history is not mentioned at all")
+ }
+ if !strings.Contains(html, "nem töröltük") {
+ t.Fatalf("the customer must be told it was NOT deleted — that is the promise being kept")
+ }
+ // §7.6 — it must NOT promise the history can be reopened. It cannot be, by anyone, today.
+ for _, forbidden := range []string{"visszaállítható lehet", "vissza tudod állítani", "megnyithatod", "kóddal később"} {
+ if strings.Contains(html, forbidden) {
+ t.Errorf("the set-aside notice promises the history can be reopened (%q) — the read path does not exist", forbidden)
+ }
+ }
+}
+
+// And a box that never set anything aside must not claim it did.
+func TestBackupsRemote_NoSetAsideNoClaim(t *testing.T) {
+ html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 1, "12.0 MB"))
+ if strings.Contains(html, "félre vannak téve") {
+ t.Fatal("a box with no set-aside history must not claim one exists")
+ }
+}
diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go
index a423748..3717aa3 100644
--- a/controller/internal/web/offbox_handlers.go
+++ b/controller/internal/web/offbox_handlers.go
@@ -101,6 +101,7 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
tgt.LastWarning = prev.LastWarning
tgt.EscrowState = prev.EscrowState
tgt.RepoSizeBytes = prev.RepoSizeBytes
+ tgt.StatsKnown = prev.StatsKnown // R-225: preserved with the numbers it qualifies
tgt.EnlargedBlocked = prev.EnlargedBlocked
}
// fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks
diff --git a/controller/internal/web/recovery_class_test.go b/controller/internal/web/recovery_class_test.go
index aba8fbd..4891553 100644
--- a/controller/internal/web/recovery_class_test.go
+++ b/controller/internal/web/recovery_class_test.go
@@ -242,3 +242,38 @@ func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) {
t.Fatal("classification followed the TEXT — it must follow the status")
}
}
+
+// ── SCENARIO G (R-227) — A RESTART MID-UNLOCK IS ANSWERED IN HUNGARIAN ──────────────────────────
+//
+// Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and the
+// customer got traefik's raw English `Bad Gateway`. The state was clean; the page was not.
+//
+// The layer that answers is traefik, whose config this repo generates — but traefik v3 serves no
+// static files, so a branded proxy page would need a new always-up container for every 502 on the
+// box. What ships is the second sanctioned option: the unlock posts via fetch and answers a gateway
+// failure in the page. This asserts the handling is PRESENT and says the right thing; with no JS the
+// plain POST is unchanged and still shows the proxy's error, which the report states plainly.
+func TestRecoveryClass_G_GatewayErrorIsAnsweredInHungarian(t *testing.T) {
+ f := newRecoveryFixture(t)
+ body := getRecoveryPage(t, f.s).Body.String()
+
+ // RED-PROOF: delete the fetch handler from recovery.html → this FAILS, and a restart mid-unlock
+ // shows `Bad Gateway` again.
+ if !strings.Contains(body, "unlock-gateway-error") {
+ t.Fatal("R-227 RETURNED: the page carries no handling for a gateway failure")
+ }
+ if !strings.Contains(body, "A gép éppen újraindul") {
+ t.Fatal("the gateway message must say, in Hungarian, that the machine is restarting")
+ }
+ if !strings.Contains(body, "resp.status >= 500") {
+ t.Fatal("a 5xx from the proxy must be caught, not rendered")
+ }
+ // It must claim NOTHING about the code — whether it was used is unknown at that point.
+ if namesTyping(body) && !strings.Contains(body, "Helyreállítási kód (tíz szó)") {
+ t.Fatal("the gateway path must not blame the code")
+ }
+ // Progressive enhancement: the plain form must survive for a JS-less browser.
+ if !strings.Contains(body, `method="POST" action="/recovery/unlock"`) {
+ t.Fatal("the plain POST form must remain for browsers without JS")
+ }
+}
diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html
index 98257c3..8b5b538 100644
--- a/controller/internal/web/templates/backups_remote.html
+++ b/controller/internal/web/templates/backups_remote.html
@@ -49,8 +49,8 @@
Utolsó távoli mentés{{if .Offbox.LastRun}}
{{timeAgoStr .Offbox.LastRun}}{{end}}
-
{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}
-
Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép
+
{{if and .Offbox.StatsKnown .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}
+
Tároló méret · {{if .Offbox.StatsKnown}}{{.Offbox.SnapshotCount}} pillanatkép{{else}}a pillanatképek száma még ismeretlen{{end}}
{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}
@@ -58,12 +58,31 @@
{{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}}
+
-
Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)
-
+
Tárhelykeret: {{if .Offbox.StatsKnown}}{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%){{else}}még nem tudjuk, mennyi van a tárolóban — legfeljebb {{.Offbox.QuotaGB}} GB{{end}}
+ {{if .Offbox.StatsKnown}}
+
{{end}}
+
+ {{end}}
+ {{/* R-228 — THE SET-ASIDE HISTORY IS SAID OUT LOUD.
+ The customer chose "I do not want the old data", was told it would be KEPT and not deleted,
+ and then it vanished from every screen: the box recorded exactly where it went
+ (OrphanedRenamedTo) and showed that to nobody. Measured 2026-08-05 (CAMPAIGN-11 F7) —
+ 12 535 KB at a path with zero references in any template or handler.
+
+ NOTE — IT STATES TWO FACTS AND STOPS. It does NOT promise the history can be reopened, because it
+ cannot be: serving a superseded package is an unbuilt link (R-199's inventory). A conditional
+ promise that turns out false is worse here than saying less — the R-202 lesson. */}}
+ {{if .Offbox.OrphanedRenamedTo}}
+
+
A korábbi mentéseid félre vannak téve — nem töröltük őket.
+
Amikor új mentési kulcsot kapott a géped, a régebbi előzményt átmozgattuk
+ a távoli tárhelyen, és ott is maradt. Megnyitni innen egyelőre nem lehet, és
+ ez nem a kódodon múlik. Ha szükséged van rá, keresd a Felhom ügyfélszolgálatát.
{{end}}
{{if .Offbox.LastError}}Utolsó hiba: {{.Offbox.LastError}}
{{end}}
diff --git a/controller/internal/web/templates/recovery.html b/controller/internal/web/templates/recovery.html
index 4286c81..a4d6911 100644
--- a/controller/internal/web/templates/recovery.html
+++ b/controller/internal/web/templates/recovery.html
@@ -89,7 +89,9 @@
semmi nem változik. A visszaállítást utána, alkalmazásonként külön választhatod.
-
+ {{/* R-227 — A RESTART MID-UNLOCK MUST NOT SHOW A RAW ENGLISH GATEWAY ERROR.
+
+ Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and
+ the customer got traefik's `Bad Gateway` — a raw upstream error, in English, naming no reason
+ and saying nothing about whether the key was installed. The state was clean; only the page
+ was not. It breaches I3 (every refusal names a reason a person can act on, in Hungarian, with
+ no raw error).
+
+ WHICH LAYER ANSWERS: traefik, and its config IS generated by this repo
+ (internal/infra/templates/traefik*.tmpl). A fully branded proxy error page is therefore
+ possible here — but traefik v3 serves no static files itself, so it would need a new
+ always-up container purely to hold an error page, for every 502 on the box. That is out of
+ proportion to this finding and is scoped in the report rather than built.
+
+ What ships instead is the second sanctioned option: the unlock posts via fetch, so a gateway
+ error or a dropped connection is caught in the page and answered in Hungarian, without
+ leaving it. PROGRESSIVE ENHANCEMENT — with no JS the plain POST is unchanged, and that path
+ still shows the proxy's own error. Said plainly rather than implied. */}}
+
+
A „Most nem” csak azt jelenti, hogy nem zavarunk vele többet a kezdőlapon. A mentéseid ettől
megmaradnak, és ez az oldal a Biztonsági mentés → Távoli mentés oldalról
@@ -118,7 +171,7 @@
Ha megerősíted:
- a korábbi mentéseket félretesszük — nem töröljük;
- - a helyreállítási kód nélkül többé nem lesznek megnyithatók;
+ - a félretett mentések innen többé nem nyithatók meg — sem kóddal, sem anélkül;
- a gép új, üres mentési tárolót kezd, és mostantól oda ment;
- ez az oldal többé nem jelenik meg.