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;