package web import ( "encoding/json" "fmt" "time" ) // ── R-331 — the operator Backup card told every customer they had no backups ───────────────────── // // THE DEFECT. `customer_unified.html`'s Backup card rendered `snapshot_count`, `repo_size_mb` and // `integrity_ok` out of the report's `backup` object. Those three fields have had NO PRODUCER since // disk-tier restic moved to the host agent (slice 8C): the controller's `buildBackupReport` says so // in a comment and leaves them zero, so the card read `Snapshots 0 · Repo Size 0 MB · Integrity // Unknown` for every customer, forever. Measured on `demo-hp` 2026-08-30, while the box's own log for // that night said `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s), 2m14s`. // // **A card that reads "0 snapshots" over a working backup is worse than no card.** It is the R-88 // direction of failure — degrading to "no backup" rather than to "unknown" — on the one screen an // operator would consult to answer "is this customer protected?". // // THE DATA WAS NEVER MISSING. The live numbers ride in the report's `offsite` object, which this // package already reads for the Offsite page (`offsiteUsageBytes`) and which `monitor.OffsiteChecker` // already drives fill and staleness alarms from. The card was simply pointed at the wrong object. // So this is a RENDER fix over an existing feed, not a new pipeline — and `integrity_ok` is dropped // rather than re-sourced, because nothing in the controller has produced it since 8C either: // `NotifyIntegrityOK` / `NotifyIntegrityFailed` exist and are called from nowhere. // // WHAT MADE IT MORE THAN A ONE-LINE TEMPLATE SWAP. `snapshot_count: 0` means two opposite things — // "this repository holds nothing" and "nobody has ever measured this repository" — and the report // could not tell them apart until controller v0.225.0 started forwarding `stats_known`. R-225 // measured that exact confusion one layer down: a rebuilt box rendered „Tároló méret · 0 pillanatkép" // over a store that really held snapshot `f3d9cd67`. Rendering the count without consulting // `stats_known` would have moved R-225 from the controller UI to the hub UI instead of fixing it. // backupCardView is what the Backup card renders. Every field is resolved in Go rather than in the // template, so the three-way "no data / unknown / known" ruling is unit-testable and lives in one // place — a template `{{if}}` chain over a `map[string]interface{}` of float64s is neither. type backupCardView struct { // --- local app-data leg (the report's `backup` object) --- // These two are the ONLY fields of that object with a live producer, which is why nothing else // from it appears on the card any more. LocalEnabled bool LastDBDump string // humanised; "—" when the box has never dumped // --- off-site leg (the report's `offsite` object) --- // OffsiteReported is false when the report carried no `offsite` object at all: a pre-v0.109.0 // controller, or a box that has never had the tier. The card then says so and shows NO numbers — // the same rule `offsiteBoxTile` already follows ("absent, not a zeroed tile"). OffsiteReported bool OffsiteEnabled bool // OffsiteState carries a DECLARED state (`needs_credential`, `awaiting_recovery_key`) for a box // that reports an object while disabled. Rendering it is the difference between "no off-site // backup" and "off-site backup is blocked waiting for a key" — an operator action item. OffsiteState string // StatsKnown is false both when the repository was never measured AND when the controller is too // old to say. Both are ignorance, and the card must show ignorance, never zero. StatsKnown bool Snapshots int RepoSize string // humanised; only meaningful when StatsKnown LastSuccess string // humanised age of the last run that actually SUCCEEDED; "—" when never LastStatus string // "ok" | "incomplete" | "error" | "running" QuotaStr string // "" when the target has no soft quota (dedicated boxes) } // reportBackupCard parses one customer's stored report into the card view. A report that will not // parse yields a zero view, which renders as "nothing reported" — never as zeroed numbers. // // It takes the raw JSON rather than the already-parsed `map[string]interface{}` the page also holds, // for the reason the sibling readers in this file's package do: typed decoding keeps the int/float64 // and absent/zero distinctions that a generic map destroys, and those distinctions are the whole // subject of this card. func reportBackupCard(reportJSON string) backupCardView { var r struct { Backup *struct { Enabled bool `json:"enabled"` LastDBDump *time.Time `json:"last_db_dump"` } `json:"backup"` Offsite *struct { Enabled bool `json:"enabled"` State string `json:"state"` LastStatus string `json:"last_status"` LastSuccess string `json:"last_success"` SnapshotCount int `json:"snapshot_count"` RepoSizeBytes int64 `json:"repo_size_bytes"` QuotaGB int `json:"quota_gb"` // StatsKnown is ABSENT on a controller below v0.225.0, and absent unmarshals to false — // which is the correct fail-safe direction: a box that cannot answer is unknown, never // empty. See OffboxReportStatus.StatsKnown in the controller. StatsKnown bool `json:"stats_known"` } `json:"offsite"` } var v backupCardView if json.Unmarshal([]byte(reportJSON), &r) != nil { return v } if r.Backup != nil { v.LocalEnabled = r.Backup.Enabled v.LastDBDump = "—" if r.Backup.LastDBDump != nil && !r.Backup.LastDBDump.IsZero() { v.LastDBDump = timeAgo(*r.Backup.LastDBDump) } } if r.Offsite == nil { return v } v.OffsiteReported = true v.OffsiteEnabled = r.Offsite.Enabled v.OffsiteState = r.Offsite.State v.LastStatus = r.Offsite.LastStatus v.StatsKnown = r.Offsite.StatsKnown if v.StatsKnown { v.Snapshots = r.Offsite.SnapshotCount v.RepoSize = fmtBytesAuto(r.Offsite.RepoSizeBytes) } v.LastSuccess = "—" if t, err := time.Parse(time.RFC3339, r.Offsite.LastSuccess); err == nil { v.LastSuccess = timeAgo(t) } if r.Offsite.QuotaGB > 0 { v.QuotaStr = fmt.Sprintf("%d GB", r.Offsite.QuotaGB) } return v } // fmtBytesAuto scales to the unit that keeps a repository size readable. The Offsite page's // fmtBytesGB is deliberately NOT reused here: it is fixed at GB because it renders against GB quotas // where a common unit is the point, and it turns demo-hp's real 140 829 678 bytes into "0.1 GB" — // which on a card whose entire defect was under-reporting a real backup reads as "nearly nothing". func fmtBytesAuto(b int64) string { switch { case b >= 1<<40: return fmt.Sprintf("%.2f TB", float64(b)/float64(int64(1)<<40)) case b >= 1<<30: return fmt.Sprintf("%.1f GB", float64(b)/float64(int64(1)<<30)) case b >= 1<<20: return fmt.Sprintf("%.1f MB", float64(b)/float64(int64(1)<<20)) case b >= 1<<10: return fmt.Sprintf("%.1f KB", float64(b)/float64(int64(1)<<10)) default: return fmt.Sprintf("%d B", b) } }