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
@@ -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>
);
}