From 5d0f3d060bea91ed4f62910ced22e5e50ccbcfb0 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 8 Jun 2026 19:55:04 +0200 Subject: [PATCH] feat(dashboard): generic server-warnings banner on every page (#1518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` (`{ 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- 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. --- docs/web-ui/dashboard.md | 11 ++ frontend/packages/dashboard/src/common.css | 30 +++++ frontend/packages/dashboard/src/common.js | 52 +++++++++ frontend/packages/dashboard/src/flow.js | 3 +- frontend/packages/dashboard/src/home.js | 11 +- frontend/packages/dashboard/src/logs.js | 4 +- frontend/packages/dashboard/src/tabs.js | 4 +- hive-c0re/src/dashboard.rs | 7 ++ hive-c0re/src/host_stats.rs | 124 +++++++++++++++++++++ hive-c0re/src/lib.rs | 1 + 10 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 hive-c0re/src/host_stats.rs diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 6db14749..52497f16 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -25,6 +25,17 @@ from the dashboard tab strip. S3TT1NGS have no count. - **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) — sits below the tab strip. +- **Server-warnings banner** — a generic, sticky top-of-page strip shown + on **every** page (dashboard + the stand-alone FL0W / L0GS / H0M3 + pages), injected at the top of `` by `renderServerWarnings` in + `common.js`. Driven by `state.server_warnings` — a list of + `{ kind, level, message }` from hive-c0re's `host_stats::server_warnings` + — and coloured by `level` (`warn` amber / `crit` red). The backend owns + the threshold + message, so adding a new system warning needs no + frontend change. The only producer today is the host disk-pressure + check (a `statvfs` probe of `/nix`: ≥85% used → `warn`, ≥95% → `crit`, + e.g. `⚠ host nix store N% full (G GiB free) — garbage-collect …`). + Hidden when there are no warnings. - **Browser tab title** — `hive / c0re` by default; updated to ` / ` once `hive_name` / `swarm_name` arrive in the state snapshot. When there are pending approvals or unanswered diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 7d8513a8..7c62c882 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -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 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); +} diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 36c812eb..cd60f399 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -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 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 */ }); +} diff --git a/frontend/packages/dashboard/src/flow.js b/frontend/packages/dashboard/src/flow.js index 0735cc1e..8f76e511 100644 --- a/frontend/packages/dashboard/src/flow.js +++ b/frontend/packages/dashboard/src/flow.js @@ -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. diff --git a/frontend/packages/dashboard/src/home.js b/frontend/packages/dashboard/src/home.js index 4151a1b8..5eea4d4d 100644 --- a/frontend/packages/dashboard/src/home.js +++ b/frontend/packages/dashboard/src/home.js @@ -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; diff --git a/frontend/packages/dashboard/src/logs.js b/frontend/packages/dashboard/src/logs.js index ef5962f0..56b7004a 100644 --- a/frontend/packages/dashboard/src/logs.js +++ b/frontend/packages/dashboard/src/logs.js @@ -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']; diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index d9522ad1..b364989f 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -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 diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 9d9e3b6f..36e463a3 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -260,6 +260,12 @@ struct StateSnapshot { /// the c0re NixOS module from `services.hyperhive.swarm.peers`). /// Empty on single-hive deploys. Feeds the P33RS dashboard tab. peer_hives: Vec, + /// Server-level warnings for the dashboard's top-of-page banner + /// (currently host disk-pressure; more producers can be added + /// backend-side). Empty when all clear. Built by + /// `host_stats::server_warnings`; the frontend renders this list + /// generically, so new warning kinds need no frontend change. + server_warnings: Vec, } /// One peer hive for the P33RS dashboard tab. Derived from @@ -488,6 +494,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J .ok() .filter(|s| !s.is_empty()), peer_hives: parse_peer_hives(), + server_warnings: crate::host_stats::server_warnings(), }) } diff --git a/hive-c0re/src/host_stats.rs b/hive-c0re/src/host_stats.rs new file mode 100644 index 00000000..61c95d9a --- /dev/null +++ b/hive-c0re/src/host_stats.rs @@ -0,0 +1,124 @@ +//! Host-system probes and the derived **server-warning** list that the +//! dashboard renders as a top-of-page banner. +//! +//! The first (and currently only) producer is a host disk-usage check: +//! the nix store filling up is what ENOSPC-failed CI before the GC +//! guardrails were documented. hive-c0re runs on the host (not inside a +//! container), so it can `statvfs` the store path directly and warn the +//! operator *before* an ENOSPC, not after. +//! +//! [`server_warnings`] is the public surface: it returns a flat list of +//! [`ServerWarning`]s for `/api/state`. The dashboard renders whatever it +//! returns, coloured by `level`, so adding a new system warning (memory +//! pressure, a failed unit, …) is a backend-only change — no frontend +//! edit. Keep producers cheap; this runs on every `/api/state` assembly. + +use serde::Serialize; + +/// One server-level warning for the dashboard's top-of-page banner. +#[derive(Debug, Clone, Serialize)] +pub struct ServerWarning { + /// Stable kind id (e.g. `"disk_pressure"`) — lets the frontend dedupe + /// or special-case without parsing the message. + pub kind: &'static str, + /// Severity: `"warn"` (amber) or `"crit"` (red). The banner picks its + /// colour from this; everything else is just the message text. + pub level: &'static str, + /// Human-readable, already-formatted message shown in the banner. + pub message: String, +} + +/// Percent-used past which the host nix store earns a disk-pressure +/// warning; above [`DISK_CRIT_PCT`] it escalates to `crit`. +const DISK_WARN_PCT: f64 = 85.0; +const DISK_CRIT_PCT: f64 = 95.0; + +/// Collect the current server-level warnings for the dashboard banner. +/// Each producer pushes zero or more [`ServerWarning`]s; the frontend +/// renders whatever this returns. Cheap to call on every `/api/state` +/// assembly (currently a single `statvfs`). +#[must_use] +pub fn server_warnings() -> Vec { + let mut out = Vec::new(); + if let Some(d) = nix_disk_usage() + && d.used_pct >= DISK_WARN_PCT + { + #[allow( + clippy::cast_precision_loss, + reason = "byte counts stay well under f64's 2^53 exact-integer range, so this GiB conversion loses no precision" + )] + let free_gib = d.free_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + out.push(ServerWarning { + kind: "disk_pressure", + level: if d.used_pct >= DISK_CRIT_PCT { + "crit" + } else { + "warn" + }, + message: format!( + "host nix store {:.0}% full ({free_gib:.1} GiB free) \ + — garbage-collect the store before it runs out of space", + d.used_pct + ), + }); + } + out +} + +/// Disk usage for the filesystem backing the host nix store — internal to +/// the disk-pressure producer above. +struct DiskUsage { + /// Space available to unprivileged writers, in bytes. + free_bytes: u64, + /// Percentage used, 0–100. Mirrors `df`'s use% — `used / (used + + /// available)` — so the threshold means what the operator sees in `df`. + used_pct: f64, +} + +/// Probe disk usage for the filesystem containing the nix store (`/nix`), +/// falling back to `/` when `/nix` isn't its own mount. Returns `None` if +/// the `statvfs` syscall fails (path missing, permission, etc.). +fn nix_disk_usage() -> Option { + disk_usage("/nix").or_else(|| disk_usage("/")) +} + +fn disk_usage(path: &str) -> Option { + let c_path = std::ffi::CString::new(path).ok()?; + // SAFETY: `statvfs` reads only through the valid NUL-terminated + // `c_path` pointer and writes into the zeroed `stat` we own. We check + // the return code before reading any field. + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statvfs(c_path.as_ptr(), &raw mut stat) }; + if rc != 0 { + return None; + } + // statvfs fields are `c_ulong` (== u64 on the x86_64-linux host this + // runs on); the arithmetic below stays in that native width. + let frsize = stat.f_frsize; + let total_blocks = stat.f_blocks; + let free_blocks = stat.f_bfree; + let avail_blocks = stat.f_bavail; + if total_blocks == 0 || frsize == 0 { + return None; + } + let free_bytes = avail_blocks.saturating_mul(frsize); + // df's use%: used / (used + available). `used` counts root-reserved + // blocks (total - bfree); `available` is the unprivileged free + // (bavail), so the percentage matches what `df` reports. + let used_blocks = total_blocks.saturating_sub(free_blocks); + let capacity = used_blocks.saturating_add(avail_blocks); + let used_pct = if capacity == 0 { + 0.0 + } else { + #[allow( + clippy::cast_precision_loss, + reason = "block counts stay well under f64's 2^53 exact-integer range, so this percentage computation loses no precision" + )] + let raw = used_blocks as f64 / capacity as f64 * 100.0; + raw + }; + Some(DiskUsage { + free_bytes, + used_pct, + }) +} diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 4b82da22..85009735 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -34,6 +34,7 @@ pub mod flake_check; pub mod forge; pub mod gateway_nginx; pub mod hive_stats; +pub mod host_stats; pub mod knowledge; pub mod lifecycle; pub mod limits;