Fleet P0: node registry + daemon + health + connect-host wizard
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:
co-authored by
Claude Opus 4.8
parent
b853aab6fd
commit
2bdd0a23e8
Generated
+93
-5
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user