Files
felhom.eu/hub/internal/web/templates/system.html
T
admin cf6fec8d87
gates / gates (push) Successful in 6m17s
hub (unreleased): the System page answers 'is anything wrong?' and 'is anything waiting for me?' first
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
2026-10-10 15:38:25 +02:00

308 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System — Felhom Hub</title>
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
<style>
.c-warn { color: var(--warn); font-weight: 600; }
.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>
{{template "icon_sprite"}}
{{template "inline_confirm_js"}}
<div class="container">
<header>
<h1>Felhom <span>Hub</span></h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link">Customers</a>
<a href="/apps" class="nav-link">Apps</a>
<a href="/hosts" class="nav-link">Hosts</a>
<a href="/system" class="nav-link active">System</a>
<a href="/offsite" class="nav-link">Offsite</a>
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<h2 style="margin-bottom: 1rem;">System — versions and OS updates</h2>
{{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 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>
<span class="text-muted">{{.Packages}} packages · {{.ApprovedAt.UTC.Format "2006-01-02 15:04"}} UTC · by {{.ApprovedBy}}</span>
{{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">
{{range .Cancelled}}
<div><strong>{{.Layer}}</strong>: <code>{{.ID}}</code><br>
<span class="c-warn">cancelled {{.Cancelled}} UTC{{if .Test}} — a TEST approval{{end}}</span><br>
<span class="text-muted">no further box installs it; boxes that installed it keep it</span></div>
{{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>
{{else if .Fingerprint}}<span class="text-muted">ready — see "Waiting for you"</span>{{end}}
</div>
{{end}}
</div>
<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>
{{range .Floors}}
<tr>
<td><a href="/customers/{{.CustomerID}}">{{.CustomerID}}</a>{{if .CustomerName}}<br><span class="text-muted">{{.CustomerName}}</span>{{end}}</td>
<td>{{.Version}}</td>
<td class="{{if .Age.Class}}c-{{.Age.Class}}{{end}}" title="{{.Age.Title}}">{{.Age.Text}}</td>
<td>{{if .HeldBack}}<span class="c-bad">NO — its own floor is lower and wins (R-604)</span>{{else}}no — its own floor applies (at or above the global){{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="text-muted">No per-customer floors: every box follows the global floor.</p>{{end}}
{{if .Rows}}
<h3>Crash guard and root files</h3>
<div class="tbl-wrap">
<table class="data-table sys">
<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></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>
</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}}