diff --git a/hub/internal/sysfacts/sysfacts.go b/hub/internal/sysfacts/sysfacts.go index e17bd383..00e2b0a6 100644 --- a/hub/internal/sysfacts/sysfacts.go +++ b/hub/internal/sysfacts/sysfacts.go @@ -59,6 +59,28 @@ type ConfigBundle struct { Drift []string `json:"drift"` } +// DiskTrim is the agent's weekly guest disk trim stanza (R-444, `09` §3 decision 139): the host report's TOP-LEVEL +// `guest_disk_trim` object (agent internal/hub GuestDiskTrimStatus). A field-by-field mirror of the agent's type, +// checked BOTH ways by the wire-contract gate (SUBTREE_MIRRORS in scripts/wire_contract_gate.py), so a rename or a new +// field on either side convicts. +type DiskTrim struct { + Schedule string `json:"schedule"` + Guests []GuestTrim `json:"guests"` +} + +// GuestTrim is one guest's LAST trim attempt. `ok` with `last_attempt_at` is that attempt's verdict; `last_ok_at` is +// the last attempt that succeeded ("" = never) — staleness is judged on it, never on the attempt time alone. +type GuestTrim struct { + VMID int `json:"vmid"` + LastAttemptAt string `json:"last_attempt_at"` // RFC3339 + OK bool `json:"ok"` + BytesTrimmed int64 `json:"bytes_trimmed"` + Mounts int `json:"mounts"` + DurationSeconds float64 `json:"duration_seconds"` + LastOKAt string `json:"last_ok_at"` // RFC3339, "" = never + Error string `json:"error"` +} + // System is the whole stanza. Present is false for a report from an agent older than v0.142.0. type System struct { Present bool @@ -70,10 +92,14 @@ type System struct { Host Host Guest Guest Bundle ConfigBundle + // DiskTrim is the top-level `guest_disk_trim` stanza (R-444). Independent of the `system` stanza: it is read even + // when Present is false. nil = the agent sent none (an agent with no trimmer). + DiskTrim *DiskTrim } type wire struct { - System *struct { + DiskTrim *DiskTrim `json:"guest_disk_trim"` + System *struct { PVEVersion string `json:"pve_version"` KernelVersion string `json:"kernel_version"` VMID int `json:"vmid"` @@ -97,7 +123,11 @@ func Parse(reportJSON string) System { Host: Host{Debian: Unknown, KernelRunning: Unknown, KernelNextBoot: Unknown}, Guest: Guest{Debian: Unknown, DockerEngine: Unknown, Containerd: Unknown, LiveRestore: Unknown}} var w wire - if json.Unmarshal([]byte(reportJSON), &w) != nil || w.System == nil { + if json.Unmarshal([]byte(reportJSON), &w) != nil { + return out + } + out.DiskTrim = w.DiskTrim + if w.System == nil { return out } out.Present = true diff --git a/hub/internal/sysfacts/sysfacts_test.go b/hub/internal/sysfacts/sysfacts_test.go index b9046c54..b98a5188 100644 --- a/hub/internal/sysfacts/sysfacts_test.go +++ b/hub/internal/sysfacts/sysfacts_test.go @@ -41,3 +41,32 @@ func TestParse_AbsentAndPartialAreUnknown(t *testing.T) { t.Fatal("unknown kernels must not read as 'differs'") } } + +// R-444: the top-level `guest_disk_trim` stanza is read on its own — with or without a `system` stanza — and an absent +// stanza stays nil (the page says "—", never a made-up trim). The fixture is the agent's TestReportJSONShape output shape. +func TestParse_DiskTrim(t *testing.T) { + s := Parse(`{"host":{},"guest_disk_trim":{"schedule":"weekly, Wednesday from 10:00","guests":[ +{"vmid":9201,"last_attempt_at":"2026-10-07T08:30:00Z","ok":true,"bytes_trimmed":90143313920,"mounts":2,"duration_seconds":24.4,"last_ok_at":"2026-10-07T08:30:00Z"}, +{"vmid":9202,"last_attempt_at":"2026-10-07T08:35:00Z","ok":false,"bytes_trimmed":0,"mounts":0,"duration_seconds":1.2,"error":"pct fstrim: exit 255"}]}}`) + if s.Present { + t.Error("no system stanza: Present must stay false") + } + d := s.DiskTrim + if d == nil || d.Schedule != "weekly, Wednesday from 10:00" || len(d.Guests) != 2 { + t.Fatalf("guest_disk_trim misread: %+v", d) + } + g, f := d.Guests[0], d.Guests[1] + if g.VMID != 9201 || !g.OK || g.BytesTrimmed != 90143313920 || g.Mounts != 2 || g.DurationSeconds != 24.4 || + g.LastAttemptAt != "2026-10-07T08:30:00Z" || g.LastOKAt != "2026-10-07T08:30:00Z" { + t.Errorf("guest 9201 misread: %+v", g) + } + if f.OK || f.Error != "pct fstrim: exit 255" || f.LastOKAt != "" { + t.Errorf("guest 9202 misread: %+v", f) + } + if s = Parse(full); s.DiskTrim != nil { + t.Errorf("a report with no guest_disk_trim must read nil, got %+v", s.DiskTrim) + } + if s = Parse(`{"system":{"pve_version":"x"},"guest_disk_trim":{"schedule":"w","guests":[]}}`); !s.Present || s.DiskTrim == nil || len(s.DiskTrim.Guests) != 0 { + t.Errorf("with a system stanza the trim must still be read: %+v", s) + } +} diff --git a/hub/internal/web/system.go b/hub/internal/web/system.go index 82f9398f..3e9d3035 100644 --- a/hub/internal/web/system.go +++ b/hub/internal/web/system.go @@ -40,6 +40,7 @@ type systemRow struct { Agent cell // R-530: the box's agent against the vouched one // guest GuestDebian, GuestRelease, GuestPending, GuestRestart cell + Trim cell // R-444: the last `pct fstrim` of the guest // docker Engine, Containerd, LiveRestore, DockerRelease cell // last leg @@ -112,6 +113,77 @@ func buildFloorRows(ovs []store.CustomerFloorOverride, global string, now time.T func plain(s string) cell { return cell{Text: s} } +// trimStaleAfter is when a guest's last SUCCESSFUL disk trim reads amber: the boxes trim weekly (`09` §3 decision +// 139), so two missed weeks are worth a look. +const trimStaleAfter = 14 * 24 * time.Hour + +// trimCell is the "Last disk trim" cell (R-444): per guest, the last successful `pct fstrim` (from `last_ok_at`) and what +// the newest attempt freed. Amber when the newest attempt failed (its error is shown) or the last success is older than +// 14 days — judged on `last_ok_at`, never on the attempt time (an attempt is not a result). "—" when the agent sends no +// stanza (no trimmer); an unreadable time is "unknown", never a guess. +func trimCell(dt *sysfacts.DiskTrim, now time.Time) cell { + if dt == nil { + return cell{Text: "—", Title: "the agent reports no disk trim (an agent without the weekly trim)"} + } + if len(dt.Guests) == 0 { + return cell{Text: "none yet", Title: "the weekly trim runs (" + dt.Schedule + ") and has not trimmed a guest yet"} + } + out := cell{Title: "schedule: " + dt.Schedule} + var texts []string + for _, g := range dt.Guests { + t, warn, why := guestTrimText(g, now) + if len(dt.Guests) > 1 { + t = fmt.Sprintf("%d: %s", g.VMID, t) + } + texts = append(texts, t) + if warn { + out.Class = "warn" + out.Title = fmt.Sprintf("guest %d: %s — %s", g.VMID, why, out.Title) + } + } + out.Text = strings.Join(texts, " / ") + return out +} + +func guestTrimText(g sysfacts.GuestTrim, now time.Time) (text string, warn bool, why string) { + var okAt time.Time + if g.LastOKAt != "" { + t, err := time.Parse(time.RFC3339, g.LastOKAt) + if err != nil { + return "unknown", true, fmt.Sprintf("unreadable last_ok_at %q", g.LastOKAt) + } + okAt = t + } + attAt, aerr := time.Parse(time.RFC3339, g.LastAttemptAt) + if aerr != nil { + return "unknown", true, fmt.Sprintf("unreadable last_attempt_at %q", g.LastAttemptAt) + } + if g.OK { + text = fmt.Sprintf("%s · %.1f GiB", ago(attAt, now), float64(g.BytesTrimmed)/(1<<30)) + if !okAt.IsZero() { + text = fmt.Sprintf("%s · %.1f GiB", ago(okAt, now), float64(g.BytesTrimmed)/(1<<30)) + } else { + okAt = attAt + } + } else { + last := "never ok" + if !okAt.IsZero() { + last = "last ok " + ago(okAt, now) + } + errText := g.Error + if errText == "" { + errText = "no error text" + } + text = fmt.Sprintf("FAILED %s: %s (%s)", ago(attAt, now), errText, last) + warn, why = true, "the newest trim failed" + } + if !okAt.IsZero() && now.Sub(okAt) > trimStaleAfter { + warn = true + why = strings.TrimPrefix(why+"; ", "; ") + "the last successful trim is older than 14 days (the boxes trim weekly)" + } + return text, warn, why +} + // bundleCell is the "Root files" cell (R-840): the box's config bundle against the vouched agent's. Amber while behind, // red from the alarm's wait on, amber when a file was changed by hand (drift); "unknown" is never coloured as a fact. func bundleCell(f sysfacts.System, vouchedAgent, vouchedSHA string, since time.Time, after time.Duration, now time.Time) cell { @@ -239,6 +311,7 @@ func buildSystemRows(lines []osupdates.FleetLine, facts map[string]sysfacts.Syst r.GuestRelease = plain(orDash(l.Guest.ReleaseID)) r.GuestPending = plain(fmt.Sprint(l.Guest.Pending)) r.GuestRestart = plain(fmt.Sprint(l.Guest.RestartNeeded)) + r.Trim = trimCell(f.DiskTrim, now) r.Engine, r.Containerd = unknownCell(f.Guest.DockerEngine), unknownCell(f.Guest.Containerd) r.LiveRestore = unknownCell(f.Guest.LiveRestore) if f.Guest.LiveRestore == "off" { @@ -312,16 +385,16 @@ func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[ERROR] system page: floor overrides: %v", oerr) } data := map[string]interface{}{ - "Rows": rows, + "Rows": rows, "GlobalFloor": global, "VouchedAgent": man.AgentVersion, "Floors": buildFloorRows(ovs, global, time.Now()), - "Releases": view.Releases(), - "Cancelled": view.CancelledReleases(), - "Candidates": view.Candidates(), - "Flash": r.URL.Query().Get("flash"), - "FlashErr": r.URL.Query().Get("err"), - "CSRFToken": s.getCSRFToken(r), + "Releases": view.Releases(), + "Cancelled": view.CancelledReleases(), + "Candidates": view.Candidates(), + "Flash": r.URL.Query().Get("flash"), + "FlashErr": r.URL.Query().Get("err"), + "CSRFToken": s.getCSRFToken(r), } if err := s.templates.ExecuteTemplate(w, "system.html", data); err != nil { s.logger.Printf("[ERROR] system.html template: %v", err) diff --git a/hub/internal/web/system_trim_test.go b/hub/internal/web/system_trim_test.go new file mode 100644 index 00000000..1eb8c1c9 --- /dev/null +++ b/hub/internal/web/system_trim_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "fmt" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts" +) + +// R-444 (`09` §3 decision 139): the System page's "Last disk trim" cell — one case per branch. Staleness is judged on +// last_ok_at, never on the attempt time ("presence is not success"). +func TestTrimCell(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + at := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } + day := 24 * time.Hour + dt := func(gs ...sysfacts.GuestTrim) *sysfacts.DiskTrim { + return &sysfacts.DiskTrim{Schedule: "weekly", Guests: gs} + } + cases := []struct { + name string + dt *sysfacts.DiskTrim + text string + class string + titleHas string + }{ + {"agent sends nothing", nil, "—", "", "reports no disk trim"}, + {"stanza, no guest yet", dt(), "none yet", "", "has not trimmed a guest yet"}, + {"recent ok", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(2 * day), LastOKAt: at(2 * day), OK: true, BytesTrimmed: 32427003904}), + "2 days ago · 30.2 GiB", "", "schedule: weekly"}, + {"ok but older than 14 days", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(15 * day), LastOKAt: at(15 * day), OK: true, BytesTrimmed: 1 << 30}), + "15 days ago · 1.0 GiB", "warn", "older than 14 days"}, + {"exactly 14 days is not stale", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(14 * day), LastOKAt: at(14 * day), OK: true}), + "14 days ago · 0.0 GiB", "", ""}, + {"newest failed, recent success before it", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(1 * day), LastOKAt: at(8 * day), OK: false, Error: "pct fstrim: exit 255"}), + "FAILED 24 h ago: pct fstrim: exit 255 (last ok 8 days ago)", "warn", "the newest trim failed"}, + {"failed and never ok", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(1 * day), OK: false}), + "FAILED 24 h ago: no error text (never ok)", "warn", "the newest trim failed"}, + {"a recent ATTEMPT does not hide a 20-day-old success", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(1 * day), LastOKAt: at(20 * day), OK: false, Error: "busy"}), + "FAILED 24 h ago: busy (last ok 20 days ago)", "warn", "older than 14 days"}, + {"two guests, one amber", dt( + sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(2 * day), LastOKAt: at(2 * day), OK: true, BytesTrimmed: 2 << 30}, + sysfacts.GuestTrim{VMID: 9202, LastAttemptAt: at(1 * day), OK: false, Error: "x"}), + "9201: 2 days ago · 2.0 GiB / 9202: FAILED 24 h ago: x (never ok)", "warn", "guest 9202"}, + {"unreadable time", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: "yesterday", OK: true}), "unknown", "warn", "unreadable last_attempt_at"}, + } + for _, c := range cases { + got := trimCell(c.dt, now) + if got.Text != c.text || got.Class != c.class || !strings.Contains(got.Title, c.titleHas) { + t.Errorf("%s: got %+v, want text %q class %q title containing %q", c.name, got, c.text, c.class, c.titleHas) + } + } +} + +// The cell reaches the rendered page, read from the stored host report (the endpoint the operator's browser calls). +func TestSystemPage_LastDiskTrim(t *testing.T) { + s, st, _ := systemServer(t) + when := time.Now().Add(-20 * 24 * time.Hour).UTC().Format(time.RFC3339) + body := fmt.Sprintf(`{"host":{"cpu_percent":1},"guest_disk_trim":{"schedule":"weekly","guests":[{"vmid":9201,"last_attempt_at":%q,"ok":true,"bytes_trimmed":32427003904,"mounts":2,"duration_seconds":24.4,"last_ok_at":%q}]}}`, when, when) + if err := st.UpsertHost(&store.Host{HostID: "trim-1", CustomerID: "c-trim", APIKey: "k-trim"}); err != nil { + t.Fatal(err) + } + if err := st.SaveHostReport("trim-1", "c-trim", []byte(body), store.HostReportDenorm{AgentVersion: "0.150.0", CloudflaredStatus: "running"}); err != nil { + t.Fatal(err) + } + b := getSystem(t, s) + if !strings.Contains(b, ">Last disk trim") { + t.Error("the System page lacks the Last disk trim column") + } + if !strings.Contains(b, "20 days ago · 30.2 GiB") { + t.Errorf("the trim-1 box's trim is not on the page") + } + if !strings.Contains(b, `class="c-warn" title="guest 9201: the last successful trim is older than 14 days`) { + t.Error("a 20-day-old trim must be amber, with its reason") + } + if !strings.Contains(b, `title="the agent reports no disk trim`) { + t.Error("a box whose agent sends no trim must read — with its reason") + } +} diff --git a/hub/internal/web/templates/system.html b/hub/internal/web/templates/system.html index c8372eee..5d5e8dcd 100644 --- a/hub/internal/web/templates/system.html +++ b/hub/internal/web/templates/system.html @@ -104,11 +104,11 @@