hub (unreleased): the System page answers 'is anything wrong?' and 'is anything waiting for me?' first
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:
2026-10-10 15:38:25 +02:00
parent ec5605d42c
commit cf6fec8d87
16 changed files with 4365 additions and 108 deletions
+54 -31
View File
@@ -1,6 +1,7 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"sort"
@@ -46,6 +47,10 @@ type systemRow struct {
Engine, Containerd, LiveRestore, DockerRelease cell
// last leg
LastLeg cell
// the narrow table and "Needs attention" (system_view.go)
Controller, Mark, Updates, KernelShort, LastNight cell
KernelNext string
Reasons []reason
}
// OSSystemView is what the System page needs beyond OSUpdateAdmin (implemented by *osupdates.Service).
@@ -58,8 +63,8 @@ type OSSystemView interface {
BundleThreshold() time.Duration
AgentThreshold() time.Duration
ApproveDocker() (string, error)
ApprovePVE() (string, error) // R-812 option A: the Proxmox package set
ApproveKernel() (string, error) // R-836: the kernel set
ApprovePVE() (string, error) // R-812 option A: the Proxmox package set
ApproveKernel() (string, error) // R-836: the kernel set
KernelLineFor(hostID string) osupdates.KernelLine // R-836: one box's kernel step and day-before mail
}
@@ -383,6 +388,7 @@ func buildSystemRows(lines []osupdates.FleetLine, facts map[string]sysfacts.Syst
} else if last.LastOutcome == "health_failed" || last.LastOutcome == "failed" || last.LastOutcome == "refused" {
r.LastLeg.Class = "warn"
}
r.LastNight = lastNightCell(l.Enabled, last.LastOutcome, last.LastAt, r.LastLeg, now)
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].HostID < rows[j].HostID })
@@ -409,44 +415,61 @@ func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
return
}
hosts, _ := s.store.ListHosts()
facts, names := map[string]sysfacts.System{}, map[string]string{}
for _, h := range hosts {
names[h.HostID] = s.customerName(h.CustomerID)
if rj, _ := s.store.GetLatestHostReportJSON(h.CustomerID); rj != "" {
facts[h.HostID] = sysfacts.Parse(rj)
now := time.Now()
in := systemInput{Lines: lines, Facts: map[string]sysfacts.System{}, Names: map[string]string{}, Controllers: map[string]string{},
Agents: map[string]string{}, KernelLines: map[string]osupdates.KernelLine{}, BundleSince: map[string]time.Time{},
AgentSince: map[string]time.Time{}, BundleAfter: view.BundleThreshold(), AgentAfter: view.AgentThreshold(),
GlobalFloor: s.store.GetGlobalMinControllerVersion(), Releases: view.Releases(), Cancelled: view.CancelledReleases(),
Candidates: view.Candidates(), Now: now}
in.Stale, in.Reboot, in.NotCov = view.Thresholds()
ctrl := map[string]string{}
if cs, cerr := s.store.GetCustomers(); cerr != nil {
s.logger.Printf("[ERROR] system page: customers: %v", cerr)
} else {
for _, c := range cs {
ctrl[c.CustomerID] = c.ControllerVersion
}
}
stale, reboot, notCov := view.Thresholds()
rows := buildSystemRows(lines, facts, names, stale, reboot, notCov, time.Now())
man := s.store.GetArtifactManifest()
agents := map[string]string{}
for _, h := range hosts {
agents[h.HostID] = h.AgentVersion
in.Names[h.HostID] = s.customerName(h.CustomerID)
in.Agents[h.HostID] = h.AgentVersion
in.Controllers[h.HostID] = ctrl[h.CustomerID]
if rj, _ := s.store.GetLatestHostReportJSON(h.CustomerID); rj != "" {
in.Facts[h.HostID] = sysfacts.Parse(rj)
}
in.KernelLines[h.HostID] = view.KernelLineFor(h.HostID)
in.BundleSince[h.HostID] = s.store.BundleBehindSince(h.HostID)
in.AgentSince[h.HostID] = s.store.AgentBehindSince(h.HostID)
}
for i := range rows {
rows[i].KernelDefault, rows[i].KernelStep = kernelCells(facts[rows[i].HostID], view.KernelLineFor(rows[i].HostID), time.Now())
rows[i].Bundle = bundleCell(facts[rows[i].HostID], man.AgentVersion, man.BundleSHA256,
s.store.BundleBehindSince(rows[i].HostID), view.BundleThreshold(), time.Now())
rows[i].Agent = agentCell(agents[rows[i].HostID], man.AgentVersion,
s.store.AgentBehindSince(rows[i].HostID), view.AgentThreshold(), time.Now())
}
global := s.store.GetGlobalMinControllerVersion()
man := s.store.GetArtifactManifest()
in.VouchedAgent, in.VouchedBundle = man.AgentVersion, man.BundleSHA256
ovs, oerr := s.store.CustomerFloorOverrides()
if oerr != nil {
s.logger.Printf("[ERROR] system page: floor overrides: %v", oerr)
}
data := map[string]interface{}{
"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),
in.Floors = ovs
in.Packages = func(fp string) []osupdates.Package {
var list []osupdates.Package
if pj, _ := s.store.OSCandidatePackages(fp); pj != "" {
_ = json.Unmarshal([]byte(pj), &list)
}
return list
}
// A healthy night run of the layer since the set was first seen: the same count the approval rule makes.
in.Nights = func(hostID, layer string, since time.Time) int {
reps, _ := s.store.OSReportsSince(hostID, layer, since)
n := 0
for _, rep := range reps {
if rep.Trigger == "night" && rep.Healthy && rep.Outcome != "failed" && rep.Outcome != "refused" && rep.Outcome != "health_failed" {
n++
}
}
return n
}
data := buildSystemPage(in)
data["Flash"] = r.URL.Query().Get("flash")
data["FlashErr"] = r.URL.Query().Get("err")
data["CSRFToken"] = s.getCSRFToken(r)
if err := s.templates.ExecuteTemplate(w, "system.html", data); err != nil {
s.logger.Printf("[ERROR] system.html template: %v", err)
}
+334
View File
@@ -0,0 +1,334 @@
package web
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"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 2026-10-10 layout: Needs attention, Waiting for you, Boxes, Details. These tests render the page
// from a fixture shaped like the fleet on 2026-10-10 (made-up host ids, no real key or address).
var fixNow = time.Date(2026, 10, 10, 15, 17, 0, 0, time.UTC)
func fixFacts(kernel, nextBoot string, trimAgo time.Duration) sysfacts.System {
trim := fixNow.Add(-trimAgo).Format(time.RFC3339)
return sysfacts.Parse(fmt.Sprintf(`{"host":{"cpu_percent":1},"guest_disk_trim":{"schedule":"weekly","guests":[{"vmid":9201,
"last_attempt_at":%q,"last_ok_at":%q,"ok":true,"bytes_trimmed":3221225472}]},
"system":{"pve_version":"pve-manager/9.0.11/abc","kernel_version":"Linux %s #1","vmid":9201,
"config_bundle":{"version":"0.155.0","bundle_sha256":"vouched-sha"},
"facts":{"host":{"debian":"13.7","kernel_running":%q,"kernel_next_boot":%q,"kernel_next_boot_source":"saved default","held":[],
"kernel_panic":10,"oops_this_boot":false,"crash_guard":{"armed":true,"tripped":false,"unclean_boots_24h":0},
"kernel_lane":{"running":%q,"default":%q,"phase":"none"}},
"guest":{"debian":"13.7","docker_engine":"29.8.2","containerd":"2.3.6-1~debian.13~trixie","live_restore":"on"}}}}`,
trim, trim, kernel, kernel, nextBoot, kernel, nextBoot))
}
func fixLeg(rel, outcome string, ago time.Duration) osupdates.LayerLine {
at := fixNow.Add(-ago)
return osupdates.LayerLine{ReleaseID: rel, LastOutcome: outcome, LastAt: at, LastSuccessfulLeg: at, WrapperPassSeconds: 41}
}
// fixtureInput is today's real situation, made up: two ring-0 demo boxes, two testers; the kernel set approved, the
// Docker set still being tested, Tester 2 on an agent that reports no versions ("unknown"); four TEST approvals
// cancelled. readyPVE adds a Proxmox set ready to approve (a "Waiting for you" card).
func fixtureInput(readyPVE bool) systemInput {
g, h := "os-guest-20261009-031500", "os-host-20261009-031500"
lines := []osupdates.FleetLine{
{HostID: "demo-felhom-a1b2c3", Ring: 0, Enabled: true, Tunnel: "running", Guest: fixLeg(g, "applied", 12*time.Hour), Host: fixLeg(h, "nothing", 12*time.Hour), Docker: fixLeg("os-docker-20261001-031500", "nothing", 12*time.Hour)},
{HostID: "demo-hp-d4e5f6", Ring: 0, Enabled: true, Tunnel: "running", Guest: fixLeg(g, "applied", 12*time.Hour), Host: fixLeg(h, "applied", 12*time.Hour), Docker: fixLeg("os-docker-20261001-031500", "nothing", 12*time.Hour)},
{HostID: "tester1-0f1e2d", Ring: 1, Enabled: true, Tunnel: "running", Guest: fixLeg("os-guest-20261008-031500", "applied", 36*time.Hour), Host: fixLeg(h, "nothing", 12*time.Hour)},
{HostID: "tester2-9a8b7c", Ring: 1, Enabled: true, Tunnel: "running", Guest: fixLeg(g, "nothing", 12*time.Hour), Host: fixLeg(h, "nothing", 12*time.Hour)},
}
k := "7.0.14-23-pve"
facts := map[string]sysfacts.System{
"demo-felhom-a1b2c3": fixFacts(k, k, 2*24*time.Hour),
"demo-hp-d4e5f6": fixFacts(k, k, 3*24*time.Hour),
"tester1-0f1e2d": fixFacts("7.0.14-20-pve", "7.0.14-20-pve", 4*24*time.Hour),
"tester2-9a8b7c": sysfacts.Parse(`{"host":{"cpu_percent":1}}`),
}
approved := func(layer, id string, n int, test bool) osupdates.ReleaseInfo {
return osupdates.ReleaseInfo{Layer: layer, ID: id, ApprovedAt: fixNow.Add(-30 * time.Hour), ApprovedBy: "auto", Packages: n, Test: test}
}
rels := []osupdates.ReleaseInfo{approved("guest", g, 214, false), approved("host", h, 389, false),
approved("docker", "os-docker-20261001-031500", 4, false), approved("pve", "os-pve-20261007-090000", 31, false),
approved("kernel", "os-kernel-20261010-091200", 2, false)}
rels[4].ApprovedBy = "operator"
var cancelled []osupdates.ReleaseInfo
for i, l := range []string{"guest", "host", "docker", "pve"} {
c := approved(l, fmt.Sprintf("os-%s-20261006-1%d0000", l, i), 3, true)
c.Cancelled = "2026-10-07 08:00:00"
cancelled = append(cancelled, c)
}
cands := []osupdates.Status{
{Layer: "guest", Fingerprint: "fp-g", FirstSeen: fixNow.Add(-36 * time.Hour), Packages: 214, Approved: g},
{Layer: "host", Fingerprint: "fp-h", FirstSeen: fixNow.Add(-36 * time.Hour), Packages: 389, Approved: h},
{Layer: "docker", Fingerprint: "fp-d", FirstSeen: fixNow.Add(-20 * time.Hour), Packages: 4,
Waiting: "demo-hp-d4e5f6 has 1 of 2 healthy night Docker step(s) with this set"},
{Layer: "pve", Fingerprint: "fp-p", FirstSeen: fixNow.Add(-4 * 24 * time.Hour), Packages: 31, Approved: "os-pve-20261007-090000", Waiting: "already approved"},
{Layer: "kernel", Fingerprint: "fp-k", FirstSeen: fixNow.Add(-26 * time.Hour), Packages: 2, Approved: "os-kernel-20261010-091200", Waiting: "already approved"},
}
if readyPVE {
cands[3] = osupdates.Status{Layer: "pve", Fingerprint: "fp-p2", FirstSeen: fixNow.Add(-50 * time.Hour), Packages: 33}
}
pkgs := map[string][]osupdates.Package{
"fp-d": {{Name: "containerd.io", Version: "2.3.7-1"}, {Name: "docker-ce", Version: "5:29.8.3-1~debian.13~trixie"}},
"fp-p2": {{Name: "pve-manager", Version: "9.0.12"}, {Name: "libpve-common-perl", Version: "9.0.8"}},
"fp-k": {{Name: "proxmox-kernel-7.0", Version: "7.0.14-23"}},
}
return systemInput{
Lines: lines, Facts: facts,
Names: map[string]string{"demo-felhom-a1b2c3": "Demo N100", "demo-hp-d4e5f6": "Demo HP", "tester1-0f1e2d": "Tester 1", "tester2-9a8b7c": "Tester 2"},
Controllers: map[string]string{"demo-felhom-a1b2c3": "0.232.0", "demo-hp-d4e5f6": "0.232.0", "tester1-0f1e2d": "0.231.0", "tester2-9a8b7c": "0.229.0"},
Agents: map[string]string{"demo-felhom-a1b2c3": "0.156.0", "demo-hp-d4e5f6": "0.156.0", "tester1-0f1e2d": "0.155.0", "tester2-9a8b7c": "0.141.0"},
KernelLines: map[string]osupdates.KernelLine{
"demo-felhom-a1b2c3": {LastOutcome: "applied", LastKernel: k, LastAt: fixNow.Add(-30 * time.Hour)},
"demo-hp-d4e5f6": {LastOutcome: "applied", LastKernel: k, LastAt: fixNow.Add(-30 * time.Hour)},
"tester1-0f1e2d": {Due: k},
},
BundleSince: map[string]time.Time{}, AgentSince: map[string]time.Time{"tester1-0f1e2d": fixNow.Add(-2 * 24 * time.Hour), "tester2-9a8b7c": fixNow.Add(-9 * 24 * time.Hour)},
Stale: 7 * 24 * time.Hour, Reboot: 14 * 24 * time.Hour, NotCov: 14 * 24 * time.Hour,
BundleAfter: 7 * 24 * time.Hour, AgentAfter: 7 * 24 * time.Hour,
VouchedAgent: "0.156.0", VouchedBundle: "vouched-sha", GlobalFloor: "0.229.0",
Floors: []store.CustomerFloorOverride{{CustomerID: "c-tester1", CustomerName: "Tester 1", Version: "0.231.0", SetAt: fixNow.Add(-5 * 24 * time.Hour)}},
Releases: rels, Cancelled: cancelled, Candidates: cands,
Packages: func(fp string) []osupdates.Package { return pkgs[fp] },
Nights: func(string, string, time.Time) int { return 2 },
Now: fixNow,
}
}
func renderSystem(t *testing.T, in systemInput) string {
t.Helper()
s, _ := newTestServer(t)
data := buildSystemPage(in)
data["CSRFToken"] = "csrf-fixture-token"
var b bytes.Buffer
if err := s.templates.ExecuteTemplate(&b, "system.html", data); err != nil {
t.Fatal(err)
}
return b.String()
}
// sysSection returns the page text between two section ids, so a check is made where the item is meant to be.
func sysSection(page, id string) string {
i := strings.Index(page, `id="`+id+`"`)
if i < 0 {
return ""
}
rest := page[i:]
end := len(rest)
for _, m := range []string{"<section", `<details class="card more"`} {
if j := strings.Index(rest[1:], m); j >= 0 && j+1 < end {
end = j + 1
}
}
return rest[:end]
}
// The contract: every item and button the page had before 2026-10-10 is still on it — in the main part or in Details.
// COMPANION RED-PROOF (observed): drop the Docker-engine group from the box panel → "lacks \">containerd</dt>\"".
func TestSystemPage_EveryItemStillOnThePage(t *testing.T) {
page := renderSystem(t, fixtureInput(true))
boxes, details := sysSection(page, "boxes"), sysSection(page, "details")
for _, want := range []string{
// per box, in the box panel (every column of the old wide table)
`href="/hosts/demo-hp-d4e5f6"`, "Demo HP", `action="/os/ring/demo-hp-d4e5f6"`, `action="/os/enabled/tester1-0f1e2d"`,
">Tunnel</dt>", ">Proxmox</dt>", ">Kernel (running)</dt>", ">Kernel (next boot)</dt>", ">Kernel (default)</dt>",
">Kernel step</dt>", ">Debian</dt>", ">Felhom release</dt>", ">Pending</dt>", ">Not covered</dt>", ">Held</dt>",
">Reboot needed</dt>", ">kernel.panic</dt>", ">Oops</dt>", ">Crash restarts 24 h</dt>", ">Crash guard</dt>",
">Root files</dt>", ">Agent</dt>", ">Guest Debian</dt>", ">Restart needed</dt>", ">Last disk trim</dt>",
">Docker</dt>", ">containerd</dt>", ">live-restore</dt>", ">Docker release</dt>", ">Last OS leg</dt>",
"no versions reported (agent older than v0.142.0)", "9.0.11", "29.8.2", "2.3.6-1~debian.13~trixie",
} {
if !strings.Contains(boxes, want) {
t.Errorf("Boxes lacks %q", want)
}
}
for _, want := range []string{
"Approved releases", "os-kernel-20261010-091200", "214 packages", "by operator",
"Cancelled approvals (last 7 days)", "os-docker-20261006-120000", "a TEST approval", "boxes that installed it keep it",
"What ring 0 runs now", "first seen", "approved as os-guest-20261009-031500", "1 of 2 healthy night Docker step",
`action="/os/approve-now"`, "Version floors", "Global controller floor: <strong>0.229.0", "vouched agent: <strong>0.156.0",
`href="/customers/c-tester1"`, "Global floor moves it?", "Crash guard and root files",
} {
if !strings.Contains(details, want) {
t.Errorf("Details lacks %q", want)
}
}
if !strings.Contains(sysSection(page, "waiting"), `action="/os/approve-pve"`) {
t.Error("the ready Proxmox set has no button in Waiting for you")
}
if strings.Count(page, ">unknown<") < 6 {
t.Errorf("Tester 2's values must read unknown, got %d", strings.Count(page, ">unknown<"))
}
// <details> carries the click-to-open parts: the page works without JavaScript, and Details is closed by default.
if !strings.Contains(page, `<details class="card more" id="details">`) || strings.Contains(page, `id="details" open`) {
t.Error("Details must be a <details> element, closed by default")
}
if strings.Count(page, `<details class="bx"`) != 4 {
t.Errorf("every box must open in place, got %d", strings.Count(page, `<details class="bx"`))
}
}
// "Approve now (guest + host)" is in Details, never above it, and asks before it posts.
func TestSystemPage_ApproveNowIsInDetailsAndAsks(t *testing.T) {
page := renderSystem(t, fixtureInput(false))
at := strings.Index(page, `action="/os/approve-now"`)
if at < 0 || at < strings.Index(page, `id="details"`) {
t.Fatal("Approve now must sit inside Details")
}
if !strings.Contains(page[at:], `data-confirm="Approve the guest and host sets now, without the usual 24 h and one night?`) {
t.Fatal("Approve now must ask first")
}
}
// Every form posts the CSRF field, and only to the routes the page always had.
// COMPANION RED-PROOF (observed): delete the _csrf input from the card form → "form /os/approve-pve lacks the CSRF or return field".
func TestSystemPage_EveryFormCarriesCSRF(t *testing.T) {
page := renderSystem(t, fixtureInput(true))
forms := regexp.MustCompile(`(?s)<form method="POST" action="([^"]+)".*?</form>`).FindAllStringSubmatch(page, -1)
if len(forms) < 10 {
t.Fatalf("only %d forms on the page", len(forms))
}
allowed := regexp.MustCompile(`^/os/(ring/[a-z0-9-]+|enabled/[a-z0-9-]+|approve-now|approve-docker|approve-pve|approve-kernel)$`)
for _, f := range forms {
if !strings.Contains(f[0], `name="_csrf" value="csrf-fixture-token"`) || !strings.Contains(f[0], `name="return" value="/system"`) {
t.Errorf("form %s lacks the CSRF or return field", f[1])
}
if !allowed.MatchString(f[1]) {
t.Errorf("form posts to a route the page never had: %s", f[1])
}
}
}
func attentionOf(t *testing.T, in systemInput) string {
t.Helper()
return sysSection(renderSystem(t, in), "attention")
}
// Needs attention: a green box is not listed, an amber and a red one are, each with its reason; all green → one line.
// COMPANION RED-PROOF (observed): skip the cells in summariseRow → "the amber box (agent behind) is missing or has no reason".
func TestSystemPage_NeedsAttention(t *testing.T) {
in := fixtureInput(false)
att := attentionOf(t, in)
if strings.Contains(att, "demo-felhom-a1b2c3") {
t.Error("a green box is listed")
}
if !strings.Contains(att, "tester1-0f1e2d") || !strings.Contains(att, "agent behind: 0.155.0 → 0.156.0") {
t.Errorf("the amber box (agent behind) is missing or has no reason:\n%s", att)
}
if !strings.Contains(att, `class="mark c-bad"`) || !strings.Contains(att, "tester2-9a8b7c") {
t.Errorf("the red box (agent behind 9 days) is missing:\n%s", att)
}
if !strings.Contains(att, "no versions reported") {
t.Error("Tester 2's unknown values have no plain reason")
}
if strings.Contains(att, "All boxes look fine") {
t.Error("says all fine while two boxes are not")
}
// A red cell of its own: the kernel step's failed revert.
in.KernelLines["demo-hp-d4e5f6"] = osupdates.KernelLine{LastOutcome: "revert_failed", LastKernel: "7.0.14-23-pve", LastAt: fixNow.Add(-9 * time.Hour)}
att = attentionOf(t, in)
if !strings.Contains(att, "kernel step: 7.0.14-23-pve revert failed 9 h ago") {
t.Errorf("the kernel step's failure has no plain reason:\n%s", att)
}
// Updates switched off: amber, in plain words.
in.Lines[0].Enabled = false
if att = attentionOf(t, in); !strings.Contains(att, "OS updates switched off") {
t.Error("a box with updates off is not listed")
}
// All green.
green := fixtureInput(false)
green.Lines, green.Facts = green.Lines[:2], map[string]sysfacts.System{"demo-felhom-a1b2c3": green.Facts["demo-felhom-a1b2c3"], "demo-hp-d4e5f6": green.Facts["demo-hp-d4e5f6"]}
if att = attentionOf(t, green); !strings.Contains(att, "All boxes look fine.") || strings.Contains(att, `class="att"`) {
t.Errorf("all-green fleet:\n%s", att)
}
}
// Waiting for you: one card per operator lane that the button may approve (the same gate as before), the empty line, and
// guest/host never as a card (they approve themselves).
// COMPANION RED-PROOF (observed): drop `c.Waiting != ""` from buildWaiting's test → "kernel not ready: cards [...]" (a card before the rule allows it).
func TestSystemPage_WaitingCards(t *testing.T) {
ring0 := []string{"demo-felhom-a1b2c3", "demo-hp-d4e5f6"}
pk := func(fp string) []osupdates.Package {
return map[string][]osupdates.Package{
"k": {{Name: "proxmox-kernel-7.0.14-23-pve-signed", Version: "7.0.14-23"}},
"d": {{Name: "docker-ce", Version: "5:29.8.3-1"}},
"p": {{Name: "pve-manager", Version: "9.0.12"}},
}[fp]
}
two := func(string, string, time.Time) int { return 2 }
for _, c := range []struct {
layer, fp, what, action, evidence string
}{
{"kernel", "k", "Kernel 7.0.14-23", "/os/approve-kernel", "Started without problems after a night step on demo-felhom-a1b2c3 and demo-hp-d4e5f6."},
{"docker", "d", "Docker engine 29.8.3-1", "/os/approve-docker", "demo-hp-d4e5f6: 2 healthy night(s)"},
{"pve", "p", "Proxmox packages 9.0.12", "/os/approve-pve", "demo-felhom-a1b2c3: 2 healthy night(s)"},
} {
cards, testing := buildWaiting([]osupdates.Status{{Layer: c.layer, Fingerprint: c.fp, FirstSeen: fixNow.Add(-26 * time.Hour), Packages: 2}}, ring0, pk, two, fixNow)
if len(cards) != 1 || len(testing) != 0 || cards[0].What != c.what || cards[0].Action != c.action || !strings.Contains(cards[0].Evidence, c.evidence) {
t.Errorf("%s: cards %+v testing %+v", c.layer, cards, testing)
}
// Not ready yet → a "still being tested" line, no card.
cards, testing = buildWaiting([]osupdates.Status{{Layer: c.layer, Fingerprint: c.fp, Waiting: "needs another night"}}, ring0, pk, two, fixNow)
if len(cards) != 0 || len(testing) != 1 || testing[0].Why != "needs another night" {
t.Errorf("%s not ready: cards %+v testing %+v", c.layer, cards, testing)
}
// Approved → neither.
cards, testing = buildWaiting([]osupdates.Status{{Layer: c.layer, Fingerprint: c.fp, Approved: "os-x", Waiting: "already approved"}}, ring0, pk, two, fixNow)
if len(cards)+len(testing) != 0 {
t.Errorf("%s approved: cards %+v testing %+v", c.layer, cards, testing)
}
}
if cards, _ := buildWaiting([]osupdates.Status{{Layer: "guest", Fingerprint: "g", Packages: 3}, {Layer: "host", Fingerprint: "h", Packages: 3}}, ring0, pk, two, fixNow); len(cards) != 0 {
t.Errorf("guest/host became cards: %+v", cards)
}
// Rendered: the card with its button, and the empty line.
page := renderSystem(t, fixtureInput(true))
w := sysSection(page, "waiting")
if !strings.Contains(w, "Proxmox packages 9.0.12") || !strings.Contains(w, "Approve Proxmox set") {
t.Errorf("the ready card is not rendered:\n%s", w)
}
if !strings.Contains(w, "Docker engine 29.8.3-1") || !strings.Contains(w, "still being tested") {
t.Errorf("the Docker set still being tested is not shown:\n%s", w)
}
if w = sysSection(renderSystem(t, fixtureInput(false)), "waiting"); !strings.Contains(w, "Nothing waits for your approval.") || strings.Contains(w, `<form`) {
t.Errorf("empty Waiting for you:\n%s", w)
}
}
// TestSystemPage_WriteFixture writes the fixture page for the screenshots (SYSTEM_PAGE_FIXTURE_OUT=<dir>); a no-op otherwise.
func TestSystemPage_WriteFixture(t *testing.T) {
dir := os.Getenv("SYSTEM_PAGE_FIXTURE_OUT")
if dir == "" {
t.Skip("set SYSTEM_PAGE_FIXTURE_OUT to write the fixture pages")
}
css, err := os.ReadFile("templates/style.css")
if err != nil {
t.Fatal(err)
}
fonts, err := filepath.Abs("static/fonts")
if err != nil {
t.Fatal(err)
}
css = bytes.ReplaceAll(css, []byte("url('/static/fonts/"), []byte("url('file://"+fonts+"/"))
link := regexp.MustCompile(`<link rel="stylesheet" href="/style.css\?v=[^"]*">`)
for name, ready := range map[string]bool{"system-fixture.html": false, "system-fixture-card.html": true} {
page := link.ReplaceAllString(renderSystem(t, fixtureInput(ready)), "<style>"+string(css)+"</style>")
if err := os.WriteFile(filepath.Join(dir, name), []byte(page), 0o644); err != nil {
t.Fatal(err)
}
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ func TestSystemPage_LastDiskTrim(t *testing.T) {
t.Fatal(err)
}
b := getSystem(t, s)
if !strings.Contains(b, ">Last disk trim</th>") {
if !strings.Contains(b, ">Last disk trim</dt>") {
t.Error("the System page lacks the Last disk trim column")
}
if !strings.Contains(b, "20 days ago · 30.2 GiB") {
+344
View File
@@ -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
}
+213 -76
View File
@@ -6,12 +6,65 @@
<title>System — Felhom Hub</title>
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
<style>
.sys td, .sys th { white-space: nowrap; font-size: 0.82em; vertical-align: top; }
.sys .grp { border-left: 2px solid var(--border, #444); }
.c-warn { color: var(--warn); font-weight: 600; }
.c-bad { color: var(--danger, #e5534b); font-weight: 700; }
.c-bad { color: var(--crit); font-weight: 700; }
.sys-sec { margin-bottom: 1.5rem; }
.sys-sec > h3 { margin: 0 0 0.15rem; font-size: 1.05rem; color: var(--text-1); }
.att a, .bx-row a, .bx-more a { color: var(--blue-bright); text-decoration: none; }
.sys-sec > .why { margin: 0 0 0.8rem; color: var(--text-2); font-size: 0.85em; }
.term { text-decoration: underline dotted; cursor: help; }
.sys form { display: inline; }
.rel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.75rem; }
/* Needs attention */
.att { list-style: none; margin: 0; padding: 0; }
.att li { padding: 0.45rem 0; border-top: 1px solid var(--line); }
.att li:first-child { border-top: 0; }
.att .rs { display: block; margin: 0.15rem 0 0 1.4rem; font-size: 0.88em; }
.att .rs span { font-weight: normal; }
.mark { display: inline-block; min-width: 3.4rem; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.03em; }
.mark::before { content: "● "; }
.mark.c-warn::before { content: "▲ "; }
.mark.c-bad::before { content: "✕ "; }
.ok-line::before { content: "● "; color: var(--blue-bright); }
/* Waiting for you */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 0.75rem; }
.wcard { border: 1px solid var(--warn); border-radius: var(--radius); padding: 0.8rem 1rem; }
.wcard h4 { margin: 0 0 0.3rem; font-size: 1.05em; }
.wcard p { margin: 0.25rem 0; font-size: 0.9em; }
.wcard form { margin-top: 0.5rem; }
.testing { margin: 0.8rem 0 0; padding-left: 1.1rem; font-size: 0.88em; }
/* Boxes: one narrow grid; each row is a <details> that opens in place (works without JavaScript) */
.boxes { font-size: 0.9em; }
.bx-row { display: grid; grid-template-columns: 1.7fr 0.5fr 0.7fr 1.2fr 1.1fr 1.3fr 1.2fr; gap: 0.6rem; align-items: start;
padding: 0.55rem 0.8rem; }
.bx-head { color: var(--text-2); font-size: 0.85em; border-bottom: 1px solid var(--line); }
details.bx { border-bottom: 1px solid var(--line); }
details.bx > summary { list-style: none; cursor: pointer; color: var(--text-1); font-size: inherit; }
details.bx > summary::-webkit-details-marker { display: none; }
details.bx > summary:hover { background: rgba(127,127,127,0.07); }
details.bx > summary .name::before { content: "▸ "; color: var(--text-2); }
details.bx[open] > summary .name::before { content: "▾ "; }
.bx-row > div { min-width: 0; overflow-wrap: anywhere; }
.bx-row .sub { display: block; color: var(--text-2); font-size: 0.88em; font-weight: normal; }
.bx-more { padding: 0.4rem 0.8rem 1rem 1.8rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 0.4rem 1.5rem; }
.bx-more h5 { margin: 0.6rem 0 0.3rem; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-2); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.15rem 0.8rem; margin: 0; font-size: 0.92em; }
.kv dt { color: var(--text-2); }
.kv dd { margin: 0; overflow-wrap: anywhere; }
.kv form { display: inline; margin-left: 0.3rem; }
/* Details */
details.more > summary { cursor: pointer; font-weight: 600; font-size: 1.05rem; }
details.more h3 { margin-top: 1.3rem; }
.tbl-wrap { overflow-x: auto; }
.sys td, .sys th { font-size: 0.85em; vertical-align: top; }
@media (max-width: 760px) {
.nav-links { flex-wrap: wrap; gap: 0.3rem 1rem; }
.bx-head { display: none; }
.bx-row { grid-template-columns: 1fr 1fr; }
.bx-row > div:first-child { grid-column: 1 / -1; }
.bx-row > div[data-label]::before { content: attr(data-label); display: block; color: var(--text-2); font-size: 0.78em; font-weight: normal; }
.bx-more { padding-left: 0.8rem; }
}
</style>
</head>
<body>
@@ -37,8 +90,138 @@
{{if .Flash}}<div class="flash flash-success" style="margin-bottom: 1rem;">{{.Flash}}</div>{{end}}
{{if .FlashErr}}<div class="flash flash-error" style="margin-bottom: 1rem;">{{.FlashErr}}</div>{{end}}
<section class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-top: 0;">Approved releases</h3>
<section class="card sys-sec" id="attention">
<h3>Needs attention</h3>
<p class="why">One line per box that is amber or red, and why. Amber: worth a look. Red: an operator alarm fires (`08` §6.3).</p>
{{if .Rows}}
{{if .Attention}}
<ul class="att">
{{range .Attention}}
<li><span class="mark c-{{.Mark.Class}}" title="{{.Mark.Title}}">{{.Mark.Text}}</span>
<a href="/hosts/{{.HostID}}"><strong>{{.HostID}}</strong></a>{{if .CustomerName}} <span class="text-muted">{{.CustomerName}}</span>{{end}}
<span class="rs">{{range $i, $r := .Reasons}}{{if $i}} · {{end}}<span class="{{if $r.Class}}c-{{$r.Class}}{{end}}"{{if $r.Title}} title="{{$r.Title}}"{{end}}>{{$r.Text}}</span>{{end}}</span></li>
{{end}}
</ul>
{{else}}<p class="ok-line" style="margin: 0;">All boxes look fine.</p>{{end}}
{{else}}<p class="text-muted" style="margin: 0;">No boxes yet.</p>{{end}}
</section>
<section class="card sys-sec" id="waiting">
<h3>Waiting for you</h3>
<p class="why">Update sets that only you approve: the kernel, the Docker engine and the Proxmox packages. Guest and host Debian updates approve themselves after 24 h and one night on the <span class="term" title="Ring 0 = the demo boxes. They install every new fix first; the other boxes (ring 1) install only what is approved.">ring-0</span> boxes.</p>
{{if .Cards}}
<div class="cards">
{{range .Cards}}
<div class="wcard">
<h4>{{.What}}</h4>
<p>{{.Evidence}}</p>
<p class="text-muted">{{.FirstSeen}}. {{.After}}</p>
<form method="POST" action="{{.Action}}">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
<button type="submit" class="btn btn-sm" data-confirm="{{.Confirm}}">{{.Button}}</button>
</form>
</div>
{{end}}
</div>
{{else}}<p class="ok-line" style="margin: 0;">Nothing waits for your approval.</p>{{end}}
{{if .Testing}}
<ul class="testing">
{{range .Testing}}<li><strong>{{.What}}</strong> — still being tested: <span class="text-muted">{{.Why}}</span></li>{{end}}
</ul>
{{end}}
</section>
{{if .Rows}}
<section class="card sys-sec boxes" id="boxes" style="padding-left: 0; padding-right: 0;">
<h3 style="padding: 0 1rem;">Boxes</h3>
<p class="why" style="padding: 0 1rem;">Click a box to see every value it reports, and its ring and update switches. "unknown": the box could not read the value — never a guess.</p>
<div class="bx-row bx-head"><div>Box</div><div>Ring</div><div>Health</div><div>Controller · agent</div><div>OS updates</div><div>Kernel</div><div title="The newest OS update run">Last night</div></div>
{{range .Rows}}
<details class="bx" id="box-{{.HostID}}">
<summary class="bx-row">
<div class="name"><a href="/hosts/{{.HostID}}">{{.HostID}}</a>{{if .CustomerName}}<span class="sub">{{.CustomerName}}</span>{{end}}</div>
<div data-label="Ring"><span class="term" title="{{if eq .Ring 0}}Ring 0: a demo box — it installs every new fix first{{else}}Ring 1: a normal box — it installs only approved releases{{end}}">{{.Ring}}</span></div>
<div data-label="Health"><span class="mark{{if .Mark.Class}} c-{{.Mark.Class}}{{end}}" title="{{.Mark.Title}}">{{.Mark.Text}}</span></div>
<div data-label="Controller · agent"><span{{if .Controller.Class}} class="c-{{.Controller.Class}}"{{end}}>{{.Controller.Text}}</span><span class="sub{{if .Agent.Class}} c-{{.Agent.Class}}{{end}}" title="{{.Agent.Title}}">agent {{.Agent.Text}}</span></div>
<div data-label="OS updates"><span{{if .Updates.Class}} class="c-{{.Updates.Class}}"{{end}} title="{{.Updates.Title}}">{{.Updates.Text}}</span></div>
<div data-label="Kernel"><span{{if .KernelShort.Class}} class="c-{{.KernelShort.Class}}"{{end}}>{{.KernelShort.Text}}</span>{{if .KernelNext}}<span class="sub c-warn" title="the next boot changes the kernel">next boot: {{.KernelNext}}</span>{{end}}</div>
<div data-label="Last night"><span{{if .LastNight.Class}} class="c-{{.LastNight.Class}}"{{end}} title="{{.LastNight.Title}}">{{.LastNight.Text}}</span></div>
</summary>
<div class="bx-more sys">
<div>
{{if .FactsNote}}<p class="c-warn" style="font-weight: normal; margin: 0.6rem 0 0;">{{.FactsNote}}</p>{{end}}
<h5>Ring and updates</h5>
<dl class="kv">
<dt>Ring</dt><dd>ring {{.Ring}}
<form method="POST" action="/os/ring/{{.HostID}}">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
{{if eq .Ring 0}}<input type="hidden" name="ring" value="1"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Make {{.HostID}} a normal (ring 1) box? It then installs only approved releases.">→ normal</button>
{{else}}<input type="hidden" name="ring" value="0"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Make {{.HostID}} a DEMO (ring 0) box? It then installs every new fix first and takes unsigned Docker steps if its root-owned ring-0 mark allows.">→ demo</button>{{end}}
</form></dd>
<dt>OS updates</dt><dd>updates {{if .Enabled}}<strong>ON</strong>{{else}}<span class="c-warn">OFF</span>{{end}}
<form method="POST" action="/os/enabled/{{.HostID}}">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
{{if .Enabled}}<input type="hidden" name="on" value="0"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Switch OS updates OFF for {{.HostID}}? It keeps reporting and installs nothing.">switch off</button>
{{else}}<input type="hidden" name="on" value="1"><button type="submit" class="btn btn-sm btn-outline">switch on</button>{{end}}
</form></dd>
<dt>Tunnel</dt>{{template "sys_dd" .Tunnel}}
<dt>Controller</dt>{{template "sys_dd" .Controller}}
<dt>Agent</dt>{{template "sys_dd" .Agent}}
<dt>Last OS leg</dt>{{template "sys_dd" .LastLeg}}
</dl>
<h5>Docker engine</h5>
<dl class="kv">
<dt>Docker</dt>{{template "sys_dd" .Engine}}
<dt>containerd</dt>{{template "sys_dd" .Containerd}}
<dt>live-restore</dt>{{template "sys_dd" .LiveRestore}}
<dt>Docker release</dt>{{template "sys_dd" .DockerRelease}}
</dl>
</div>
<div>
<h5>Host</h5>
<dl class="kv">
<dt>Proxmox</dt>{{template "sys_dd" .PVE}}
<dt>Kernel (running)</dt>{{template "sys_dd" .KernelRunning}}
<dt>Kernel (next boot)</dt>{{template "sys_dd" .KernelNextBoot}}
<dt title="The kernel GRUB boots normally (R-836). Amber: a one-shot flag names another kernel for the next boot only.">Kernel (default)</dt>{{template "sys_dd" .KernelDefault}}
<dt title="The kernel lane (R-836): the newest step, the kernel the box is due, and whether its household was told for tonight (no mail, no step).">Kernel step</dt>{{template "sys_dd" .KernelStep}}
<dt>Debian</dt>{{template "sys_dd" .HostDebian}}
<dt>Felhom release</dt>{{template "sys_dd" .HostRelease}}
<dt>Pending</dt>{{template "sys_dd" .HostPending}}
<dt>Not covered</dt>{{template "sys_dd" .HostNotCovered}}
<dt>Held</dt>{{template "sys_dd" .Held}}
<dt>Reboot needed</dt>{{template "sys_dd" .RebootSince}}
<dt>kernel.panic</dt>{{template "sys_dd" .KernelPanic}}
<dt>Oops</dt>{{template "sys_dd" .Oops}}
<dt>Crash restarts 24 h</dt>{{template "sys_dd" .CrashRestarts24h}}
<dt>Crash guard</dt>{{template "sys_dd" .Guard}}
<dt title="The root-owned config bundle: sudoers, wrappers, units (R-840)">Root files</dt>{{template "sys_dd" .Bundle}}
</dl>
</div>
<div>
<h5>Guest</h5>
<dl class="kv">
<dt>Guest Debian</dt>{{template "sys_dd" .GuestDebian}}
<dt>Felhom release</dt>{{template "sys_dd" .GuestRelease}}
<dt>Pending</dt>{{template "sys_dd" .GuestPending}}
<dt>Restart needed</dt>{{template "sys_dd" .GuestRestart}}
<dt title="The newest pct fstrim of the guest's disks, and what it freed (R-444). The boxes trim weekly.">Last disk trim</dt>{{template "sys_dd" .Trim}}
</dl>
</div>
</div>
</details>
{{end}}
</section>
{{else}}
<div class="empty-state"><p>No boxes yet.</p></div>
{{end}}
<details class="card more" id="details">
<summary>Details</summary>
<p class="why text-muted" style="font-size: 0.85em;">Release ids, cancelled approvals, what ring 0 runs, the version floors, and every box's crash guard and root files.</p>
<h3>Approved releases</h3>
<p class="text-muted" style="font-size: 0.85em; margin-top: 0;">The newest approved set of each layer. A <span class="term" title="Approved while a TEST wait override was active. It is cancelled when the hub starts without the override (`11` §5.3.1).">TEST approval</span> is amber.</p>
<div class="rel-grid">
{{range .Releases}}
<div><strong>{{.Layer}}</strong>: <code>{{.ID}}</code><br>
@@ -46,6 +229,11 @@
{{if .Test}}<br><span class="c-warn" title="Approved while a TEST wait override was active. It is cancelled when the hub starts without the override (`11` §5.3.1).">TEST approval</span>{{end}}</div>
{{else}}<div class="text-muted">No release approved yet.</div>{{end}}
</div>
<form method="POST" action="/os/approve-now" style="margin-top: 0.8rem;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Approve the guest and host sets now, without the usual 24 h and one night? Every ring-1 box installs them at its next night run.">Approve now (guest + host)</button>
<span class="text-muted" style="font-size: 0.85em;">An urgent fix only — normally the hub approves after 24 h and one night.</span>
</form>
{{if .Cancelled}}
<h3>Cancelled approvals (last 7 days)</h3>
<div class="rel-grid">
@@ -56,42 +244,24 @@
{{end}}
</div>
{{end}}
<h3>What ring 0 runs now</h3>
<div class="rel-grid">
{{range .Candidates}}
<div><strong>{{.Layer}}</strong>:
{{if .Fingerprint}}{{.Packages}} packages, first seen {{.FirstSeen.UTC.Format "2006-01-02 15:04"}} UTC{{else}}<span class="text-muted">—</span>{{end}}<br>
{{if .Approved}}<span class="text-muted">approved as {{.Approved}}</span>
{{else if .Waiting}}<span class="text-muted">{{.Waiting}}</span>{{end}}
{{if and (eq .Layer "docker") .Fingerprint (not .Approved) (eq .Waiting "")}}
<form method="POST" action="/os/approve-docker" style="margin-top: 0.3rem;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
<button type="submit" class="btn btn-sm" data-confirm="Approve this Docker engine set? Ring-1 boxes take it only through a signed operator job.">Approve Docker set</button>
</form>{{end}}
{{if and (eq .Layer "pve") .Fingerprint (not .Approved) (eq .Waiting "")}}
<form method="POST" action="/os/approve-pve" style="margin-top: 0.3rem;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
<button type="submit" class="btn btn-sm" data-confirm="Approve this Proxmox package set (no kernel)? Ring-1 boxes take it only through a signed operator job.">Approve Proxmox set</button>
</form>{{end}}
{{if and (eq .Layer "kernel") .Fingerprint (not .Approved) (eq .Waiting "")}}
<form method="POST" action="/os/approve-kernel" style="margin-top: 0.3rem;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
<button type="submit" class="btn btn-sm" data-confirm="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.">Approve kernel set</button>
</form>{{end}}
{{else if .Waiting}}<span class="text-muted">{{.Waiting}}</span>
{{else if .Fingerprint}}<span class="text-muted">ready — see "Waiting for you"</span>{{end}}
</div>
{{end}}
</div>
<form method="POST" action="/os/approve-now" style="margin-top: 0.8rem;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Approve the guest and host sets ring 0 runs NOW, without the 24 h + 1 night wait? Every ring-1 box installs them at its next night run.">Approve now (guest + host)</button>
<span class="text-muted" style="font-size: 0.85em;">An urgent fix only — normally the hub approves after 24 h and one night.</span>
</form>
</section>
<section class="card" id="version-floors">
<h3 style="margin-top: 0;">Version floors</h3>
<h3 id="version-floors">Version floors</h3>
<p>Global controller floor: <strong>{{if .GlobalFloor}}{{.GlobalFloor}}{{else}}none{{end}}</strong> · vouched agent: <strong>{{if .VouchedAgent}}{{.VouchedAgent}}{{else}}none{{end}}</strong></p>
<p class="text-muted" style="font-size: 0.85em; margin-top: -0.5rem;"><span class="term" title="A floor is the lowest controller version a box must run; the hub moves a box below it up.">What is a floor?</span> · <span class="term" title="The agent version the operator checked and signed off for the fleet. Agents update only by a per-box signed job.">What is a vouched agent?</span></p>
{{if .Floors}}
<div class="tbl-wrap">
<table class="data-table">
<thead><tr><th>Customer</th><th>Own floor</th><th>Set</th><th>Global floor moves it?</th></tr></thead>
<tbody>
@@ -105,66 +275,33 @@
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="text-muted">No per-customer floors: every box follows the global floor.</p>{{end}}
</section>
{{if .Rows}}
<section class="card" style="padding: 0; overflow-x: auto;">
{{if .Rows}}
<h3>Crash guard and root files</h3>
<div class="tbl-wrap">
<table class="data-table sys">
<thead>
<tr>
<th>Box</th><th>Ring / updates</th><th>Tunnel</th>
<th class="grp">Proxmox</th><th>Kernel (running)</th><th>Kernel (next boot)</th><th title="The kernel GRUB boots normally (R-836). Amber: a one-shot flag names another kernel for the next boot only.">Kernel (default)</th><th title="The kernel lane (R-836): the newest step, the kernel the box is due, and whether its household was told for tonight (no mail, no step).">Kernel step</th><th>Debian</th><th>Felhom release</th><th>Pending</th><th>Not covered</th><th>Held</th><th>Reboot needed</th><th>kernel.panic</th><th>Oops</th><th>Crash restarts 24 h</th><th>Crash guard</th><th title="The root-owned config bundle: sudoers, wrappers, units (R-840)">Root files</th><th title="The box's agent against the vouched one (R-530). Agents update only by a per-box signed job.">Agent</th>
<th class="grp">Guest Debian</th><th>Felhom release</th><th>Pending</th><th>Restart needed</th><th title="The newest pct fstrim of the guest's disks, and what it freed (R-444). The boxes trim weekly.">Last disk trim</th>
<th class="grp">Docker</th><th>containerd</th><th>live-restore</th><th>Docker release</th>
<th class="grp">Last OS leg</th>
</tr>
<tr class="text-muted"><th></th><th></th><th></th><th class="grp" colspan="17">host</th><th class="grp" colspan="5">guest</th><th class="grp" colspan="4">Docker engine</th><th class="grp"></th></tr>
</thead>
<thead><tr><th>Box</th><th>kernel.panic</th><th>Oops</th><th>Crash restarts 24 h</th><th>Crash guard</th><th title="The root-owned config bundle: sudoers, wrappers, units (R-840)">Root files</th></tr></thead>
<tbody>
{{range .Rows}}
<tr>
<td><a href="/hosts/{{.HostID}}">{{.HostID}}</a>{{if .CustomerName}}<br><span class="text-muted">{{.CustomerName}}</span>{{end}}
{{if .FactsNote}}<br><span class="c-warn" style="font-weight: normal;">{{.FactsNote}}</span>{{end}}</td>
<td>
ring {{.Ring}}
<form method="POST" action="/os/ring/{{.HostID}}">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
{{if eq .Ring 0}}<input type="hidden" name="ring" value="1"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Make {{.HostID}} a normal (ring 1) box? It then installs only approved releases.">→ normal</button>
{{else}}<input type="hidden" name="ring" value="0"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Make {{.HostID}} a DEMO (ring 0) box? It then installs every new fix first and takes unsigned Docker steps if its root-owned ring-0 mark allows.">→ demo</button>{{end}}
</form><br>
updates {{if .Enabled}}<strong>ON</strong>{{else}}<span class="c-warn">OFF</span>{{end}}
<form method="POST" action="/os/enabled/{{.HostID}}">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
{{if .Enabled}}<input type="hidden" name="on" value="0"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Switch OS updates OFF for {{.HostID}}? It keeps reporting and installs nothing.">switch off</button>
{{else}}<input type="hidden" name="on" value="1"><button type="submit" class="btn btn-sm btn-outline">switch on</button>{{end}}
</form>
</td>
{{template "sys_cell" .Tunnel}}
<td class="grp {{if .PVE.Class}}c-{{.PVE.Class}}{{end}}">{{.PVE.Text}}</td>
{{template "sys_cell" .KernelRunning}}{{template "sys_cell" .KernelNextBoot}}{{template "sys_cell" .KernelDefault}}{{template "sys_cell" .KernelStep}}{{template "sys_cell" .HostDebian}}
{{template "sys_cell" .HostRelease}}{{template "sys_cell" .HostPending}}{{template "sys_cell" .HostNotCovered}}
{{template "sys_cell" .Held}}{{template "sys_cell" .RebootSince}}{{template "sys_cell" .KernelPanic}}{{template "sys_cell" .Oops}}
{{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}{{template "sys_cell" .Bundle}}{{template "sys_cell" .Agent}}
<td class="grp {{if .GuestDebian.Class}}c-{{.GuestDebian.Class}}{{end}}">{{.GuestDebian.Text}}</td>
{{template "sys_cell" .GuestRelease}}{{template "sys_cell" .GuestPending}}{{template "sys_cell" .GuestRestart}}{{template "sys_cell" .Trim}}
<td class="grp {{if .Engine.Class}}c-{{.Engine.Class}}{{end}}">{{.Engine.Text}}</td>
{{template "sys_cell" .Containerd}}{{template "sys_cell" .LiveRestore}}{{template "sys_cell" .DockerRelease}}
<td class="grp {{if .LastLeg.Class}}c-{{.LastLeg.Class}}{{end}}" title="{{.LastLeg.Title}}">{{.LastLeg.Text}}</td>
</tr>
<tr><td><a href="/hosts/{{.HostID}}">{{.HostID}}</a></td>{{template "sys_cell" .KernelPanic}}{{template "sys_cell" .Oops}}{{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}{{template "sys_cell" .Bundle}}</tr>
{{end}}
</tbody>
</table>
</section>
<p class="text-muted" style="font-size: 0.85em;">Amber: worth a look. Red: an operator alarm fires (`08` §6.3). "unknown": the box could not read the value — never a guess.</p>
{{else}}
<div class="empty-state"><p>No boxes yet.</p></div>
{{end}}
</div>
{{end}}
</details>
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
</footer>
</div>
<script>
/* A link to a part of "Details" (e.g. /system#version-floors) opens it. Without JavaScript the section still opens by a click. */
(function(){var h=location.hash&&document.getElementById(location.hash.slice(1));if(!h)return;var d=h.closest('details');while(d){d.open=true;d=d.parentElement&&d.parentElement.closest('details');}h.scrollIntoView();})();
</script>
</body>
</html>
{{define "sys_cell"}}<td{{if .Class}} class="c-{{.Class}}"{{end}}{{if .Title}} title="{{.Title}}"{{end}}>{{.Text}}</td>{{end}}
{{define "sys_dd"}}<dd{{if .Class}} class="c-{{.Class}}"{{end}}{{if .Title}} title="{{.Title}}"{{end}}>{{.Text}}</dd>{{end}}