Fleet: Beszel hub integration — rich per-node metrics + per-node monitor (Phase 1)
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:
co-authored by
Claude Opus 4.8
parent
4de2f31b50
commit
36a227566b
@@ -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 })))
|
||||
}
|
||||
Reference in New Issue
Block a user