Fleet: Beszel hub integration — rich per-node metrics + per-node monitor (Phase 1)
ci / gates (push) Failing after 5s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

Tap each node's Beszel metrics (GPU/temps/disk-IO/network/per-container — beyond
our basic heartbeat) by reading the workspace's Beszel hub. The agents run in
WS-only mode with no locally-readable socket, so (per the de-risk) the server taps
the hub's PocketBase API instead of the daemon reading agents — no daemon changes.

- migrations: workspace_beszel (BYO hub URL + login, server-side only, mirrors the
  Tailscale BYO pattern) + node_metrics (latest scalar columns + JSONB blob).
- cm-db: repo/fleet_beszel.rs, repo/node_metrics.rs; nodes SELECT joins node_metrics
  (gpu_pct/temp_max surfaced on node_json for the live cards).
- cm-api: beszel.rs client (auth-with-password, poll `systems`, map to nodes by
  hostname, upsert metrics) + a 15s spawn_poller; routes/beszel.rs (connect/status/
  disconnect + GET /api/nodes/{id}/metrics with history proxied live from the hub).
- frontend: HostCard gains a GPU/temp readout + a Monitor button; NodeMonitor is a
  full-width per-node page (current panel + CPU/mem/GPU/temp/net/disk charts from the
  hub's 1m history); a "Beszel monitoring" connect form in the Local view.

Reachability confirmed: gw-04 → the hub over the tailnet (100.123.224.84:8090). Needs
the user to connect their hub login to activate the poller.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-25 23:28:29 -07:00
co-authored by Claude Opus 4.8
parent 4de2f31b50
commit 36a227566b
17 changed files with 708 additions and 8 deletions
+75
View File
@@ -0,0 +1,75 @@
//! Rich per-node metrics (from the workspace's Beszel hub). One latest snapshot
//! per node: scalar columns for the fleet cards + rules engine, plus a JSONB blob
//! for the per-node monitor page.
use cm_domain::NodeId;
use serde_json::Value;
use sqlx::{PgPool, Row};
use crate::DbError;
/// The latest metric snapshot for a node (nullable scalars + the full blob).
#[derive(Debug, Clone, Default)]
pub struct NodeMetrics {
pub cpu_pct: Option<f64>,
pub mem_pct: Option<f64>,
pub disk_pct: Option<f64>,
pub gpu_pct: Option<f64>,
pub temp_max: Option<f64>,
pub net_sent_ps: Option<i64>,
pub net_recv_ps: Option<i64>,
pub disk_read_ps: Option<i64>,
pub disk_write_ps: Option<i64>,
pub load1: Option<f64>,
pub container_count: Option<i32>,
pub data: Value,
}
/// Upsert a node's latest metrics snapshot.
pub async fn upsert(pool: &PgPool, node_id: NodeId, m: &NodeMetrics) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO node_metrics
(node_id, updated_at, cpu_pct, mem_pct, disk_pct, gpu_pct, temp_max,
net_sent_ps, net_recv_ps, disk_read_ps, disk_write_ps, load1, container_count, data)
VALUES ($1, now(), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (node_id) DO UPDATE SET
updated_at = now(), cpu_pct = excluded.cpu_pct, mem_pct = excluded.mem_pct,
disk_pct = excluded.disk_pct, gpu_pct = excluded.gpu_pct, temp_max = excluded.temp_max,
net_sent_ps = excluded.net_sent_ps, net_recv_ps = excluded.net_recv_ps,
disk_read_ps = excluded.disk_read_ps, disk_write_ps = excluded.disk_write_ps,
load1 = excluded.load1, container_count = excluded.container_count, data = excluded.data",
)
.bind(node_id.as_uuid())
.bind(m.cpu_pct)
.bind(m.mem_pct)
.bind(m.disk_pct)
.bind(m.gpu_pct)
.bind(m.temp_max)
.bind(m.net_sent_ps)
.bind(m.net_recv_ps)
.bind(m.disk_read_ps)
.bind(m.disk_write_ps)
.bind(m.load1)
.bind(m.container_count)
.bind(&m.data)
.execute(pool)
.await?;
Ok(())
}
/// The latest metrics blob for a node (the full snapshot for the monitor page).
pub async fn latest(pool: &PgPool, node_id: NodeId) -> Result<Option<Value>, DbError> {
let row = sqlx::query(
"SELECT data, extract(epoch from updated_at)::bigint AS updated FROM node_metrics WHERE node_id = $1",
)
.bind(node_id.as_uuid())
.fetch_optional(pool)
.await?;
Ok(row.map(|r| {
let mut data: Value = r.get("data");
if let Some(obj) = data.as_object_mut() {
obj.insert("updatedAt".into(), serde_json::json!(r.get::<i64, _>("updated")));
}
data
}))
}