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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user