//! 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, node_rules, 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, Authed(user): Authed, Json(req): Json, ) -> Result, 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, Authed(user): Authed, ) -> Result, 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, Authed(user): Authed, ) -> Result, 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, Authed(user): Authed, Path(id): Path, ) -> Result, 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 }))) } // ── Fleet automation rules ────────────────────────────────────────────────── const METRICS: [&str; 6] = [ "cpu_pct", "mem_pct", "disk_pct", "gpu_pct", "temp_max", "load1", ]; const OPS: [&str; 4] = [">", "<", ">=", "<="]; fn rule_json(r: &node_rules::NodeRule) -> Value { json!({ "id": r.id, "nodeId": r.node_id.map(|n| n.as_uuid()), "name": r.name, "enabled": r.enabled, "metric": r.metric, "op": r.op, "threshold": r.threshold, "forSeconds": r.for_seconds, "action": r.action, "lastFired": r.last_fired_at.map(|t| t.unix_timestamp()), }) } /// `GET /api/fleet/rules` — the workspace's automation rules. pub async fn rules_list( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let rules = node_rules::list(&state.pool, user.workspace_id).await?; Ok(Json( json!({ "rules": rules.iter().map(rule_json).collect::>() }), )) } #[derive(Deserialize)] pub struct RuleReq { pub name: String, #[serde(rename = "nodeId")] pub node_id: Option, pub metric: String, pub op: String, pub threshold: f64, #[serde(rename = "forSeconds")] pub for_seconds: Option, pub action: Value, } /// `POST /api/fleet/rules` — create an automation rule. pub async fn rules_create( State(state): State, Authed(user): Authed, Json(req): Json, ) -> Result, ApiError> { if !METRICS.contains(&req.metric.as_str()) || !OPS.contains(&req.op.as_str()) { return Ok(Json( json!({ "ok": false, "error": "invalid metric or operator" }), )); } let node_id = match req.node_id.as_deref() { Some(s) if !s.is_empty() && s != "all" => { let nid = NodeId::from(s.parse::().map_err(|_| ApiError::NotFound)?); nodes::get(&state.pool, nid, user.workspace_id) .await? .ok_or(ApiError::NotFound)?; Some(nid) } _ => None, }; let id = node_rules::create( &state.pool, user.workspace_id, node_id, req.name.trim(), &req.metric, &req.op, req.threshold, req.for_seconds.unwrap_or(60).max(0), &req.action, ) .await?; Ok(Json(json!({ "ok": true, "id": id }))) } #[derive(Deserialize)] pub struct RulePatch { pub enabled: bool, } /// `PATCH /api/fleet/rules/{id}` — enable/disable a rule. pub async fn rules_patch( State(state): State, Authed(user): Authed, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { node_rules::set_enabled(&state.pool, id, user.workspace_id, req.enabled).await?; Ok(Json(json!({ "ok": true }))) } /// `DELETE /api/fleet/rules/{id}` — delete a rule. pub async fn rules_delete( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { node_rules::delete(&state.pool, id, user.workspace_id).await?; Ok(Json(json!({ "ok": true }))) }