Fleet P0: node registry + daemon + health + connect-host wizard
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

Users can connect their own local-hardware nodes into a fleet. Each node runs a
new Rust daemon that dials home over an outbound WebSocket, reports host health,
and runs commands we send.

Backend:
- migrations/0018_fleet_nodes.sql: nodes + node_health tables + agent_containers
  (node_id, workspace_id) index. cm-domain NodeId.
- cm-db repo/nodes.rs: create/auth/list+health/get/heartbeat/set_status/delete
  (unchecked sqlx, no .sqlx regen).
- cm-api fleet.rs NodeHub: live daemon channels (node_id→sender) + the WS channel
  runner (heartbeat→DB upsert, exec request/response framing). routes/nodes.rs:
  POST /pair, GET /nodes, SSE /nodes/live, POST /{id}/exec-test, DELETE /{id},
  WS /nodes/agent (token-auth). Wired into AppState + router.

Daemon (new crate crates/bins/clawmates-node):
- sysinfo host metrics (cpu/mem/pressure/swap/disk/load/containers), outbound WSS
  dial + reconnect, heartbeat loop, exec command handling, tailscale-ip probe.
  install.sh convenience installer.

Frontend:
- Fleet sidebar item + FleetOverview + LocalHardware node-health cards (live via
  /api/nodes, 3s poll) + ConnectHostWizard (install → verify connection →
  exec-test). InfraStage dispatches fleet/local; default selection = fleet.

Deferred: P1 (BYO Tailscale + network metrics), P2 (RemoteDriver + placement so
agents actually run on connected nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 08:09:14 -07:00
co-authored by Claude Opus 4.8
parent b853aab6fd
commit 2bdd0a23e8
18 changed files with 1343 additions and 9 deletions
Generated
+93 -5
View File
@@ -729,6 +729,18 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "clawmates-node"
version = "0.1.0"
dependencies = [
"futures",
"serde",
"serde_json",
"sysinfo",
"tokio",
"tokio-tungstenite 0.26.2",
]
[[package]] [[package]]
name = "clawmates-server" name = "clawmates-server"
version = "0.1.0" version = "0.1.0"
@@ -2182,7 +2194,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core", "windows-core 0.62.2",
] ]
[[package]] [[package]]
@@ -2789,6 +2801,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -3957,7 +3978,7 @@ dependencies = [
"security-framework 3.7.0", "security-framework 3.7.0",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4668,6 +4689,20 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "sysinfo"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"rayon",
"windows",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -5614,19 +5649,52 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link", "windows-link",
"windows-result", "windows-result 0.4.1",
"windows-strings", "windows-strings",
] ]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@@ -5638,6 +5706,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
@@ -5655,6 +5734,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
+1
View File
@@ -22,6 +22,7 @@ members = [
"crates/cm-api", "crates/cm-api",
"crates/bins/clawmates-server", "crates/bins/clawmates-server",
"crates/bins/clawmates-broker", "crates/bins/clawmates-broker",
"crates/bins/clawmates-node",
"tools/bundler", "tools/bundler",
] ]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "clawmates-node"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "clawmates-node"
path = "src/main.rs"
[dependencies]
tokio = { workspace = true, features = ["process"] }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures = "0.3"
serde = { workspace = true }
serde_json = { workspace = true }
sysinfo = "0.33"
[lints]
workspace = true
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# clawmates-node installer. Downloads the daemon binary and runs it against your
# ClawMates gateway with the pairing token from the "Connect a host" wizard.
#
# curl -fsSL <gateway>/install.sh | bash -s -- --server <gateway> --token <token>
#
# Until binary hosting is wired up you can also build from source:
# cargo build --release -p clawmates-node
# ./target/release/clawmates-node --server <gateway> --token <token>
set -euo pipefail
SERVER=""
TOKEN=""
while [[ $# -gt 0 ]]; do
case "$1" in
--server) SERVER="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$SERVER" || -z "$TOKEN" ]]; then
echo "usage: install.sh --server <https://gateway> --token <token>" >&2
exit 2
fi
BIN_DIR="${CLAWMATES_BIN_DIR:-$HOME/.clawmates/bin}"
BIN="$BIN_DIR/clawmates-node"
mkdir -p "$BIN_DIR"
# Download the prebuilt binary if a release URL is configured; otherwise the user
# builds from source (see header) and points CLAWMATES_NODE_BIN at it.
if [[ -n "${CLAWMATES_NODE_BIN:-}" ]]; then
cp "$CLAWMATES_NODE_BIN" "$BIN"
elif [[ ! -x "$BIN" ]]; then
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
URL="${CLAWMATES_NODE_URL:-$SERVER/dl/clawmates-node-$OS-$ARCH}"
echo "downloading clawmates-node from $URL"
curl -fsSL "$URL" -o "$BIN" || {
echo "download failed — build from source: cargo build --release -p clawmates-node" >&2
exit 1
}
fi
chmod +x "$BIN"
echo "starting clawmates-node → $SERVER"
exec "$BIN" --server "$SERVER" --token "$TOKEN"
+202
View File
@@ -0,0 +1,202 @@
//! clawmates-node — the fleet daemon a user installs on each local-hardware node.
//!
//! It dials home to the ClawMates gateway over an OUTBOUND WebSocket
//! (`/api/nodes/agent?token=…`), reports host health on a heartbeat, and runs the
//! commands the gateway sends (host verification today; container placement in a
//! later phase). Outbound-only → no inbound port, NAT-friendly.
use std::time::Duration;
use futures::{SinkExt, StreamExt};
use serde_json::{json, Value};
use sysinfo::{Disks, System};
use tokio_tungstenite::tungstenite::Message;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main]
async fn main() {
let (server, token) = parse_args();
if server.is_empty() || token.is_empty() {
eprintln!("usage: clawmates-node --server <https://gateway> --token <token>");
eprintln!(" (or set CLAWMATES_SERVER / CLAWMATES_TOKEN)");
std::process::exit(2);
}
let ws_url = ws_url(&server, &token);
println!("clawmates-node {VERSION} connecting to {server}");
loop {
if let Err(e) = run(&ws_url).await {
eprintln!("channel ended: {e}; reconnecting in 5s");
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
fn parse_args() -> (String, String) {
let mut server = String::new();
let mut token = String::new();
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--server" => server = args.next().unwrap_or_default(),
"--token" => token = args.next().unwrap_or_default(),
_ => {}
}
}
if server.is_empty() {
server = std::env::var("CLAWMATES_SERVER").unwrap_or_default();
}
if token.is_empty() {
token = std::env::var("CLAWMATES_TOKEN").unwrap_or_default();
}
(server, token)
}
fn ws_url(server: &str, token: &str) -> String {
let base = server.trim_end_matches('/');
let base = base
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1);
let base = if base.starts_with("ws") {
base
} else {
format!("wss://{base}")
};
format!("{base}/api/nodes/agent?token={token}")
}
async fn run(ws_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?;
println!("connected; reporting health every 5s");
let (mut write, mut read) = ws.split();
let mut sys = System::new_all();
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
tokio::select! {
_ = ticker.tick() => {
let hb = heartbeat(&mut sys);
write.send(Message::Text(hb.into())).await?;
}
msg = read.next() => match msg {
Some(Ok(Message::Text(t))) => {
if let Some(resp) = handle_command(t.as_str()).await {
write.send(Message::Text(resp.into())).await?;
}
}
Some(Ok(Message::Ping(p))) => write.send(Message::Pong(p)).await?,
Some(Ok(Message::Close(_))) | None => return Ok(()),
Some(Err(e)) => return Err(e.into()),
_ => {}
},
}
}
}
/// Collect a host-health snapshot and serialize the heartbeat frame.
fn heartbeat(sys: &mut System) -> String {
sys.refresh_cpu_usage();
sys.refresh_memory();
let mem_total = sys.total_memory() as i64;
let mem_used = sys.used_memory() as i64;
let mem_pressure = if mem_total > 0 {
mem_used as f64 / mem_total as f64
} else {
0.0
};
let load = System::load_average();
let (disk_total, disk_free) = root_disk();
json!({
"t": "heartbeat",
"version": VERSION,
"tailscale_ip": tailscale_ip(),
"health": {
"cpu_pct": sys.global_cpu_usage() as f64,
"mem_total": mem_total,
"mem_used": mem_used,
"mem_pressure": mem_pressure,
"swap_used": sys.used_swap() as i64,
"disk_total": disk_total,
"disk_free": disk_free,
"load1": load.one,
"load5": load.five,
"load15": load.fifteen,
"container_count": docker_count(),
}
})
.to_string()
}
/// Total + available bytes of the filesystem backing `/` (largest disk as a
/// fallback).
fn root_disk() -> (i64, i64) {
let disks = Disks::new_with_refreshed_list();
let mut best: Option<(i64, i64)> = None;
for d in &disks {
let total = d.total_space() as i64;
let free = d.available_space() as i64;
if d.mount_point().as_os_str() == "/" {
return (total, free);
}
if best.map(|(t, _)| total > t).unwrap_or(true) {
best = Some((total, free));
}
}
best.unwrap_or((0, 0))
}
/// Number of running Docker containers (0 if Docker is absent).
fn docker_count() -> i32 {
std::process::Command::new("docker")
.args(["ps", "-q"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).lines().count() as i32)
.unwrap_or(0)
}
/// This node's Tailscale IP, if Tailscale is up (BYO tailnet).
fn tailscale_ip() -> Option<String> {
let out = std::process::Command::new("tailscale")
.args(["ip", "-4"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let ip = String::from_utf8_lossy(&out.stdout).trim().to_owned();
(!ip.is_empty()).then_some(ip)
}
/// Run a command the gateway requested and serialize the result frame.
async fn handle_command(text: &str) -> Option<String> {
let v: Value = serde_json::from_str(text).ok()?;
if v.get("t").and_then(Value::as_str) != Some("exec") {
return None;
}
let id = v.get("id").and_then(Value::as_u64)?;
let cmd: Vec<String> = v
.get("cmd")?
.as_array()?
.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect();
if cmd.is_empty() {
return None;
}
let (ok, output) = match tokio::process::Command::new(&cmd[0])
.args(&cmd[1..])
.output()
.await
{
Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
if !o.stderr.is_empty() {
s.push_str(&String::from_utf8_lossy(&o.stderr));
}
(o.status.success(), s)
}
Err(e) => (false, format!("exec error: {e}")),
};
Some(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string())
}
+167
View File
@@ -0,0 +1,167 @@
//! Fleet control plane: an in-memory hub of live daemon control channels plus
//! the WebSocket channel runner. Each connected `clawmates-node` daemon dials
//! `GET /api/nodes/agent?token=…` (outbound), and we drive that socket to
//! receive host-health heartbeats and to run commands on the node.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use cm_db::repo::nodes::{self, NodeHealth};
use cm_domain::NodeId;
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::json;
use sqlx::PgPool;
use tokio::sync::{mpsc, oneshot, Mutex};
/// The result of running a command on a node.
#[derive(Debug, Clone)]
pub struct ExecOutput {
pub ok: bool,
pub output: String,
}
struct NodeConn {
tx: mpsc::UnboundedSender<String>,
pending: Mutex<HashMap<u64, oneshot::Sender<ExecOutput>>>,
next_id: AtomicU64,
}
/// Registry of live daemon channels, keyed by node id.
#[derive(Default)]
pub struct NodeHub {
conns: Mutex<HashMap<NodeId, Arc<NodeConn>>>,
}
impl NodeHub {
pub fn new() -> Self {
Self::default()
}
pub async fn is_online(&self, id: NodeId) -> bool {
self.conns.lock().await.contains_key(&id)
}
async fn get(&self, id: NodeId) -> Option<Arc<NodeConn>> {
self.conns.lock().await.get(&id).cloned()
}
/// Run a command on a connected node and await its output (with a timeout).
pub async fn exec(&self, id: NodeId, cmd: &[String]) -> Result<ExecOutput, String> {
let conn = self.get(id).await.ok_or("node is not connected")?;
let req_id = conn.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
conn.pending.lock().await.insert(req_id, tx);
let frame = json!({ "t": "exec", "id": req_id, "cmd": cmd }).to_string();
conn.tx
.send(frame)
.map_err(|_| "node channel closed".to_string())?;
match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await {
Ok(Ok(out)) => Ok(out),
Ok(Err(_)) => Err("node dropped before responding".into()),
Err(_) => {
conn.pending.lock().await.remove(&req_id);
Err("node timed out".into())
}
}
}
}
#[derive(Deserialize)]
#[serde(tag = "t")]
enum Uplink {
#[serde(rename = "heartbeat")]
Heartbeat {
version: Option<String>,
tailscale_ip: Option<String>,
health: HealthMsg,
},
#[serde(rename = "result")]
Result { id: u64, ok: bool, output: String },
}
#[derive(Deserialize)]
struct HealthMsg {
cpu_pct: f64,
mem_total: i64,
mem_used: i64,
mem_pressure: f64,
swap_used: i64,
disk_total: i64,
disk_free: i64,
load1: f64,
load5: f64,
load15: f64,
container_count: i32,
}
/// Drive a daemon's control channel: register it, pump outbound command frames,
/// and apply uplink heartbeats/results until the socket closes.
pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket) {
let (mut ws_tx, mut ws_rx) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let conn = Arc::new(NodeConn {
tx,
pending: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(0),
});
hub.conns.lock().await.insert(node_id, conn.clone());
let writer = async {
while let Some(frame) = rx.recv().await {
if ws_tx.send(Message::Text(frame.into())).await.is_err() {
break;
}
}
};
let reader = async {
while let Some(Ok(msg)) = ws_rx.next().await {
let Message::Text(t) = msg else { continue };
match serde_json::from_str::<Uplink>(t.as_str()) {
Ok(Uplink::Heartbeat {
version,
tailscale_ip,
health,
}) => {
let h = NodeHealth {
cpu_pct: health.cpu_pct,
mem_total: health.mem_total,
mem_used: health.mem_used,
mem_pressure: health.mem_pressure,
swap_used: health.swap_used,
disk_total: health.disk_total,
disk_free: health.disk_free,
load1: health.load1,
load5: health.load5,
load15: health.load15,
container_count: health.container_count,
};
let _ = nodes::heartbeat(
&pool,
node_id,
version.as_deref(),
tailscale_ip.as_deref(),
&h,
)
.await;
}
Ok(Uplink::Result { id, ok, output }) => {
if let Some(s) = conn.pending.lock().await.remove(&id) {
let _ = s.send(ExecOutput { ok, output });
}
}
Err(_) => {}
}
}
};
tokio::select! {
_ = writer => {},
_ = reader => {},
}
hub.conns.lock().await.remove(&node_id);
let _ = nodes::set_status(&pool, node_id, "offline").await;
}
+10
View File
@@ -3,6 +3,7 @@
pub mod cleanup_sweeper; pub mod cleanup_sweeper;
mod error; mod error;
mod extract; mod extract;
pub mod fleet;
mod mcp_door; mod mcp_door;
pub mod quota; pub mod quota;
mod recursive_exec; mod recursive_exec;
@@ -34,6 +35,8 @@ pub struct AppState {
/// Local blob-store root (Some on the Local backend) so the Files app can /// Local blob-store root (Some on the Local backend) so the Files app can
/// reconcile its index with files the Terminal wrote into the drives. /// reconcile its index with files the Terminal wrote into the drives.
pub file_root: Option<std::path::PathBuf>, pub file_root: Option<std::path::PathBuf>,
/// Live control channels to connected fleet-node daemons.
pub node_hub: std::sync::Arc<fleet::NodeHub>,
} }
impl AppState { impl AppState {
@@ -47,6 +50,7 @@ impl AppState {
oauth: cm_config::OAuthConfig::default(), oauth: cm_config::OAuthConfig::default(),
billing: cm_config::BillingConfig::default(), billing: cm_config::BillingConfig::default(),
file_root: None, file_root: None,
node_hub: std::sync::Arc::new(fleet::NodeHub::new()),
} }
} }
@@ -104,6 +108,12 @@ pub fn router(state: AppState) -> Router {
.route("/api/quota", get(quota::get_quota)) .route("/api/quota", get(quota::get_quota))
.route("/api/world/live", get(routes::world::world_live)) .route("/api/world/live", get(routes::world::world_live))
.route("/api/world/replay", get(routes::world::world_replay)) .route("/api/world/replay", get(routes::world::world_replay))
.route("/api/nodes", get(routes::nodes::list))
.route("/api/nodes/pair", post(routes::nodes::pair))
.route("/api/nodes/live", get(routes::nodes::live))
.route("/api/nodes/agent", get(routes::nodes::agent_ws))
.route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test))
.route("/api/nodes/{id}", delete(routes::nodes::remove))
.route("/mcp", post(mcp_door::mcp)) .route("/mcp", post(mcp_door::mcp))
.route("/api/auth/login", post(routes::auth::login)) .route("/api/auth/login", post(routes::auth::login))
.route("/api/auth/logout", post(routes::auth::logout)) .route("/api/auth/logout", post(routes::auth::logout))
+1
View File
@@ -12,6 +12,7 @@ pub mod files;
pub mod gateway; pub mod gateway;
pub mod health; pub mod health;
pub mod identity; pub mod identity;
pub mod nodes;
pub mod oauth; pub mod oauth;
pub mod orgs; pub mod orgs;
pub mod routines; pub mod routines;
+153
View File
@@ -0,0 +1,153 @@
//! Fleet node registry HTTP/SSE/WS routes: pair a new node, list nodes with live
//! health, stream health updates, run a verification command, deregister, and the
//! daemon's outbound control channel.
use std::convert::Infallible;
use std::time::Duration;
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::{Path, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::Json;
use cm_db::repo::nodes;
use cm_domain::NodeId;
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::fleet::run_channel;
use crate::{ApiError, AppState, Authed};
/// An unguessable control-channel token (two time-ordered UUIDs).
fn gen_token() -> String {
format!("{}{}", Uuid::now_v7().simple(), Uuid::now_v7().simple())
}
fn node_json(n: &nodes::NodeRow) -> Value {
json!({
"id": n.id,
"name": n.name,
"status": n.status,
"agentVersion": n.agent_version,
"tailscaleIp": n.tailscale_ip,
"lastSeen": n.last_seen.map(|t| t.unix_timestamp()),
"createdAt": n.created_at.unix_timestamp(),
"health": n.health.as_ref().map(|h| json!({
"cpuPct": h.cpu_pct,
"memTotal": h.mem_total,
"memUsed": h.mem_used,
"memPressure": h.mem_pressure,
"swapUsed": h.swap_used,
"diskTotal": h.disk_total,
"diskFree": h.disk_free,
"load1": h.load1,
"load5": h.load5,
"load15": h.load15,
"containerCount": h.container_count,
})),
})
}
#[derive(Deserialize)]
pub struct PairReq {
pub name: Option<String>,
}
/// `POST /api/nodes/pair` — register a pending node + mint its control-channel
/// token. The frontend builds the `curl … | bash` install command (it knows its
/// own origin); we just return the token + node id.
pub async fn pair(
State(state): State<AppState>,
Authed(user): Authed,
Json(req): Json<PairReq>,
) -> Result<Json<Value>, ApiError> {
let token = gen_token();
let name = req
.name
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "New node".to_owned());
let id = nodes::create(&state.pool, user.workspace_id, &name, &token).await?;
Ok(Json(json!({ "id": id, "token": token })))
}
/// `GET /api/nodes` — list the workspace's nodes with their latest health.
pub async fn list(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let rows = nodes::list(&state.pool, user.workspace_id).await?;
Ok(Json(json!({ "nodes": rows.iter().map(node_json).collect::<Vec<_>>() })))
}
/// `GET /api/nodes/live` — SSE stream of the node list + health (2s poll).
pub async fn live(State(state): State<AppState>, Authed(user): Authed) -> impl IntoResponse {
let pool = state.pool.clone();
let ws = user.workspace_id;
let stream = async_stream::stream! {
loop {
if let Ok(rows) = nodes::list(&pool, ws).await {
let arr: Vec<_> = rows.iter().map(node_json).collect();
let data = serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned());
yield Ok::<_, Infallible>(Event::default().event("nodes").data(data));
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
/// `POST /api/nodes/{id}/exec-test` — run a verification command on the node and
/// return its output (wizard step 3: "we can run commands on your behalf").
pub async fn exec_test(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let node_id = NodeId::from(id);
// Scope: the node must belong to the caller's workspace.
let node = nodes::get(&state.pool, node_id, user.workspace_id)
.await?
.ok_or(ApiError::NotFound)?;
let cmd = vec![
"sh".to_owned(),
"-lc".to_owned(),
"uname -a; echo '---'; docker version --format 'docker {{.Server.Version}}' 2>/dev/null || echo 'docker: not found'".to_owned(),
];
match state.node_hub.exec(node_id, &cmd).await {
Ok(out) => Ok(Json(json!({ "ok": out.ok, "output": out.output, "node": node.name }))),
Err(e) => Ok(Json(json!({ "ok": false, "output": e, "node": node.name }))),
}
}
/// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped).
pub async fn remove(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
nodes::delete(&state.pool, NodeId::from(id), user.workspace_id).await?;
Ok(Json(json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct AgentQuery {
pub token: String,
}
/// `GET /api/nodes/agent?token=…` — the daemon's outbound control channel. The
/// WS handshake can't carry a bearer header, so the daemon authenticates with
/// its node token in the query string (like the Terminal WS ticket).
pub async fn agent_ws(
State(state): State<AppState>,
Query(q): Query<AgentQuery>,
upgrade: WebSocketUpgrade,
) -> Response {
let auth = nodes::auth(&state.pool, &q.token).await.ok().flatten();
let Some((node_id, _workspace_id)) = auth else {
return ApiError::Unauthorized.into_response();
};
let pool = state.pool.clone();
let hub = state.node_hub.clone();
upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket))
}
+1
View File
@@ -7,6 +7,7 @@ pub mod connections;
pub mod credits; pub mod credits;
pub mod files; pub mod files;
pub mod messages; pub mod messages;
pub mod nodes;
pub mod orgs; pub mod orgs;
pub mod outbox; pub mod outbox;
pub mod routine_runs; pub mod routine_runs;
+213
View File
@@ -0,0 +1,213 @@
//! Fleet node registry: user-connected local-hardware hosts that run the
//! clawmates-node daemon. The daemon authenticates its control channel with the
//! node `token`, then upserts its host-health snapshot on every heartbeat.
use cm_domain::{NodeId, WorkspaceId};
use sqlx::{PgPool, Row};
use time::OffsetDateTime;
use crate::DbError;
/// Latest host-health snapshot for a node.
#[derive(Debug, Clone)]
pub struct NodeHealth {
pub cpu_pct: f64,
pub mem_total: i64,
pub mem_used: i64,
pub mem_pressure: f64,
pub swap_used: i64,
pub disk_total: i64,
pub disk_free: i64,
pub load1: f64,
pub load5: f64,
pub load15: f64,
pub container_count: i32,
}
/// A registered node plus its latest health (if it has reported one).
#[derive(Debug, Clone)]
pub struct NodeRow {
pub id: NodeId,
pub name: String,
pub status: String,
pub agent_version: Option<String>,
pub tailscale_ip: Option<String>,
pub last_seen: Option<OffsetDateTime>,
pub created_at: OffsetDateTime,
pub health: Option<NodeHealth>,
}
/// Register a new (pending) node with its control-channel token.
pub async fn create(
pool: &PgPool,
workspace_id: WorkspaceId,
name: &str,
token: &str,
) -> Result<NodeId, DbError> {
let id = NodeId::new();
sqlx::query("INSERT INTO nodes (id, workspace_id, name, status, token) VALUES ($1, $2, $3, 'pending', $4)")
.bind(id.as_uuid())
.bind(workspace_id.as_uuid())
.bind(name)
.bind(token)
.execute(pool)
.await?;
Ok(id)
}
/// Resolve a control-channel token to its node + workspace (daemon auth).
pub async fn auth(pool: &PgPool, token: &str) -> Result<Option<(NodeId, WorkspaceId)>, DbError> {
let row = sqlx::query("SELECT id, workspace_id FROM nodes WHERE token = $1")
.bind(token)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| {
(
NodeId::from(r.get::<uuid::Uuid, _>("id")),
WorkspaceId::from(r.get::<uuid::Uuid, _>("workspace_id")),
)
}))
}
const SELECT_WITH_HEALTH: &str = "SELECT n.id, n.name, n.status, n.agent_version, n.tailscale_ip, n.last_seen, n.created_at,
h.node_id AS health_node, h.cpu_pct, h.mem_total, h.mem_used, h.mem_pressure, h.swap_used,
h.disk_total, h.disk_free, h.load1, h.load5, h.load15, h.container_count
FROM nodes n LEFT JOIN node_health h ON h.node_id = n.id";
/// List a workspace's nodes (oldest first) with their latest health.
pub async fn list(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<NodeRow>, DbError> {
let rows = sqlx::query(&format!(
"{SELECT_WITH_HEALTH} WHERE n.workspace_id = $1 ORDER BY n.created_at"
))
.bind(workspace_id.as_uuid())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(map_node).collect())
}
/// Fetch one node (workspace-scoped) with its latest health.
pub async fn get(
pool: &PgPool,
id: NodeId,
workspace_id: WorkspaceId,
) -> Result<Option<NodeRow>, DbError> {
let row = sqlx::query(&format!(
"{SELECT_WITH_HEALTH} WHERE n.id = $1 AND n.workspace_id = $2"
))
.bind(id.as_uuid())
.bind(workspace_id.as_uuid())
.fetch_optional(pool)
.await?;
Ok(row.map(map_node))
}
/// Record a heartbeat: mark the node online + refresh its version/tailscale IP,
/// and upsert its latest host-health snapshot.
pub async fn heartbeat(
pool: &PgPool,
id: NodeId,
agent_version: Option<&str>,
tailscale_ip: Option<&str>,
h: &NodeHealth,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE nodes SET status = 'online', last_seen = now(),
agent_version = COALESCE($2, agent_version),
tailscale_ip = COALESCE($3, tailscale_ip)
WHERE id = $1",
)
.bind(id.as_uuid())
.bind(agent_version)
.bind(tailscale_ip)
.execute(pool)
.await?;
sqlx::query(
"INSERT INTO node_health
(node_id, captured_at, cpu_pct, mem_total, mem_used, mem_pressure, swap_used,
disk_total, disk_free, load1, load5, load15, container_count)
VALUES ($1, now(), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (node_id) DO UPDATE SET
captured_at = now(), cpu_pct = excluded.cpu_pct, mem_total = excluded.mem_total,
mem_used = excluded.mem_used, mem_pressure = excluded.mem_pressure,
swap_used = excluded.swap_used, disk_total = excluded.disk_total,
disk_free = excluded.disk_free, load1 = excluded.load1, load5 = excluded.load5,
load15 = excluded.load15, container_count = excluded.container_count",
)
.bind(id.as_uuid())
.bind(h.cpu_pct)
.bind(h.mem_total)
.bind(h.mem_used)
.bind(h.mem_pressure)
.bind(h.swap_used)
.bind(h.disk_total)
.bind(h.disk_free)
.bind(h.load1)
.bind(h.load5)
.bind(h.load15)
.bind(h.container_count)
.execute(pool)
.await?;
Ok(())
}
/// Set a node's status (e.g. 'offline' when its channel drops, 'draining' on
/// deregister).
pub async fn set_status(pool: &PgPool, id: NodeId, status: &str) -> Result<(), DbError> {
sqlx::query("UPDATE nodes SET status = $2 WHERE id = $1")
.bind(id.as_uuid())
.bind(status)
.execute(pool)
.await?;
Ok(())
}
/// Mark online nodes whose last heartbeat is older than `secs` as offline.
pub async fn mark_stale_offline(pool: &PgPool, secs: i64) -> Result<(), DbError> {
sqlx::query(
"UPDATE nodes SET status = 'offline'
WHERE status = 'online'
AND (last_seen IS NULL OR last_seen < now() - ($1 * interval '1 second'))",
)
.bind(secs)
.execute(pool)
.await?;
Ok(())
}
/// Remove a node from the registry (workspace-scoped).
pub async fn delete(pool: &PgPool, id: NodeId, workspace_id: WorkspaceId) -> Result<(), DbError> {
sqlx::query("DELETE FROM nodes WHERE id = $1 AND workspace_id = $2")
.bind(id.as_uuid())
.bind(workspace_id.as_uuid())
.execute(pool)
.await?;
Ok(())
}
fn map_node(r: sqlx::postgres::PgRow) -> NodeRow {
let health = r
.get::<Option<uuid::Uuid>, _>("health_node")
.map(|_| NodeHealth {
cpu_pct: r.get("cpu_pct"),
mem_total: r.get("mem_total"),
mem_used: r.get("mem_used"),
mem_pressure: r.get("mem_pressure"),
swap_used: r.get("swap_used"),
disk_total: r.get("disk_total"),
disk_free: r.get("disk_free"),
load1: r.get("load1"),
load5: r.get("load5"),
load15: r.get("load15"),
container_count: r.get("container_count"),
});
NodeRow {
id: NodeId::from(r.get::<uuid::Uuid, _>("id")),
name: r.get("name"),
status: r.get("status"),
agent_version: r.get("agent_version"),
tailscale_ip: r.get("tailscale_ip"),
last_seen: r.get("last_seen"),
created_at: r.get("created_at"),
health,
}
}
+4
View File
@@ -69,3 +69,7 @@ define_id!(
/// A single message within a session. /// A single message within a session.
MessageId MessageId
); );
define_id!(
/// A connected fleet node (a user's local-hardware host running the daemon).
NodeId
);
+1 -1
View File
@@ -18,6 +18,6 @@ pub use chat::{
}; };
pub use entities::{Agent, AgentStatus, FileDrive, FileNode, User, Workspace}; pub use entities::{Agent, AgentStatus, FileDrive, FileNode, User, Workspace};
pub use gated::GatedCategory; pub use gated::GatedCategory;
pub use ids::{AgentId, MessageId, SessionId, UserId, WorkspaceId}; pub use ids::{AgentId, MessageId, NodeId, SessionId, UserId, WorkspaceId};
pub use role::Role; pub use role::Role;
pub use session_key::{SessionKey, SessionKeyError}; pub use session_key::{SessionKey, SessionKeyError};
@@ -0,0 +1,180 @@
"use client";
// The "Connect a host" wizard: (1) install the daemon with the pairing token,
// (2) verify the daemon dialed back and is reporting health, (3) verify we can
// run a command on it. Mirrors the agent-deploy wizard's stepped flow.
import { useCallback, useEffect, useState } from "react";
import { Check, Copy, Loader2, Terminal, X } from "lucide-react";
import type { FleetNode } from "./fleet/FleetPanels";
const mono = "'Geist Mono', ui-monospace, monospace";
type Step = "install" | "verify" | "exec";
function CopyBox({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const copy = useCallback(() => {
navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}, [text]);
return (
<div style={{ position: "relative", borderRadius: 10, background: "#08080a", border: "1px solid rgba(255,255,255,.1)", padding: "12px 44px 12px 13px" }}>
<code style={{ fontFamily: mono, fontSize: 12, color: "#bfe9d4", whiteSpace: "pre-wrap", wordBreak: "break-all", lineHeight: 1.5 }}>{text}</code>
<button type="button" onClick={copy} title="Copy" aria-label="Copy" style={{ position: "absolute", top: 8, right: 8, width: 28, height: 28, borderRadius: 7, border: "1px solid rgba(255,255,255,.12)", background: "rgba(8,8,10,.7)", color: copied ? "#5fd08a" : "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>{copied ? <Check size={14} /> : <Copy size={14} />}</button>
</div>
);
}
export function ConnectHostWizard({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState<Step>("install");
const [nodeId, setNodeId] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [pairError, setPairError] = useState<string | null>(null);
const [node, setNode] = useState<FleetNode | null>(null);
const [exec, setExec] = useState<{ ok: boolean; output: string } | null>(null);
const [execing, setExecing] = useState(false);
const origin = typeof window !== "undefined" ? window.location.origin : "";
// Mint a node + pairing token when the wizard opens.
useEffect(() => {
let cancelled = false;
fetch("/api/nodes/pair", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "" }) })
.then(async (r) => {
if (!r.ok) throw new Error(`pair failed (${r.status})`);
return r.json();
})
.then((d: { id: string; token: string }) => {
if (!cancelled) {
setNodeId(d.id);
setToken(d.token);
}
})
.catch((e: Error) => !cancelled && setPairError(e.message));
return () => {
cancelled = true;
};
}, []);
// While verifying, poll until the node reports online.
useEffect(() => {
if (step !== "verify" || !nodeId) return;
let stop = false;
const tick = () => {
fetch("/api/nodes")
.then((r) => r.json())
.then((d: { nodes: FleetNode[] }) => {
if (stop) return;
const n = d.nodes.find((x) => x.id === nodeId) ?? null;
setNode(n);
})
.catch(() => {});
};
tick();
const t = setInterval(tick, 2000);
return () => {
stop = true;
clearInterval(t);
};
}, [step, nodeId]);
const runExec = useCallback(() => {
if (!nodeId) return;
setExecing(true);
setExec(null);
fetch(`/api/nodes/${nodeId}/exec-test`, { method: "POST" })
.then((r) => r.json())
.then((d: { ok: boolean; output: string }) => setExec(d))
.catch((e: Error) => setExec({ ok: false, output: e.message }))
.finally(() => setExecing(false));
}, [nodeId]);
const online = node?.status === "online";
const installCmd = `curl -fsSL ${origin}/install.sh | bash -s -- --server ${origin} --token ${token ?? "…"}`;
const directCmd = `clawmates-node --server ${origin} --token ${token ?? "…"}`;
const STEPS: { key: Step; label: string }[] = [
{ key: "install", label: "Install" },
{ key: "verify", label: "Connect" },
{ key: "exec", label: "Verify" },
];
const idx = STEPS.findIndex((s) => s.key === step);
return (
<div style={{ position: "fixed", inset: 0, zIndex: 300, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 560, maxWidth: "100%", maxHeight: "90vh", overflow: "auto", borderRadius: 18, background: "#141417", border: "1px solid rgba(255,255,255,.1)", boxShadow: "0 24px 70px rgba(0,0,0,.6)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "18px 20px 14px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<span style={{ width: 34, height: 34, borderRadius: 9, background: "rgba(94,200,216,.12)", border: "1px solid rgba(94,200,216,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5ec8d8" }}><Terminal size={17} /></span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>Connect a host</div>
<div style={{ display: "flex", gap: 6, marginTop: 5 }}>
{STEPS.map((s, i) => (
<span key={s.key} style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".06em", padding: "2px 8px", borderRadius: 5, color: i <= idx ? "#5ec8d8" : "#5a5a62", background: i <= idx ? "rgba(94,200,216,.1)" : "transparent", border: `1px solid ${i <= idx ? "rgba(94,200,216,.3)" : "rgba(255,255,255,.08)"}` }}>{i + 1}. {s.label}</span>
))}
</div>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#9a9aa2", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X size={16} /></button>
</div>
<div style={{ padding: 20 }}>
{pairError ? <div style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a", marginBottom: 14 }}>Couldn’t start pairing: {pairError}</div> : null}
{step === "install" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<p style={{ fontSize: 13.5, color: "#9a9aa2", lineHeight: 1.55, margin: 0 }}>On the machine you want to add to your fleet, run this. It installs the lightweight <strong style={{ color: "#cfcfd5" }}>clawmates-node</strong> daemon, which dials home and starts reporting health. Docker is required to run agents on it.</p>
<CopyBox text={installCmd} />
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72" }}>Already built from source? Run instead:</div>
<CopyBox text={directCmd} />
</div>
) : step === "verify" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 14, alignItems: "center", padding: "10px 0" }}>
{online ? (
<>
<span style={{ width: 52, height: 52, borderRadius: 14, background: "rgba(95,208,138,.12)", border: "1px solid rgba(95,208,138,.35)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5fd08a" }}><Check size={26} /></span>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>Connected!</div>
{node?.health ? (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#9a9aa2", textAlign: "center", lineHeight: 1.7 }}>
{(node.health.memTotal / 1e9).toFixed(1)} GB RAM · {(node.health.diskTotal / 1e9).toFixed(0)} GB disk{node.agentVersion ? ` · v${node.agentVersion}` : ""}
</div>
) : null}
</>
) : (
<>
<Loader2 size={32} color="#5ec8d8" className="animate-spin" />
<div style={{ fontSize: 14.5, fontWeight: 600, color: "#cfcfd5" }}>Waiting for the daemon to connect…</div>
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#6a6a72", textAlign: "center" }}>Run the install command on your host. This updates automatically.</div>
</>
)}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<p style={{ fontSize: 13.5, color: "#9a9aa2", lineHeight: 1.55, margin: 0 }}>Finally, confirm we can run commands on this node on your behalf.</p>
<button type="button" onClick={runExec} disabled={execing} style={{ alignSelf: "flex-start", display: "inline-flex", alignItems: "center", gap: 7, padding: "9px 15px", borderRadius: 10, border: "1px solid rgba(94,200,216,.4)", background: "rgba(94,200,216,.1)", color: "#5ec8d8", fontSize: 13, fontWeight: 600, cursor: execing ? "default" : "pointer" }}>{execing ? <Loader2 size={15} className="animate-spin" /> : <Terminal size={15} />} Run a test command</button>
{exec ? (
<div style={{ borderRadius: 10, background: "#08080a", border: `1px solid ${exec.ok ? "rgba(95,208,138,.3)" : "rgba(255,111,97,.3)"}`, padding: 13 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".08em", color: exec.ok ? "#5fd08a" : "#ff8a7a", marginBottom: 8 }}>{exec.ok ? "✓ EXECUTED" : "✗ FAILED"}</div>
<pre style={{ fontFamily: mono, fontSize: 11.5, color: "#bfe9d4", whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0, maxHeight: 180, overflow: "auto" }}>{exec.output}</pre>
</div>
) : null}
</div>
)}
</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, padding: "14px 20px", borderTop: "1px solid rgba(255,255,255,.07)" }}>
<button type="button" onClick={onClose} style={{ padding: "9px 16px", borderRadius: 9, border: "1px solid rgba(255,255,255,.14)", background: "transparent", color: "#9a9aa2", fontSize: 13, fontWeight: 600, cursor: "pointer" }}>{step === "exec" ? "Done" : "Cancel"}</button>
{step === "install" ? (
<button type="button" disabled={!token} onClick={() => setStep("verify")} style={{ padding: "9px 18px", borderRadius: 9, border: 0, background: token ? "#5ec8d8" : "rgba(94,200,216,.3)", color: "#04222a", fontSize: 13, fontWeight: 700, cursor: token ? "pointer" : "default" }}>I’ve run it →</button>
) : step === "verify" ? (
<button type="button" disabled={!online} onClick={() => setStep("exec")} style={{ padding: "9px 18px", borderRadius: 9, border: 0, background: online ? "#5ec8d8" : "rgba(94,200,216,.3)", color: "#04222a", fontSize: 13, fontWeight: 700, cursor: online ? "pointer" : "default" }}>Next →</button>
) : (
<button type="button" onClick={onClose} style={{ padding: "9px 18px", borderRadius: 9, border: 0, background: "#5fd08a", color: "#04220f", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>Finish</button>
)}
</div>
</div>
</div>
);
}
@@ -192,7 +192,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const [worldPanelOpen, setWorldPanelOpen] = useState(false); const [worldPanelOpen, setWorldPanelOpen] = useState(false);
const [worldSize, setWorldSize] = useState<DeviceSize>("phone"); const [worldSize, setWorldSize] = useState<DeviceSize>("phone");
// Infrastructure tier: which category is selected in the left list. // Infrastructure tier: which category is selected in the left list.
const [infraSel, setInfraSel] = useState<string | null>("local"); const [infraSel, setInfraSel] = useState<string | null>("fleet");
const org: DemoOrg = findOrg(orgId) ?? fallbackOrg; const org: DemoOrg = findOrg(orgId) ?? fallbackOrg;
const company: DemoCompany = findCompany(companyId) ?? org.companies[0] ?? EMPTY_ORG.companies[0]; const company: DemoCompany = findCompany(companyId) ?? org.companies[0] ?? EMPTY_ORG.companies[0];
@@ -4,7 +4,9 @@
// the infra categories, and a console placeholder (the bottom-split slot mirrors // the infra categories, and a console placeholder (the bottom-split slot mirrors
// the agent chat). Placeholders for now; whittled into real fleet/host UI later. // the agent chat). Placeholders for now; whittled into real fleet/host UI later.
import { Box, ChevronDown, Cloud, HardDrive, Server, Terminal, type LucideIcon } from "lucide-react"; import { Box, ChevronDown, Cloud, HardDrive, Network, Server, Terminal, type LucideIcon } from "lucide-react";
import { FleetOverview, LocalHardware } from "./fleet/FleetPanels";
const mono = "'Geist Mono', ui-monospace, monospace"; const mono = "'Geist Mono', ui-monospace, monospace";
@@ -16,6 +18,7 @@ export interface InfraCat {
} }
export const INFRA_CATS: InfraCat[] = [ export const INFRA_CATS: InfraCat[] = [
{ id: "fleet", icon: Network, label: "Fleet", desc: "An overview of every machine connected to your fleet." },
{ id: "local", icon: HardDrive, label: "Local hardware", desc: "Run agents on your own machines — Macs, Linux boxes, edge devices." }, { id: "local", icon: HardDrive, label: "Local hardware", desc: "Run agents on your own machines — Macs, Linux boxes, edge devices." },
{ id: "containers", icon: Box, label: "Containers", desc: "Deploy agent runtimes as Docker / OCI containers." }, { id: "containers", icon: Box, label: "Containers", desc: "Deploy agent runtimes as Docker / OCI containers." },
{ id: "vms", icon: Server, label: "Virtual machines", desc: "Provision agents on VMs across your fleet." }, { id: "vms", icon: Server, label: "Virtual machines", desc: "Provision agents on VMs across your fleet." },
@@ -57,8 +60,10 @@ export function InfraSidebar({ selected, onSelect }: { selected: string | null;
); );
} }
/** Center overview — the infra category cards, highlighting the selected one. */ /** Center content for the selected infra category. */
export function InfraStage({ selected }: { selected: string | null }) { export function InfraStage({ selected }: { selected: string | null }) {
if (selected === "fleet") return <FleetOverview />;
if (selected === "local") return <LocalHardware />;
return ( return (
<div style={{ height: "100%", overflow: "auto", padding: "28px 32px" }}> <div style={{ height: "100%", overflow: "auto", padding: "28px 32px" }}>
<div style={{ maxWidth: 920, margin: "0 auto" }}> <div style={{ maxWidth: 920, margin: "0 auto" }}>
@@ -0,0 +1,203 @@
"use client";
// Fleet UI: live per-node health cards (Local hardware) + a fleet overview. Data
// comes from the nodes registry (`GET /api/nodes`), polled every 3s.
import { useCallback, useEffect, useState } from "react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, Trash2 } from "lucide-react";
import { useFetchJson } from "@/lib/api/use-fetch";
import { ConnectHostWizard } from "../ConnectHostWizard";
const mono = "'Geist Mono', ui-monospace, monospace";
export interface NodeHealth {
cpuPct: number;
memTotal: number;
memUsed: number;
memPressure: number;
swapUsed: number;
diskTotal: number;
diskFree: number;
load1: number;
load5: number;
load15: number;
containerCount: number;
}
export interface FleetNode {
id: string;
name: string;
status: "pending" | "online" | "offline" | "draining";
agentVersion: string | null;
tailscaleIp: string | null;
lastSeen: number | null;
createdAt: number;
health: NodeHealth | null;
}
const STATUS_COLOR: Record<FleetNode["status"], string> = {
online: "#5fd08a",
offline: "#6a6a72",
pending: "#e8b465",
draining: "#ff8a7a",
};
function fmtBytes(b: number): string {
if (b >= 1e12) return `${(b / 1e12).toFixed(1)} TB`;
if (b >= 1e9) return `${(b / 1e9).toFixed(1)} GB`;
if (b >= 1e6) return `${(b / 1e6).toFixed(0)} MB`;
return `${b} B`;
}
/** Poll the workspace's nodes every 3s. */
export function useNodes(): { nodes: FleetNode[]; refresh: () => void } {
const { data, refresh } = useFetchJson<{ nodes: FleetNode[] }>("/api/nodes");
useEffect(() => {
const t = setInterval(refresh, 3000);
return () => clearInterval(t);
}, [refresh]);
return { nodes: data?.nodes ?? [], refresh };
}
function Bar({ label, pct, detail, color }: { label: string; pct: number; detail: string; color: string }) {
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#9a9aa2", marginBottom: 4 }}>
<span>{label}</span>
<span style={{ fontFamily: mono, color: "#cfcfd5" }}>{detail}</span>
</div>
<div style={{ height: 6, borderRadius: 3, background: "rgba(255,255,255,.07)", overflow: "hidden" }}>
<div style={{ height: "100%", width: `${Math.max(0, Math.min(100, pct))}%`, background: color, borderRadius: 3, transition: "width .4s ease" }} />
</div>
</div>
);
}
export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
const h = node.health;
const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0;
const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0;
const remove = useCallback(() => {
if (!confirm(`Remove "${node.name}" from your fleet?`)) return;
fetch(`/api/nodes/${node.id}`, { method: "DELETE" }).then(onRemoved);
}, [node.id, node.name, onRemoved]);
return (
<div style={{ borderRadius: 14, background: "#0f0f13", border: "1px solid rgba(255,255,255,.08)", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 36, height: 36, borderRadius: 9, background: "rgba(94,200,216,.1)", border: "1px solid rgba(94,200,216,.25)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5ec8d8" }}><Server size={18} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#f3f3f5", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{node.name}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 10, color: "#7a7a82", marginTop: 2 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: STATUS_COLOR[node.status] }} />
{node.status.toUpperCase()}
{node.agentVersion ? <span>· v{node.agentVersion}</span> : null}
</div>
</div>
<button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button>
</div>
{h ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Bar label="CPU" pct={h.cpuPct} detail={`${h.cpuPct.toFixed(0)}%`} color="#7fc8ff" />
<Bar label="Memory" pct={memPct} detail={`${fmtBytes(h.memUsed)} / ${fmtBytes(h.memTotal)}`} color={h.memPressure > 0.85 ? "#ff6f61" : "#c98af0"} />
<Bar label="Disk" pct={diskUsedPct} detail={`${fmtBytes(h.diskFree)} free`} color="#5fd08a" />
<div style={{ display: "flex", gap: 14, fontSize: 11, color: "#9a9aa2", fontFamily: mono, paddingTop: 2 }}>
<span>load {h.load1.toFixed(2)}</span>
<span>· {h.containerCount} containers</span>
{node.tailscaleIp ? <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}><Network size={11} /> {node.tailscaleIp}</span> : null}
</div>
</div>
) : (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#6a6a72", padding: "8px 0" }}>
{node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"}
</div>
)}
</div>
);
}
export function LocalHardware() {
const { nodes, refresh } = useNodes();
const [wizard, setWizard] = useState(false);
return (
<div style={{ height: "100%", overflow: "auto", padding: "28px 32px" }}>
<div style={{ maxWidth: 980, margin: "0 auto" }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 12, marginBottom: 20 }}>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>LOCAL HARDWARE · {nodes.length} NODE{nodes.length === 1 ? "" : "S"}</div>
<div style={{ fontSize: 24, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>Your machines</div>
</div>
<button type="button" onClick={() => setWizard(true)} style={{ display: "inline-flex", alignItems: "center", gap: 7, padding: "10px 16px", borderRadius: 10, border: "1px solid rgba(94,200,216,.4)", background: "rgba(94,200,216,.1)", color: "#5ec8d8", fontSize: 13, fontWeight: 600, cursor: "pointer" }}><Plus size={16} /> Connect a host</button>
</div>
{nodes.length === 0 ? (
<div style={{ borderRadius: 16, border: "1px dashed rgba(255,255,255,.12)", padding: "44px 24px", textAlign: "center" }}>
<span style={{ display: "inline-flex", width: 48, height: 48, borderRadius: 12, background: "rgba(94,200,216,.1)", border: "1px solid rgba(94,200,216,.25)", alignItems: "center", justifyContent: "center", color: "#5ec8d8", marginBottom: 14 }}><HardDrive size={24} /></span>
<div style={{ fontSize: 16, fontWeight: 700, color: "#f3f3f5", marginBottom: 6 }}>No nodes connected yet</div>
<p style={{ fontSize: 13, color: "#8a8a92", maxWidth: 420, margin: "0 auto 18px", lineHeight: 1.55 }}>Run agents on your own machines — install the lightweight daemon and it reports back here with live health.</p>
<button type="button" onClick={() => setWizard(true)} style={{ display: "inline-flex", alignItems: "center", gap: 7, padding: "11px 18px", borderRadius: 11, border: 0, background: "linear-gradient(135deg,#5ec8d8,#3aa6b8)", color: "#04222a", fontSize: 13.5, fontWeight: 700, cursor: "pointer" }}><Plus size={16} /> Connect your first host</button>
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(290px, 1fr))", gap: 16 }}>
{nodes.map((n) => (
<NodeCard key={n.id} node={n} onRemoved={refresh} />
))}
</div>
)}
</div>
{wizard ? <ConnectHostWizard onClose={() => { setWizard(false); refresh(); }} /> : null}
</div>
);
}
export function FleetOverview() {
const { nodes } = useNodes();
const online = nodes.filter((n) => n.status === "online");
const cores = online.reduce((a, n) => a + (n.health?.containerCount ?? 0), 0);
const totalMem = online.reduce((a, n) => a + (n.health?.memTotal ?? 0), 0);
const totalDisk = online.reduce((a, n) => a + (n.health?.diskTotal ?? 0), 0);
const stats = [
{ label: "Nodes", value: String(nodes.length), sub: `${online.length} online`, icon: Server, tint: "#5ec8d8" },
{ label: "Containers", value: String(cores), sub: "running", icon: Cpu, tint: "#7fc8ff" },
{ label: "Memory", value: fmtBytes(totalMem), sub: "fleet total", icon: MemoryStick, tint: "#c98af0" },
{ label: "Storage", value: fmtBytes(totalDisk), sub: "fleet total", icon: HardDrive, tint: "#5fd08a" },
];
return (
<div style={{ height: "100%", overflow: "auto", padding: "28px 32px" }}>
<div style={{ maxWidth: 980, margin: "0 auto" }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>FLEET</div>
<div style={{ fontSize: 24, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>Overview</div>
<p style={{ fontSize: 13.5, color: "#8a8a92", marginTop: 8, marginBottom: 22, lineHeight: 1.5 }}>The machines connected to your fleet across all of your infrastructure. Connect Tailscale to see your network here too.</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 14, marginBottom: 26 }}>
{stats.map((s) => (
<div key={s.label} style={{ borderRadius: 14, background: "#0f0f13", border: "1px solid rgba(255,255,255,.08)", padding: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
<span style={{ width: 30, height: 30, borderRadius: 8, background: `${s.tint}1a`, border: `1px solid ${s.tint}40`, display: "flex", alignItems: "center", justifyContent: "center", color: s.tint }}><s.icon size={15} /></span>
<span style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".1em", color: "#7a7a82" }}>{s.label.toUpperCase()}</span>
</div>
<div style={{ fontSize: 26, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>{s.value}</div>
<div style={{ fontSize: 11.5, color: "#7a7a82", marginTop: 2 }}>{s.sub}</div>
</div>
))}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
{nodes.map((n) => (
<div key={n.id} style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "9px 13px", borderRadius: 999, background: "#101014", border: "1px solid rgba(255,255,255,.08)" }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: STATUS_COLOR[n.status] }} />
<span style={{ fontSize: 13, color: "#cfcfd5", fontWeight: 600 }}>{n.name}</span>
{n.health ? <span style={{ fontFamily: mono, fontSize: 10.5, color: "#7a7a82" }}>{n.health.cpuPct.toFixed(0)}% cpu</span> : null}
</div>
))}
{nodes.length === 0 ? <span style={{ fontFamily: mono, fontSize: 12, color: "#6a6a72" }}>No nodes yet — add one under Local hardware.</span> : null}
</div>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
-- Fleet: user-connected local-hardware nodes. Each node runs the clawmates-node
-- daemon, which dials home over an outbound control channel to report host health
-- and accept workload-placement commands. (Phase 2 of the multi-node node-pool;
-- agent_containers.node_id references the chosen node, 'local' by default.)
CREATE TABLE nodes (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | online | offline | draining
token TEXT NOT NULL UNIQUE, -- shared secret the daemon presents on the channel
agent_version TEXT,
tailscale_ip TEXT,
last_seen TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX nodes_workspace_idx ON nodes (workspace_id);
-- Latest host-health snapshot per node (upserted by the daemon heartbeat).
CREATE TABLE node_health (
node_id UUID PRIMARY KEY REFERENCES nodes (id) ON DELETE CASCADE,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cpu_pct DOUBLE PRECISION NOT NULL DEFAULT 0, -- 0..100
mem_total BIGINT NOT NULL DEFAULT 0, -- bytes
mem_used BIGINT NOT NULL DEFAULT 0,
mem_pressure DOUBLE PRECISION NOT NULL DEFAULT 0, -- 0..1 (used/total, or PSI if available)
swap_used BIGINT NOT NULL DEFAULT 0,
disk_total BIGINT NOT NULL DEFAULT 0,
disk_free BIGINT NOT NULL DEFAULT 0,
load1 DOUBLE PRECISION NOT NULL DEFAULT 0,
load5 DOUBLE PRECISION NOT NULL DEFAULT 0,
load15 DOUBLE PRECISION NOT NULL DEFAULT 0,
container_count INTEGER NOT NULL DEFAULT 0
);
-- Placement lookups (which containers run on which node, per workspace).
CREATE INDEX agent_containers_node_idx ON agent_containers (node_id, workspace_id);