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:
parent
d9d2a52221
commit
5d0f3d060b
10 changed files with 241 additions and 6 deletions
|
|
@ -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 `<body>` 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
|
||||
`<swarm> / <hive>` once `hive_name` / `swarm_name` arrive in the
|
||||
state snapshot. When there are pending approvals or unanswered
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */ });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<PeerHiveView>,
|
||||
/// 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<crate::host_stats::ServerWarning>,
|
||||
}
|
||||
|
||||
/// One peer hive for the P33RS dashboard tab. Derived from
|
||||
|
|
@ -488,6 +494,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
|||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
peer_hives: parse_peer_hives(),
|
||||
server_warnings: crate::host_stats::server_warnings(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
124
hive-c0re/src/host_stats.rs
Normal file
124
hive-c0re/src/host_stats.rs
Normal file
|
|
@ -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<ServerWarning> {
|
||||
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<DiskUsage> {
|
||||
disk_usage("/nix").or_else(|| disk_usage("/"))
|
||||
}
|
||||
|
||||
fn disk_usage(path: &str) -> Option<DiskUsage> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue