feat(#2289): push-based server-warning registry with RAII guard

This commit is contained in:
damocles 2026-07-10 13:39:11 +02:00 committed by mara
commit 9f98925c14
5 changed files with 361 additions and 34 deletions

View file

@ -8,10 +8,12 @@
//! 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.
//! [`ServerWarning`]s for `/api/state`. It is now just a snapshot of the
//! push-based [`crate::warnings`] registry — producers raise/clear their
//! own warnings via an RAII guard rather than being re-probed on every
//! render. The disk-pressure check is reworked onto that model here:
//! [`refresh_disk_warning`] drives a held guard from a periodic task (see
//! `spawn` in `main.rs`) instead of a `statvfs` per `/api/state`.
use std::collections::HashMap;
@ -37,36 +39,62 @@ pub struct ServerWarning {
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`).
/// Snapshot the current server-level warnings for the dashboard banner —
/// a cheap read of the push-based [`crate::warnings`] registry. Producers
/// (the disk watch below, background converge sweeps, …) raise and clear
/// their own entries via [`crate::warnings::set_warning`]; nothing is
/// re-probed here.
#[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
),
});
crate::warnings::snapshot()
}
/// Pure disk-pressure decision: given usage, return the banner
/// `(level, message)` or `None` when below the warn threshold. Split from
/// the probe + guard plumbing so the threshold + formatting is unit-tested
/// without a real `statvfs`.
fn disk_pressure_warning(used_pct: f64, free_bytes: u64) -> Option<(&'static str, String)> {
if used_pct < DISK_WARN_PCT {
return None;
}
#[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 = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let level = if used_pct >= DISK_CRIT_PCT {
"crit"
} else {
"warn"
};
let message = format!(
"host nix store {used_pct:.0}% full ({free_gib:.1} GiB free) \
garbage-collect the store before it runs out of space"
);
Some((level, message))
}
/// One tick of the disk-pressure watch: probe the store and reconcile the
/// held warning `guard` — raise or refresh it while usage is over
/// threshold, drop it (clearing the banner) once back under. Driven by the
/// periodic disk-watch task spawned in `main.rs`, replacing the old
/// per-render `statvfs` in [`server_warnings`].
pub fn refresh_disk_warning(guard: &mut Option<crate::warnings::WarningGuard>) {
let decision = nix_disk_usage().and_then(|d| disk_pressure_warning(d.used_pct, d.free_bytes));
match decision {
Some((level, message)) => match guard {
Some(g) => g.update(level, message),
None => {
*guard = Some(crate::warnings::set_warning(
"disk_pressure",
level,
message,
));
}
},
// Back under threshold (or probe failed) — drop the guard to clear.
None => *guard = None,
}
out
}
/// Agent-state warnings derived from the live container snapshot the
@ -194,6 +222,25 @@ fn disk_usage(path: &str) -> Option<DiskUsage> {
mod tests {
use super::*;
#[test]
fn disk_below_threshold_is_none() {
assert!(disk_pressure_warning(84.9, 100 << 30).is_none());
}
#[test]
fn disk_warn_band_is_amber() {
let (level, msg) = disk_pressure_warning(90.0, 20 << 30).expect("warn");
assert_eq!(level, "warn");
assert!(msg.contains("90% full"));
assert!(msg.contains("20.0 GiB free"));
}
#[test]
fn disk_crit_band_is_red() {
let (level, _) = disk_pressure_warning(95.0, 1 << 30).expect("crit");
assert_eq!(level, "crit");
}
fn cv(name: &str, needs_login: bool) -> ContainerView {
ContainerView {
name: name.to_owned(),