Files
felhom.eu/hub/internal/web/system.go
T
admin ab3b7ea2f4
gates / gates (push) Successful in 2m47s
hub v0.143.0 (code): the kernel lane — the day-before household mail, the night instruction, the operator's kernel set (R-836, decision 172)
KernelDue / KernelNotify (09-20 h Budapest, one per 20 h, max 3, registered
address, only an accepted mail counts) / os_update.kernel {kver, tonight}
(no mail, no step) / layer kernel ingest + operator events / Approve kernel
set after every ring-0 box booted it healthily after a night stage / two
System page cells. 11 §5.11 written; §5.10 status corrected (proven).
Installer uninstall knows the two GRUB generators (unreleased).
Evidence: audits/kernel-lane-2026-10-07/ (red-proofs, boot timing).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 15:34:14 +02:00

454 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/osupdates"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts"
)
// The System page (hub v0.132.0; R-852, `09` decision 89, `11` §5.7): every box's real versions and its OS-update
// state, with the operator's buttons (ring, switch, approve now, approve the Docker set). Read from the hub's own
// records only: the newest host report's `system` stanza (agent ≥ v0.142.0) and the OS fleet lines.
// cell is one value with its colour: "" plain, "warn" amber, "bad" red (red = an alarm would fire).
type cell struct {
Text string
Class string
Title string
}
type systemRow struct {
HostID, CustomerName string
Ring int
Enabled bool
Tunnel cell
HasFacts bool
FactsNote string
// host
PVE, KernelRunning, KernelNextBoot, HostDebian cell
HostRelease, HostPending, HostNotCovered cell
Held, RebootSince, KernelPanic, Oops cell
CrashRestarts24h, Guard cell
KernelDefault, KernelStep cell // R-836: the GRUB default kernel; the kernel lane's step
Bundle cell // R-840: the root-owned config bundle
Agent cell // R-530: the box's agent against the vouched one
// guest
GuestDebian, GuestRelease, GuestPending, GuestRestart cell
Trim cell // R-444: the last `pct fstrim` of the guest
// docker
Engine, Containerd, LiveRestore, DockerRelease cell
// last leg
LastLeg cell
}
// OSSystemView is what the System page needs beyond OSUpdateAdmin (implemented by *osupdates.Service).
type OSSystemView interface {
Fleet() ([]osupdates.FleetLine, error)
Releases() []osupdates.ReleaseInfo
CancelledReleases() []osupdates.ReleaseInfo
Candidates() []osupdates.Status
Thresholds() (stale, reboot, notCovered time.Duration)
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
KernelLineFor(hostID string) osupdates.KernelLine // R-836: one box's kernel step and day-before mail
}
// kernelCells are the System page's two kernel-lane cells (R-836, `11` §5.11): the GRUB default (amber while a one-shot
// flag names another kernel for the next boot), and the step — the newest result, the kernel the box is due, whether
// the household was told for tonight. "unknown" when the box reports no kernel lane (an older agent).
func kernelCells(f sysfacts.System, kl osupdates.KernelLine, now time.Time) (dflt, step cell) {
lane := f.Host.KernelLane
if lane == nil {
if kl.LastOutcome == "" {
return unknownCell(""), cell{Text: "—", Title: "the box reports no kernel lane (agent older than v0.152.0)"}
}
lane = &sysfacts.KernelLane{} // a step result without facts (an older facts read) still shows
}
dflt = unknownCell(lane.Default)
if lane.Flag != nil && *lane.Flag != "" {
dflt.Text += " (once: " + *lane.Flag + ")"
dflt.Class, dflt.Title = "warn", "the next boot runs "+*lane.Flag+" ONCE; the boot after it the default again"
}
var parts []string
if kl.LastOutcome != "" {
parts = append(parts, fmt.Sprintf("%s %s %s", kl.LastKernel, kl.LastOutcome, ago(kl.LastAt, now)))
}
switch {
case kl.Due != "" && kl.Tonight:
parts = append(parts, "due "+kl.Due+" — household told "+ago(kl.NoticeAt, now)+": TONIGHT")
case kl.Due != "":
parts = append(parts, "due "+kl.Due+" — not told yet (mails 09–20 h)")
}
if lane.Phase != "" && lane.Phase != "none" {
parts = append(parts, "phase "+lane.Phase)
}
if len(parts) == 0 {
parts = append(parts, "—")
}
step = cell{Text: strings.Join(parts, " · "), Title: strings.TrimSpace(kl.Why + " " + kl.LastReason)}
switch kl.LastOutcome {
case "fell_back", "health_failed", "self_reverted", "refused", "failed":
step.Class = "warn"
case "revert_failed":
step.Class = "bad"
}
if len(lane.SetupProblems) > 0 {
step.Class = "warn"
step.Title = "cannot do a one-shot boot: " + strings.Join(lane.SetupProblems, "; ")
}
return dflt, step
}
// agentCell is the "Agent" cell (R-530, hub v0.135.0): the box's agent against the vouched one, how far behind and
// since when. Amber while behind; red from the alarm's wait on (the operator alarm fires then). An unreadable
// version is "unknown", never a guess; nothing vouched → the version alone.
func agentCell(boxAgent, vouched string, since time.Time, after time.Duration, now time.Time) cell {
if !semver.Valid(boxAgent) {
return unknownCell("")
}
c := cell{Text: boxAgent}
if !semver.Valid(vouched) {
c.Title = "no vouched agent to compare with"
return c
}
if semver.Compare(boxAgent, vouched) >= 0 {
c.Title = "current (vouched " + vouched + ")"
return c
}
c.Class = "warn"
c.Text = boxAgent + " → " + vouched
c.Title = osupdates.ReleasesBehind(boxAgent, vouched) + " — sign an agent_update for this box"
if !since.IsZero() {
c.Text += " (since " + since.UTC().Format("2006-01-02") + ")"
if now.Sub(since) >= after {
c.Class = "bad"
}
}
return c
}
// floorRow is one line of the System page's "Version floors" table (R-604).
type floorRow struct {
CustomerID, CustomerName, Version string
Age cell
HeldBack bool // the override is BELOW the global floor: the global does not move this box
}
func buildFloorRows(ovs []store.CustomerFloorOverride, global string, now time.Time) []floorRow {
var out []floorRow
for _, o := range ovs {
r := floorRow{CustomerID: o.CustomerID, CustomerName: o.CustomerName, Version: o.Version}
if o.SetAt.IsZero() {
r.Age = cell{Text: "unknown", Class: "warn", Title: "set before hub v0.135.0 — the hub did not record when"}
} else {
r.Age = plain(ago(o.SetAt, now) + " (" + o.SetAt.UTC().Format("2006-01-02") + ")")
}
if semver.Valid(global) && semver.Valid(o.Version) && semver.Compare(global, o.Version) > 0 {
r.HeldBack = true
}
out = append(out, r)
}
return out
}
func plain(s string) cell { return cell{Text: s} }
// trimStaleAfter is when a guest's last SUCCESSFUL disk trim reads amber: the boxes trim weekly (`09` §3 decision
// 139), so two missed weeks are worth a look.
const trimStaleAfter = 14 * 24 * time.Hour
// trimCell is the "Last disk trim" cell (R-444): per guest, the last successful `pct fstrim` (from `last_ok_at`) and what
// the newest attempt freed. Amber when the newest attempt failed (its error is shown) or the last success is older than
// 14 days — judged on `last_ok_at`, never on the attempt time (an attempt is not a result). "—" when the agent sends no
// stanza (no trimmer); an unreadable time is "unknown", never a guess.
func trimCell(dt *sysfacts.DiskTrim, now time.Time) cell {
if dt == nil {
return cell{Text: "—", Title: "the agent reports no disk trim (an agent without the weekly trim)"}
}
if len(dt.Guests) == 0 {
return cell{Text: "none yet", Title: "the weekly trim runs (" + dt.Schedule + ") and has not trimmed a guest yet"}
}
out := cell{Title: "schedule: " + dt.Schedule}
var texts []string
for _, g := range dt.Guests {
t, warn, why := guestTrimText(g, now)
if len(dt.Guests) > 1 {
t = fmt.Sprintf("%d: %s", g.VMID, t)
}
texts = append(texts, t)
if warn {
out.Class = "warn"
out.Title = fmt.Sprintf("guest %d: %s — %s", g.VMID, why, out.Title)
}
}
out.Text = strings.Join(texts, " / ")
return out
}
func guestTrimText(g sysfacts.GuestTrim, now time.Time) (text string, warn bool, why string) {
var okAt time.Time
if g.LastOKAt != "" {
t, err := time.Parse(time.RFC3339, g.LastOKAt)
if err != nil {
return "unknown", true, fmt.Sprintf("unreadable last_ok_at %q", g.LastOKAt)
}
okAt = t
}
attAt, aerr := time.Parse(time.RFC3339, g.LastAttemptAt)
if aerr != nil {
return "unknown", true, fmt.Sprintf("unreadable last_attempt_at %q", g.LastAttemptAt)
}
if g.OK {
text = fmt.Sprintf("%s · %.1f GiB", ago(attAt, now), float64(g.BytesTrimmed)/(1<<30))
if !okAt.IsZero() {
text = fmt.Sprintf("%s · %.1f GiB", ago(okAt, now), float64(g.BytesTrimmed)/(1<<30))
} else {
okAt = attAt
}
} else {
last := "never ok"
if !okAt.IsZero() {
last = "last ok " + ago(okAt, now)
}
errText := g.Error
if errText == "" {
errText = "no error text"
}
text = fmt.Sprintf("FAILED %s: %s (%s)", ago(attAt, now), errText, last)
warn, why = true, "the newest trim failed"
}
if !okAt.IsZero() && now.Sub(okAt) > trimStaleAfter {
warn = true
why = strings.TrimPrefix(why+"; ", "; ") + "the last successful trim is older than 14 days (the boxes trim weekly)"
}
return text, warn, why
}
// bundleCell is the "Root files" cell (R-840): the box's config bundle against the vouched agent's. Amber while behind,
// red from the alarm's wait on, amber when a file was changed by hand (drift); "unknown" is never coloured as a fact.
func bundleCell(f sysfacts.System, vouchedAgent, vouchedSHA string, since time.Time, after time.Duration, now time.Time) cell {
b := f.Bundle
if !f.Present || b.Version == "" || b.Version == sysfacts.Unknown {
return unknownCell("")
}
c := cell{Text: b.Version}
switch {
case vouchedSHA == "":
c.Title = "no vouched bundle to compare with (the vouched agent carries none)"
case b.BundleSHA256 != vouchedSHA:
c.Class, c.Title = "warn", "behind the vouched agent "+vouchedAgent+"'s bundle — send it with a signed agent_config_update"
if !since.IsZero() {
c.Title += " (behind since " + since.UTC().Format("2006-01-02 15:04") + " UTC)"
if now.Sub(since) >= after {
c.Class = "bad"
}
}
}
if len(b.Drift) > 0 {
c.Text += " (changed by hand)"
c.Class = "warn"
c.Title = "files differ from the installed bundle: " + strings.Join(b.Drift, ", ")
}
return c
}
func unknownCell(s string) cell {
if s == "" || s == sysfacts.Unknown {
return cell{Text: "unknown", Class: "warn", Title: "the box could not read it (agent older than v0.142.0, or the guest is down)"}
}
return cell{Text: s}
}
func ago(t time.Time, now time.Time) string {
if t.IsZero() {
return "never"
}
d := now.Sub(t)
switch {
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
}
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
// buildSystemRows is pure (the render test feeds it directly).
func buildSystemRows(lines []osupdates.FleetLine, facts map[string]sysfacts.System, names map[string]string,
stale, reboot, notCov time.Duration, now time.Time) []systemRow {
var rows []systemRow
for _, l := range lines {
f := facts[l.HostID]
r := systemRow{HostID: l.HostID, CustomerName: names[l.HostID], Ring: l.Ring, Enabled: l.Enabled, HasFacts: f.Present}
switch l.Tunnel {
case "running":
r.Tunnel = plain("running")
case "not_running", "inactive":
r.Tunnel = cell{Text: l.Tunnel, Class: "bad"}
default:
r.Tunnel = cell{Text: l.Tunnel, Class: "warn"}
}
if !f.Present {
r.FactsNote = "no versions reported (agent older than v0.142.0)"
} else if f.FactsError != "" {
r.FactsNote = "partial: " + f.FactsError
}
r.PVE = unknownCell(sysfacts.ShortPVE(f.PVEVersion))
r.KernelRunning = unknownCell(f.Host.KernelRunning)
r.KernelNextBoot = unknownCell(f.Host.KernelNextBoot)
if f.NextBootDiffers() {
r.KernelNextBoot.Class, r.KernelNextBoot.Title = "warn", "the next boot changes the kernel ("+f.Host.KernelNextBootSource+")"
}
r.HostDebian = unknownCell(f.Host.Debian)
r.HostRelease = plain(orDash(l.Host.ReleaseID))
r.HostPending = plain(fmt.Sprint(l.Host.Pending))
r.HostNotCovered = plain(fmt.Sprint(l.Host.NotCoveredFast))
if l.Host.NotCoveredFast > 0 {
r.HostNotCovered.Class = "warn"
}
switch {
case f.Host.Held == nil:
r.Held = unknownCell("")
case len(f.Host.Held) == 0:
r.Held = plain("none")
default:
r.Held = cell{Text: strings.Join(f.Host.Held, ", "), Class: "warn", Title: "held by hand (an undo) — the hub cannot see it otherwise (R-848)"}
}
if l.Host.RebootNeededSince.IsZero() {
r.RebootSince = plain("no")
} else {
r.RebootSince = cell{Text: "since " + l.Host.RebootNeededSince.UTC().Format("2006-01-02"), Class: "warn"}
if now.Sub(l.Host.RebootNeededSince) >= reboot {
r.RebootSince.Class = "bad"
}
}
if f.Host.KernelPanic != nil {
r.KernelPanic = plain(fmt.Sprintf("%d s", *f.Host.KernelPanic))
if *f.Host.KernelPanic == 0 {
r.KernelPanic = cell{Text: "0 (stays off)", Class: "warn"}
}
} else {
r.KernelPanic = unknownCell("")
}
r.Oops = plain("no")
if f.Host.OopsThisBoot != nil && *f.Host.OopsThisBoot {
r.Oops = cell{Text: "yes", Class: "warn", Title: "a kernel oops this boot"}
}
if cg := f.Host.CrashGuard; cg != nil {
r.CrashRestarts24h = plain(fmt.Sprint(cg.In24h))
if cg.In24h > 0 {
r.CrashRestarts24h.Class = "warn"
}
if cg.Tripped {
r.Guard = cell{Text: "TRIPPED " + cg.TrippedAt, Class: "bad", Title: cg.TrippedReason}
} else {
r.Guard = plain("armed")
}
} else {
r.CrashRestarts24h, r.Guard = unknownCell(""), cell{Text: "not installed", Class: "warn"}
}
r.GuestDebian = unknownCell(f.Guest.Debian)
r.GuestRelease = plain(orDash(l.Guest.ReleaseID))
r.GuestPending = plain(fmt.Sprint(l.Guest.Pending))
r.GuestRestart = plain(fmt.Sprint(l.Guest.RestartNeeded))
r.Trim = trimCell(f.DiskTrim, now)
r.Engine, r.Containerd = unknownCell(f.Guest.DockerEngine), unknownCell(f.Guest.Containerd)
r.LiveRestore = unknownCell(f.Guest.LiveRestore)
if f.Guest.LiveRestore == "off" {
r.LiveRestore.Class, r.LiveRestore.Title = "warn", "a Docker step is refused until it is on (decision 87)"
}
r.DockerRelease = plain(orDash(l.Docker.ReleaseID))
last := l.Guest
if l.Host.LastAt.After(last.LastAt) {
last = l.Host
}
if l.Docker.LastAt.After(last.LastAt) {
last = l.Docker
}
ok := l.Guest.LastSuccessfulLeg
r.LastLeg = cell{Text: fmt.Sprintf("%s · %s · %.0f s", ago(last.LastAt, now), orDash(last.LastOutcome), last.WrapperPassSeconds),
Title: "last successful leg: " + ago(ok, now)}
if l.Enabled && !ok.IsZero() && now.Sub(ok) >= stale {
r.LastLeg.Class = "bad"
} else if last.LastOutcome == "health_failed" || last.LastOutcome == "failed" || last.LastOutcome == "refused" {
r.LastLeg.Class = "warn"
}
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool { return rows[i].HostID < rows[j].HostID })
return rows
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
view, ok := s.osUpdates.(OSSystemView)
if s.osUpdates == nil || !ok {
http.Error(w, "os updates not configured", http.StatusServiceUnavailable)
return
}
lines, err := view.Fleet()
if err != nil {
s.logger.Printf("[ERROR] system page: fleet: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
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)
}
}
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
}
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()
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),
}
if err := s.templates.ExecuteTemplate(w, "system.html", data); err != nil {
s.logger.Printf("[ERROR] system.html template: %v", err)
}
}