cf6fec8d87
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
345 lines
13 KiB
Go
345 lines
13 KiB
Go
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
|
|
}
|