feat(dashboard): generic server-warnings banner on every page (#1518)

Per operator request: instead of a disk-specific alert, surface a generic
server-warnings banner at the very top of every page, so new system
warnings can be added backend-side with no frontend change.

- hive-c0re `host_stats`: `server_warnings() -> Vec<ServerWarning>`
  (`{ kind, level, message }`). The threshold logic lives server-side; the
  host disk-pressure check (a `statvfs` probe of `/nix`: ≥85% used → warn,
  ≥95% → crit) is the first and only producer today. No new deps (libc).
- `/api/state` carries `server_warnings` (replaces the disk-specific
  field). Empty when all clear.
- frontend: `renderServerWarnings` / `initServerWarnings` in `common.js`
  inject a sticky top-of-<body> banner and render the list, coloured by
  `level`. Wired on every page — dashboard (live, via refreshState),
  FL0W, L0GS, H0M3. No per-warning frontend code; adding a warning kind
  is a pure backend change.

cargo check/clippy/fmt + npm run build green. Closes #1518.
This commit is contained in:
iris 2026-06-08 19:55:04 +02:00 committed by mara
commit 5d0f3d060b
10 changed files with 241 additions and 6 deletions

View file

@ -517,3 +517,33 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
border: 1px solid var(--border);
padding: 0.2em 0.5em;
}
/* server warnings banner
Generic top-of-page strip, injected at the top of <body> by
common.js (renderServerWarnings) on every page. One row per warning;
colour comes from the per-warning `level` (warn = amber, crit = red).
`position: sticky; top: 0` keeps it pinned above the page chrome so an
operator sees it on any page no matter how far they've scrolled. */
.server-warnings {
position: sticky;
top: 0;
z-index: 50;
}
.server-warnings[hidden] { display: none; }
.server-warn {
text-align: center;
padding: 0.4em 1em;
font-size: 0.85em;
font-weight: bold;
letter-spacing: 0.02em;
}
.server-warn-warn {
color: var(--amber);
background: rgba(250, 179, 135, 0.16);
border-bottom: 1px solid var(--amber);
}
.server-warn-crit {
color: var(--red);
background: rgba(243, 139, 168, 0.18);
border-bottom: 1px solid var(--red);
}

View file

@ -624,3 +624,55 @@ export const NOTIF = (() => {
}
return { bind, show, renderControls };
})();
// ─── server warnings banner ──────────────────────────────────────────
// A generic top-of-page banner shown on every page (dashboard + the
// stand-alone FL0W / L0GS / H0M3 pages). The backend decides what to
// warn about — `/api/state.server_warnings` is a list of
// `{ kind, level, message }` — and this just renders it, coloured by
// `level` (`warn` amber / `crit` red). Adding a new system warning is a
// backend-only change. The bar is injected at the top of <body> so no
// page needs to add markup.
function ensureServerWarningsBar() {
let bar = document.getElementById('server-warnings');
if (!bar) {
bar = document.createElement('div');
bar.id = 'server-warnings';
bar.className = 'server-warnings';
bar.setAttribute('role', 'alert');
bar.hidden = true;
document.body.prepend(bar);
}
return bar;
}
/// Render a `server_warnings` list (from /api/state) into the banner.
/// Empty / missing → the bar hides itself.
export function renderServerWarnings(warnings) {
const bar = ensureServerWarningsBar();
bar.replaceChildren();
if (!Array.isArray(warnings) || warnings.length === 0) {
bar.hidden = true;
return;
}
for (const w of warnings) {
const row = el('div', {
class: 'server-warn server-warn-' + (w && w.level === 'crit' ? 'crit' : 'warn'),
});
appendText(row, '⚠ ' + ((w && w.message) || ''));
bar.append(row);
}
bar.hidden = false;
}
/// One-shot init for pages that don't otherwise poll /api/state: ensure
/// the bar exists, fetch the snapshot once, render. The dashboard (which
/// already polls /api/state) calls `renderServerWarnings` directly for
/// live updates instead.
export function initServerWarnings() {
ensureServerWarningsBar();
fetch('/api/state')
.then((r) => (r.ok ? r.json() : null))
.then((s) => renderServerWarnings(s && s.server_warnings))
.catch(() => { /* non-fatal: no banner if the snapshot is unreachable */ });
}

View file

@ -16,11 +16,12 @@ import {
$, el,
NOTIF,
appendLinkified,
openStream,
openStream, initServerWarnings,
} from './common.js';
(() => {
NOTIF.bind();
initServerWarnings();
// ─── local containers cache (for compose autocomplete) ──────────────────
// The compose box's @-mention completion suggests known agent names.

View file

@ -1,9 +1,12 @@
// H0M3 page script (#1464). The page is static markup; this only does
// two small things off a single `/api/state` read:
// a few small things off a single `/api/state` read:
// 1. reveal the Matrix tile when the matrix GUI is enabled (same
// gating as the dashboard's M4TR1X tab — no dead link otherwise);
// 2. fill the swarm/hive identity line when configured.
// No SSE, no shared deps — a portal doesn't need live updates.
// 2. fill the swarm/hive identity line when configured;
// 3. render the shared server-warnings banner (top of every page).
// No SSE — a portal doesn't need live updates.
import { renderServerWarnings } from './common.js';
const $ = (id) => document.getElementById(id);
@ -17,6 +20,8 @@ async function init() {
return; // best-effort: the tiles still work without it
}
renderServerWarnings(state.server_warnings);
if (state.matrix_gui_enabled) {
const tile = $('home-tile-matrix');
if (tile) tile.hidden = false;

View file

@ -20,10 +20,12 @@
// SYSTEM tabs so the operator knows how stale the output is.
import {
$, el, fmtAgeSecs, openStream,
$, el, fmtAgeSecs, openStream, initServerWarnings,
} from './common.js';
(() => {
initServerWarnings();
// ─── tab routing ──────────────────────────────────────────────────────
const TABS = ['build', 'agent', 'system'];

View file

@ -16,7 +16,7 @@ import {
fmtAgeSecs,
Panel, NOTIF,
makePathLink, appendText, appendLinkified,
openStream,
openStream, renderServerWarnings,
} from './common.js';
// mdNode (in common.js) reads `window.marked` for the markdown side
@ -3879,6 +3879,7 @@ window.marked = marked;
return sect && sect.contains(el_);
});
}
async function refreshState() {
// Don't yank the form out from under the operator. Try again
// shortly on the next tick; eventually they'll blur and the
@ -3901,6 +3902,7 @@ window.marked = marked;
const peers = s.peer_hives || [];
if (peersTab) peersTab.hidden = peers.length === 0;
renderPeerHives(peers);
renderServerWarnings(s.server_warnings);
// (The M4TR1X surface is reachable from the H0M3 hub now, not the
// dashboard tab strip — home.js gates its tile on matrix_gui_enabled.)
// Hive identity: update the chrome banner + page title once the