hub (unreleased): the System page answers 'is anything wrong?' and 'is anything waiting for me?' first
gates / gates (push) Successful in 6m17s
gates / gates (push) Successful in 6m17s
Needs attention, Waiting for you (one card per kernel/Docker/Proxmox set the button may approve), a 7-column Boxes table whose rows open to every old value, and a closed Details (release ids, cancelled approvals, ring-0 counts, floors, crash guard and root files, Approve now - which now asks first). Same data, buttons, routes and CSRF field. No deploy. Screenshots in audits/hub-system-page-2026-10-10/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user