Files
felhom.eu/hub/internal/web/backup_card.go
T
admin f5c9411e5e R-331 (hub half): the Backup card reads offsite, not the dead backup fields (v0.109.0)
The customer page's Backup card read `Snapshots 0 / Repo Size 0 MB / Integrity
Unknown` for EVERY customer, indefinitely. Measured on demo-hp 2026-08-30 while
that night's controller log said `[offbox] backup OK: 8 app(s) backed up, 67
snapshot(s), 2m14s` and the box held snapshot_count:67, repo_size_bytes:
140829678, stats_known:true.

A card reading "no backups" over a working backup is worse than no card -- the
R-88 direction of failure (degrade to NO BACKUP rather than to UNKNOWN) on the
one screen that answers "is this customer protected?".

The data was never missing. The card rendered the report's `backup` object,
whose snapshot/size/integrity fields have had no producer since slice 8C. The
live numbers are in the `offsite` object, which THIS PACKAGE already reads for
the Offsite page and which monitor.OffsiteChecker already alarms from. Proof the
bytes were arriving: the Offsite page rendered demo-hp's usage as 0.1 GB from
that very object while the Backup card said 0 MB. So this is a render fix over
an existing feed, not a new pipeline.

Not a one-line swap, because snapshot_count:0 means two opposite things --
"holds nothing" and "never measured". R-225 measured that confusion one layer
down. backup_card.go resolves a three-way ruling in Go (a {{if}} chain over
map[string]interface{} float64s cannot keep the absent/zero distinction the card
is entirely about):
  no offsite object   -> "No off-site data reported", and says explicitly that
                         this is NOT the same as "no backups"
  disabled + state    -> names the blocker (needs_credential)
  stats_known:false   -> em-dash + "never been measured". NEVER 0
  stats_known:true    -> the real numbers, INCLUDING a real 0

A pre-v0.225.0 controller sends no stats_known -> false -> "unknown". That
direction is pinned: upgrading the hub ahead of the fleet must not report every
un-upgraded customer as having zero backups.

The Integrity row is DELETED, not re-sourced: nothing produces it, the
controller runs no integrity check, and NotifyIntegrityOK/Failed are called from
nowhere.

RED-PROOF: restore the pre-fix card markup -> all four tests fail, reporting 67
and 134.3 MB absent from the rendered page and the Integrity row present. The
tests drive handleCustomerUnified and grep the HTML on purpose: the defect was
the template's choice of source object, so a test one layer below it would have
been green against the shipped bug.

Green gate clean: 18 packages, rc 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB8FmJaGd2cyjvy6dbEjpM
2026-08-30 18:39:15 +02:00

149 lines
7.0 KiB
Go

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)
}
}