package web import ( "fmt" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/osupdates" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts" ) // The System page's layout (2026-10-10): it answers "is anything wrong?" (Needs attention) and "is anything waiting for // me?" (Waiting for you) first, then the boxes in one narrow table whose rows open to every value the old wide table // showed, and folds the rest (release ids, cancelled approvals, ring-0 package counts, floors, the crash guard and root // files) into a closed "Details". The SAME data, buttons, routes and CSRF field as before — only the view changed. // The contract (every old item still on the page) is pinned by TestSystemPage_EveryItemStillOnThePage. // reason is one plain-words line of a box's "Needs attention" entry; its class is the cell's colour. type reason struct { Class, Text, Title string } // systemInput is everything the page shows, read by the handler (or written by a test fixture). buildSystemPage is pure. type systemInput struct { Lines []osupdates.FleetLine Facts map[string]sysfacts.System Names, Controllers, Agents map[string]string // by host id KernelLines map[string]osupdates.KernelLine BundleSince, AgentSince map[string]time.Time Stale, Reboot, NotCov time.Duration BundleAfter, AgentAfter time.Duration VouchedAgent, VouchedBundle string GlobalFloor string Floors []store.CustomerFloorOverride Releases, Cancelled []osupdates.ReleaseInfo Candidates []osupdates.Status Packages func(fingerprint string) []osupdates.Package Nights func(hostID, layer string, since time.Time) int Now time.Time } func buildSystemPage(in systemInput) map[string]interface{} { rows := buildSystemRows(in.Lines, in.Facts, in.Names, in.Stale, in.Reboot, in.NotCov, in.Now) latest := map[string]string{} for _, rel := range in.Releases { latest[rel.Layer] = rel.ID } var attention []systemRow for i := range rows { id := rows[i].HostID rows[i].KernelDefault, rows[i].KernelStep = kernelCells(in.Facts[id], in.KernelLines[id], in.Now) rows[i].Bundle = bundleCell(in.Facts[id], in.VouchedAgent, in.VouchedBundle, in.BundleSince[id], in.BundleAfter, in.Now) rows[i].Agent = agentCell(in.Agents[id], in.VouchedAgent, in.AgentSince[id], in.AgentAfter, in.Now) rows[i].Controller = unknownCell(in.Controllers[id]) summariseRow(&rows[i], latest) if rows[i].Mark.Class != "" { attention = append(attention, rows[i]) } } var ring0 []string for _, l := range in.Lines { if l.Ring == 0 && l.Enabled { ring0 = append(ring0, l.HostID) } } sort.Strings(ring0) cards, testing := buildWaiting(in.Candidates, ring0, in.Packages, in.Nights, in.Now) return map[string]interface{}{ "Rows": rows, "Attention": attention, "Cards": cards, "Testing": testing, "GlobalFloor": in.GlobalFloor, "VouchedAgent": in.VouchedAgent, "Floors": buildFloorRows(in.Floors, in.GlobalFloor, in.Now), "Releases": in.Releases, "Cancelled": in.Cancelled, "Candidates": in.Candidates, } } // labelled names every coloured per-box cell the way "Needs attention" says it. A new cell that can turn amber or red // is added here, or the box's mark would miss it. func (r *systemRow) labelled() []struct { label string c cell } { type lc = struct { label string c cell } return []lc{ {"tunnel", r.Tunnel}, {"Proxmox version", r.PVE}, {"running kernel", r.KernelRunning}, {"next-boot kernel", r.KernelNextBoot}, {"default kernel", r.KernelDefault}, {"kernel step", r.KernelStep}, {"host Debian", r.HostDebian}, {"host updates not covered", r.HostNotCovered}, {"held packages", r.Held}, {"host restart", r.RebootSince}, {"kernel.panic", r.KernelPanic}, {"kernel oops", r.Oops}, {"crash restarts", r.CrashRestarts24h}, {"crash guard", r.Guard}, {"root files", r.Bundle}, {"agent", r.Agent}, {"controller", r.Controller}, {"guest Debian", r.GuestDebian}, {"Docker engine", r.Engine}, {"containerd", r.Containerd}, {"Docker live-restore", r.LiveRestore}, {"disk trim", r.Trim}, {"last OS run", r.LastLeg}, } } func phrase(label string, c cell) string { switch label { case "tunnel": return "tunnel " + strings.ReplaceAll(c.Text, "_", " ") case "next-boot kernel": return "the next boot changes the kernel to " + c.Text case "default kernel": return "a one-shot boot is set: " + c.Text case "kernel step": return "kernel step: " + strings.ReplaceAll(c.Text, "_", " ") case "host updates not covered": return c.Text + " host update(s) that no approved release covers" case "held packages": return "packages held by hand: " + c.Text case "host restart": return "the host needs a restart " + c.Text case "kernel.panic": return "kernel.panic is 0: a crashed box stays off" case "kernel oops": return "a kernel oops this boot" case "crash restarts": return c.Text + " crash restart(s) in the last 24 h" case "crash guard": if strings.HasPrefix(c.Text, "TRIPPED") { return "crash guard tripped: the next crash leaves the box off" } return "crash guard " + c.Text case "root files": if strings.Contains(c.Text, "changed by hand") { return "root files changed by hand" } return "root files behind the vouched agent's" case "agent": return "agent behind: " + c.Text case "Docker live-restore": return "Docker live-restore is off: a Docker step is refused" case "disk trim": return "disk trim: " + c.Text case "last OS run": if c.Class == "bad" { return "no successful OS update run for 7 days or more" } return "the last OS update run did not succeed: " + c.Text } return label + ": " + c.Text } func worse(a, b string) string { if a == "bad" || b == "bad" { return "bad" } if a == "warn" || b == "warn" { return "warn" } return "" } // summariseRow fills the narrow table's cells and the box's mark and reasons from the cells buildSystemRows and the // handler computed — the colours are the same thresholds (`08` §6.3), never a second definition. func summariseRow(r *systemRow, latestRelease map[string]string) { var reasons []reason var unknown []string mark := "" if !r.Enabled { reasons = append(reasons, reason{Class: "warn", Text: "OS updates switched off"}) mark = "warn" } if r.FactsNote != "" { reasons = append(reasons, reason{Class: "warn", Text: r.FactsNote}) mark = worse(mark, "warn") } for _, x := range r.labelled() { if x.c.Class == "" { continue } mark = worse(mark, x.c.Class) if x.c.Text == "unknown" { unknown = append(unknown, x.label) continue } reasons = append(reasons, reason{Class: x.c.Class, Text: phrase(x.label, x.c), Title: x.c.Title}) } if len(unknown) > 0 && r.HasFacts { reasons = append(reasons, reason{Class: "warn", Text: "could not read: " + strings.Join(unknown, ", "), Title: "the box could not read these values (agent older than v0.142.0, or the guest is down) — never a guess"}) } sort.SliceStable(reasons, func(i, j int) bool { return reasons[i].Class == "bad" && reasons[j].Class != "bad" }) r.Reasons = reasons switch mark { case "bad": r.Mark = cell{Text: "alarm", Class: "bad", Title: "an operator alarm fires for this box"} case "warn": r.Mark = cell{Text: "look", Class: "warn", Title: "worth a look"} default: r.Mark = cell{Text: "fine", Title: "nothing amber or red"} } // OS updates: on/off, and whether the box runs the newest approved guest and host releases. if !r.Enabled { r.Updates = cell{Text: "off", Class: "warn", Title: "the box keeps reporting and installs nothing"} } else { var behind []string for _, l := range []struct{ layer, has string }{{osupdates.LayerGuest, r.GuestRelease.Text}, {osupdates.LayerHost, r.HostRelease.Text}} { if want := latestRelease[l.layer]; want != "" && l.has != want { behind = append(behind, l.layer) } } if len(behind) == 0 { r.Updates = cell{Text: "on · up to date", Title: "runs the newest approved guest and host releases"} } else { r.Updates = cell{Text: "on · " + strings.Join(behind, " + ") + " behind", Title: "not on the newest approved release yet — a box takes it at its next night run"} } } r.KernelShort = r.KernelRunning if r.KernelNextBoot.Class != "" && r.KernelNextBoot.Text != "unknown" { r.KernelNext = r.KernelNextBoot.Text } } // lastNightCell is the narrow table's "Last night": the newest OS run in a word (ok / failed / skipped) and when. func lastNightCell(enabled bool, outcome string, at time.Time, leg cell, now time.Time) cell { c := cell{Title: leg.Text + " — " + leg.Title} switch { case !enabled: c.Text = "skipped (updates off)" case outcome == "": c.Text = "no run reported" case outcome == "applied" || outcome == "nothing": c.Text = "ok · " + ago(at, now) case outcome == "failed" || outcome == "health_failed" || outcome == "refused": c.Text, c.Class = strings.ReplaceAll(outcome, "_", " ")+" · "+ago(at, now), "warn" default: c.Text = strings.ReplaceAll(outcome, "_", " ") + " · " + ago(at, now) } if leg.Class == "bad" { c.Class = "bad" c.Text += " · no success for 7+ days" } return c } // waitCard is one "Waiting for you" card: a set the operator's button may approve now. type waitCard struct { Layer, What, Evidence, FirstSeen, After string Action, Button, Confirm string } // testingLine is a set ring 0 runs that is not ready for approval yet, with the hub's own reason. type testingLine struct { What, Why string } type lane struct { name, pkg, action, button, confirm, after string } // lanes are the OPERATOR-approved sets (guest and host approve themselves). The button, route and question are the // ones the page had before 2026-10-10. var lanes = map[string]lane{ osupdates.LayerKernel: {"Kernel", "proxmox-kernel-", "/os/approve-kernel", "Approve kernel set", "Approve this kernel set? Every ring-0 box booted it healthily after a night step. Ring-1 boxes take it only through a signed os_kernel_step, and restart only on a night their household was told about.", "Approving installs nothing by itself: a ring-1 box takes it only through a signed kernel step, and restarts only on a night its household was told about."}, osupdates.LayerDocker: {"Docker engine", "docker-ce", "/os/approve-docker", "Approve Docker set", "Approve this Docker engine set? Ring-1 boxes take it only through a signed operator job.", "Approving installs nothing by itself: a ring-1 box takes it only through a signed operator job."}, osupdates.LayerPVE: {"Proxmox packages", "pve-manager", "/os/approve-pve", "Approve Proxmox set", "Approve this Proxmox package set (no kernel)? Ring-1 boxes take it only through a signed operator job.", "Approving installs nothing by itself: a ring-1 box takes it only through a signed operator job."}, osupdates.LayerGuest: {name: "Guest Debian updates"}, osupdates.LayerHost: {name: "Host Debian updates"}, } func setVersion(ln lane, pkgs []osupdates.Package, count int) string { if ln.pkg != "" { for _, p := range pkgs { if p.Name == ln.pkg || (strings.HasSuffix(ln.pkg, "-") && strings.HasPrefix(p.Name, ln.pkg)) { v := p.Version if i := strings.Index(v, ":"); i >= 0 && i < 3 { v = v[i+1:] // a Debian epoch ("5:29.8.2-1") is not the version a person reads } if i := strings.Index(v, "~"); i > 0 { v = v[:i] // nor is the distribution suffix ("~debian.13~trixie") } return ln.name + " " + v } } } return fmt.Sprintf("%s (%d packages)", ln.name, count) } // buildWaiting splits ring 0's candidate sets into cards (the button may approve now — the SAME gate the page's // buttons always had: a set, not yet approved, nothing waiting) and lines still being tested. func buildWaiting(cands []osupdates.Status, ring0 []string, pkgsOf func(string) []osupdates.Package, nights func(string, string, time.Time) int, now time.Time) ([]waitCard, []testingLine) { var cards []waitCard var testing []testingLine for _, c := range cands { ln, known := lanes[c.Layer] if !known || c.Fingerprint == "" || c.Approved != "" { continue } var pkgs []osupdates.Package if pkgsOf != nil { pkgs = pkgsOf(c.Fingerprint) } what := setVersion(ln, pkgs, c.Packages) if c.Waiting != "" || ln.action == "" { why := c.Waiting if why == "" { why = "the hub approves it by itself" } testing = append(testing, testingLine{What: what, Why: why}) continue } card := waitCard{Layer: c.Layer, What: what, After: ln.after, Action: ln.action, Button: ln.button, Confirm: ln.confirm, FirstSeen: "first seen " + ago(c.FirstSeen, now) + " (" + c.FirstSeen.UTC().Format("2006-01-02 15:04") + " UTC)"} switch { case len(ring0) == 0: card.Evidence = "no ring-0 box" case c.Layer == osupdates.LayerKernel: card.Evidence = "Started without problems after a night step on " + strings.Join(ring0, " and ") + "." default: var per []string for _, h := range ring0 { n := 0 if nights != nil { n = nights(h, c.Layer, c.FirstSeen) } per = append(per, fmt.Sprintf("%s: %d healthy night(s)", h, n)) } card.Evidence = "Ran on every ring-0 box — " + strings.Join(per, ", ") + "." if c.Layer == osupdates.LayerDocker { card.Evidence += " The memory-kill check passed." } } cards = append(cards, card) } return cards, testing }