Files
clawmates/crates/cm-api/src/beszel.rs
T
Omar SobhandClaude Opus 5 dc8f65fc64
ci / gates (push) Failing after 6s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
fix(metrics): GPU, network and disk IO were arriving and being dropped
`gpu_pct` read `info.g` as a scalar. Beszel 0.18 puts GPU in a different
collection entirely, as a MAP keyed by GPU index —
`{"0":{"n":"GeForce RTX 5060 Ti","u":0,"p":4.38}}` in `system_stats.stats`
— and `systems.info` carries no `g` at all. So every NVIDIA node reported
null while the data sat one request away. Null and "no GPU" are
indistinguishable downstream, so the fleet card showed nothing and a
`gpu_pct` drain rule could never fire, both without an error.

`net_sent_ps`, `net_recv_ps`, `disk_read_ps` and `disk_write_ps` were
columns nothing ever wrote. They come from the same sample.

The two array orders were MEASURED, not read off a schema, because
inverting one does not fail — it reports upload as download forever:

  b   = [sent, recv]. `stats.ni` gives per-interface [sent_ps, recv_ps,
        total_sent, total_recv]; indices 2 and 3 matched /proc/net/dev
        tx_bytes and rx_bytes on all four of tank's interfaces, and `b` is
        the sum of the per-second pair across them.
  dio = [read, write]. An 800 MB dd on tank moved index 1 from 7441 to
        23688 while index 0 stayed near zero.

`info.ct` is deliberately NOT mapped to container_count. It reads 1 on
tank, which runs 1 container, and also 1 on architect, which runs 4 —
right exactly often enough to pass a spot check.

One extra request per poll, not one per node: the newest 1m sample for
every system arrives in a single sorted page. A hub that cannot answer it
falls back to the info snapshot rather than losing the CPU and memory
readings that still work.

GPU is the busiest card, not the mean — placement asks whether there is a
free GPU, and averaging a saturated card with an idle one answers a
question nobody asked.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 17:53:16 -07:00

375 lines
14 KiB
Rust

//! Bring-your-own Beszel hub client: authenticate to a workspace's Beszel hub
//! (PocketBase) and poll rich per-node metrics, mapping its `systems` to our fleet
//! nodes by hostname and upserting `node_metrics`. Server-side only.
use std::collections::HashMap;
use std::time::Duration;
use cm_db::repo::fleet_beszel::BeszelConn;
use cm_db::repo::node_metrics::NodeMetrics;
use cm_db::repo::{fleet_beszel, node_metrics, nodes};
use cm_domain::{NodeId, WorkspaceId};
use serde_json::{json, Value};
use sqlx::PgPool;
/// Authenticate to a Beszel hub → a PocketBase token. Tries the regular users
/// collection first, then superusers.
pub async fn authenticate(client: &reqwest::Client, conn: &BeszelConn) -> Result<String, String> {
let base = conn.hub_url.trim_end_matches('/');
for coll in ["users", "_superusers"] {
let url = format!("{base}/api/collections/{coll}/auth-with-password");
let resp = client
.post(&url)
.json(&json!({ "identity": conn.username, "password": conn.password }))
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status().is_success() {
let body: Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(tok) = body.get("token").and_then(Value::as_str) {
return Ok(tok.to_owned());
}
}
}
Err("auth failed (check hub URL + credentials)".into())
}
/// Fetch all `systems` records (current status + the `info` snapshot).
pub async fn fetch_systems(
client: &reqwest::Client,
conn: &BeszelConn,
token: &str,
) -> Result<Vec<Value>, String> {
let base = conn.hub_url.trim_end_matches('/');
let resp = client
.get(format!("{base}/api/collections/systems/records"))
.query(&[("perPage", "500")])
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("systems list HTTP {}", resp.status()));
}
let body: Value = resp.json().await.map_err(|e| e.to_string())?;
Ok(body
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default())
}
/// Newest `1m` sample per system, in ONE request.
///
/// The alternative is a request per system per poll, which grows with the
/// fleet for data that arrives in a single sorted page. `perPage` is generous
/// rather than exact because several samples belong to the same system: sorted
/// newest-first, the FIRST row seen for a system id is its latest, so later
/// rows for that system are skipped.
///
/// A hub that cannot answer this is not an error — the caller falls back to the
/// `systems.info` snapshot, which is what it used before this existed. Losing
/// GPU and IO detail must not cost the CPU and memory that still work.
pub async fn fetch_latest_stats(
client: &reqwest::Client,
conn: &BeszelConn,
token: &str,
) -> HashMap<String, Value> {
let base = conn.hub_url.trim_end_matches('/');
let resp = client
.get(format!("{base}/api/collections/system_stats/records"))
.query(&[
("perPage", "200"),
("sort", "-created"),
("filter", "type='1m'"),
])
.header("Authorization", token)
.send()
.await;
let Ok(resp) = resp else { return HashMap::new() };
if !resp.status().is_success() {
return HashMap::new();
}
let Ok(body) = resp.json::<Value>().await else {
return HashMap::new();
};
let mut out: HashMap<String, Value> = HashMap::new();
for row in body.get("items").and_then(Value::as_array).unwrap_or(&vec![]) {
let Some(sid) = row.get("system").and_then(Value::as_str) else {
continue;
};
if let Some(stats) = row.get("stats") {
out.entry(sid.to_string()).or_insert_with(|| stats.clone());
}
}
out
}
/// Proxy a system's recent 1m time-series (for the monitor-page charts).
pub async fn fetch_history(
client: &reqwest::Client,
conn: &BeszelConn,
token: &str,
system_id: &str,
) -> Result<Value, String> {
let base = conn.hub_url.trim_end_matches('/');
let filter = format!("system='{system_id}' && type='1m'");
let resp = client
.get(format!("{base}/api/collections/system_stats/records"))
.query(&[
("perPage", "120"),
("sort", "-created"),
("filter", filter.as_str()),
])
.header("Authorization", token)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("system_stats HTTP {}", resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
fn f(v: &Value, k: &str) -> Option<f64> {
v.get(k).and_then(Value::as_f64)
}
/// The n-th element of a numeric array field, as the integer the
/// `node_metrics` per-second columns store. Rounded rather than truncated: a
/// rate of 0.6 is traffic, and `as i64` would file it as silence.
fn pair(v: &Value, k: &str, idx: usize) -> Option<i64> {
v.get(k)
.and_then(Value::as_array)
.and_then(|a| a.get(idx))
.and_then(Value::as_f64)
.map(|n| n.round() as i64)
}
/// Busiest GPU's utilisation percentage, from a `system_stats` sample.
///
/// `stats.g` is a MAP keyed by GPU index — `{"0":{"n":"GeForce RTX 5060 Ti",
/// "u":0,"p":4.38}}` — where `u` is utilisation and `p` is power draw. This is
/// why `gpu_pct` was null on every NVIDIA node: the old mapping read `info.g`
/// as a scalar, and `info` carries no `g` at all in Beszel 0.18. The data was
/// arriving the whole time, one collection away.
///
/// MAX rather than mean across GPUs: the question placement asks is "is there a
/// free GPU here", and averaging a saturated card with an idle one answers a
/// question nobody asked.
fn gpu_busiest(stats: &Value) -> Option<f64> {
let gpus = stats.get("g")?.as_object()?;
gpus.values()
.filter_map(|g| g.get("u").and_then(Value::as_f64))
.fold(None, |acc: Option<f64>, u| Some(acc.map_or(u, |a| a.max(u))))
}
/// Map a Beszel `systems` record into our NodeMetrics.
///
/// `stats` is the newest `system_stats` sample for this system, when there is
/// one. It carries everything the `systems.info` snapshot does not: GPU,
/// per-second network, per-second disk IO.
///
/// The array orders below were MEASURED against the hosts, not read off a
/// schema — an inverted pair here does not fail, it reports upload as download
/// forever:
/// - `b` = [sent, recv]. `stats.ni` gives per-interface
/// `[sent_ps, recv_ps, total_sent, total_recv]`; indices 2 and 3 matched
/// `/proc/net/dev` tx_bytes and rx_bytes on all four of tank's interfaces,
/// and `b` is the sum of the per-second pair across them.
/// - `dio` = [read, write]. An 800 MB `dd` on tank moved index 1 from 7441 to
/// 23688 while index 0 stayed near zero.
///
/// `info.ct` is NOT mapped to `container_count`: it reads 1 on tank (1
/// container) and also 1 on architect (4 containers), so whatever it counts, it
/// is not that.
fn metrics_from_system(system: &Value, stats: Option<&Value>) -> NodeMetrics {
let info = system.get("info").cloned().unwrap_or_else(|| json!({}));
let load1 = info
.get("la")
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_f64);
let empty = json!({});
let st = stats.unwrap_or(&empty);
NodeMetrics {
cpu_pct: f(&info, "cpu"),
mem_pct: f(&info, "mp"),
disk_pct: f(&info, "dp"),
gpu_pct: gpu_busiest(st),
temp_max: f(&info, "dt"),
net_sent_ps: pair(st, "b", 0),
net_recv_ps: pair(st, "b", 1),
disk_read_ps: pair(st, "dio", 0),
disk_write_ps: pair(st, "dio", 1),
load1,
container_count: None,
data: json!({
"beszelSystemId": system.get("id").and_then(Value::as_str),
"status": system.get("status").and_then(Value::as_str),
"name": system.get("name").and_then(Value::as_str),
"host": system.get("host").and_then(Value::as_str),
"info": info,
// The GPU roster, so a card can name the card rather than only
// report a percentage.
"gpus": st.get("g").cloned().unwrap_or(Value::Null),
"temps": st.get("t").cloned().unwrap_or(Value::Null),
}),
}
}
/// Poll one workspace's hub: auth, fetch systems, match to nodes by hostname,
/// upsert metrics. Returns how many nodes were updated.
pub async fn poll_workspace(
client: &reqwest::Client,
pool: &PgPool,
ws: WorkspaceId,
conn: &BeszelConn,
) -> Result<usize, String> {
let token = authenticate(client, conn).await?;
let systems = fetch_systems(client, conn, &token).await?;
let stats = fetch_latest_stats(client, conn, &token).await;
let node_rows = nodes::list(pool, ws).await.map_err(|e| e.to_string())?;
// hostname/name (lowercased) → node id.
let mut by_host: HashMap<String, NodeId> = HashMap::new();
for n in &node_rows {
if let Some(h) = n.hostname.as_deref() {
by_host.insert(h.to_lowercase(), n.id);
}
by_host.entry(n.name.to_lowercase()).or_insert(n.id);
}
let mut updated = 0;
for sys in &systems {
let key = sys
.get("name")
.and_then(Value::as_str)
.or_else(|| sys.get("host").and_then(Value::as_str))
.map(str::to_lowercase);
let Some(node_id) = key.as_deref().and_then(|k| by_host.get(k).copied()) else {
continue;
};
let sample = sys
.get("id")
.and_then(Value::as_str)
.and_then(|id| stats.get(id));
if node_metrics::upsert(pool, node_id, &metrics_from_system(sys, sample))
.await
.is_ok()
{
updated += 1;
}
}
Ok(updated)
}
/// Spawn the background poller: every `interval`, refresh node_metrics for each
/// workspace with a Beszel hub connected.
pub fn spawn_poller(pool: PgPool, interval: Duration) {
tokio::spawn(async move {
let client = reqwest::Client::new();
let mut tick = tokio::time::interval(interval);
loop {
tick.tick().await;
let workspaces = match fleet_beszel::all(&pool).await {
Ok(w) => w,
Err(_) => continue,
};
for (ws, conn) in workspaces {
if let Err(e) = poll_workspace(&client, &pool, ws, &conn).await {
eprintln!("beszel poll (ws {}): {e}", ws.as_uuid());
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// A real 0.18.7 sample, copied from tank rather than invented.
fn sample() -> Value {
json!({
"b": [1830, 1811],
"dio": [204, 23688],
"g": { "0": { "n": "GeForce RTX 5060 Ti", "u": 37.5, "p": 4.38 } },
"t": { "GeForce RTX 5060 Ti": 29, "k10temp_tctl": 38.38 }
})
}
fn system() -> Value {
json!({
"id": "glo9hj260jhnlgr",
"name": "tank",
"host": "100.108.129.81",
"status": "up",
"info": { "cpu": 0.31, "mp": 7.14, "dp": 77.96, "dt": 38.85, "la": [0.03, 0.01, 0], "ct": 1 }
})
}
/// GPU comes from the stats sample's MAP, not from `info`.
///
/// This is the bug the whole change exists for: `info` carries no `g` in
/// 0.18, so reading it as a scalar produced null on every NVIDIA node while
/// the data sat one collection away. Null and "no GPU" are indistinguishable
/// downstream, so metrics-aware placement simply never saw a GPU.
#[test]
fn gpu_comes_from_the_stats_sample_not_the_info_snapshot() {
let m = metrics_from_system(&system(), Some(&sample()));
assert_eq!(m.gpu_pct, Some(37.5));
// No sample ⇒ no GPU claim. NOT zero: "we did not get a reading" and
// "the card is idle" are different facts.
assert_eq!(metrics_from_system(&system(), None).gpu_pct, None);
}
/// The busiest card, not the average.
#[test]
fn a_saturated_card_is_not_averaged_away_by_an_idle_one() {
let two = json!({ "g": { "0": { "u": 99.0 }, "1": { "u": 1.0 } } });
assert_eq!(gpu_busiest(&two), Some(99.0));
assert_eq!(gpu_busiest(&json!({})), None);
// Present but empty is still no reading.
assert_eq!(gpu_busiest(&json!({ "g": {} })), None);
}
/// The measured array orders. An inverted pair does not fail — it reports
/// upload as download, and disk reads as writes, forever.
///
/// `b` = [sent, recv]: `stats.ni` per-interface indices 2 and 3 matched
/// `/proc/net/dev` tx_bytes and rx_bytes on all four of tank's
/// interfaces, and `b` is the sum of the per-second pair.
/// `dio` = [read, write]: an 800 MB `dd` moved index 1 from 7441 to 23688
/// while index 0 stayed near zero.
#[test]
fn the_measured_array_orders_are_not_reinverted() {
let m = metrics_from_system(&system(), Some(&sample()));
assert_eq!(m.net_sent_ps, Some(1830), "b[0] is SENT");
assert_eq!(m.net_recv_ps, Some(1811), "b[1] is RECV");
assert_eq!(m.disk_read_ps, Some(204), "dio[0] is READ");
assert_eq!(m.disk_write_ps, Some(23688), "dio[1] is WRITE");
}
/// `info.ct` must not become `container_count`.
///
/// It reads 1 on tank, which runs 1 container, and ALSO 1 on architect,
/// which runs 4. It agrees with the truth exactly often enough to look
/// right in a spot check.
#[test]
fn the_unidentified_ct_field_is_not_reported_as_a_container_count() {
let m = metrics_from_system(&system(), Some(&sample()));
assert_eq!(m.container_count, None);
assert_eq!(system()["info"]["ct"], json!(1));
}
/// The snapshot fields keep working when the stats call fails.
#[test]
fn a_missing_stats_sample_does_not_cost_the_metrics_that_still_work() {
let m = metrics_from_system(&system(), None);
assert_eq!(m.cpu_pct, Some(0.31));
assert_eq!(m.mem_pct, Some(7.14));
assert_eq!(m.temp_max, Some(38.85));
assert_eq!(m.load1, Some(0.03));
assert_eq!(m.net_sent_ps, None);
}
}