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:
+54
-31
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user