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
+90
View File
@@ -0,0 +1,90 @@
//! Bring-your-own Beszel hub: connect a workspace's Beszel monitoring hub (store +
//! verify its login) and serve per-node metrics (latest snapshot + history proxied
//! live from the hub). Credentials are used server-side only.
use axum::extract::{Path, State};
use axum::Json;
use cm_db::repo::fleet_beszel::BeszelConn;
use cm_db::repo::{fleet_beszel, node_metrics, nodes};
use cm_domain::NodeId;
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{beszel, ApiError, AppState, Authed};
#[derive(Deserialize)]
pub struct ConnectReq {
#[serde(rename = "hubUrl")]
pub hub_url: String,
pub username: String,
pub password: String,
}
/// `POST /api/fleet/beszel` — store + verify the workspace's Beszel hub login.
pub async fn connect(
State(state): State<AppState>,
Authed(user): Authed,
Json(req): Json<ConnectReq>,
) -> Result<Json<Value>, ApiError> {
let hub = req.hub_url.trim().trim_end_matches('/').to_owned();
let username = req.username.trim().to_owned();
if hub.is_empty() || username.is_empty() || req.password.is_empty() {
return Ok(Json(
json!({ "ok": false, "error": "hubUrl, username and password are required" }),
));
}
let conn = BeszelConn { hub_url: hub.clone(), username: username.clone(), password: req.password.clone() };
// Verify the credentials authenticate before persisting.
if let Err(e) = beszel::authenticate(&reqwest::Client::new(), &conn).await {
return Ok(Json(json!({ "ok": false, "error": e })));
}
fleet_beszel::set(&state.pool, user.workspace_id, &hub, &username, &req.password).await?;
Ok(Json(json!({ "ok": true, "hubUrl": hub })))
}
/// `GET /api/fleet/beszel` — connection status.
pub async fn status(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let conn = fleet_beszel::get(&state.pool, user.workspace_id).await?;
Ok(Json(json!({ "connected": conn.is_some(), "hubUrl": conn.map(|c| c.hub_url) })))
}
/// `DELETE /api/fleet/beszel` — disconnect.
pub async fn disconnect(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
fleet_beszel::delete(&state.pool, user.workspace_id).await?;
Ok(Json(json!({ "ok": true })))
}
/// `GET /api/nodes/{id}/metrics` — latest snapshot + recent 1m history (proxied
/// live from the hub) for the per-node monitor page.
pub async fn node_metrics_get(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let node_id = NodeId::from(id);
nodes::get(&state.pool, node_id, user.workspace_id)
.await?
.ok_or(ApiError::NotFound)?;
let latest = node_metrics::latest(&state.pool, node_id).await?;
let mut history = json!([]);
if let (Some(latest_v), Some(conn)) =
(&latest, fleet_beszel::get(&state.pool, user.workspace_id).await?)
{
if let Some(sysid) = latest_v.get("beszelSystemId").and_then(Value::as_str) {
let client = reqwest::Client::new();
if let Ok(token) = beszel::authenticate(&client, &conn).await {
if let Ok(h) = beszel::fetch_history(&client, &conn, &token, sysid).await {
history = h.get("items").cloned().unwrap_or_else(|| json!([]));
}
}
}
}
Ok(Json(json!({ "latest": latest, "history": history })))
}