herdr phase 3: INFRA tier Herdr sessions surface
New INFRA category "Herdr sessions" (purple sparkles icon between
Fleet and Local hardware). Shows a card per online fleet node with:
- Node name + hostname + IP
- Per-workspace agent state pills (working / blocked / done /
idle / unknown), colored dots + pane count
- "Open" button → renders that node's full Herdr TUI inline via
xterm.js (same nodeHerdrConnector + WebRTC-with-fallback the
MissionCanvas Live Pane uses)
Backend:
- node daemon: herdr_workspaces + herdr_snapshot ops
(`herdr workspace list`, `herdr api snapshot`)
- fleet_herdr::snapshot helper on top of hub.call_timeout
- GET /api/nodes/{id}/herdr/session route
Fetch flow: /api/nodes filtered to status='online' → for each,
/api/nodes/{id}/herdr/session in parallel. Snapshot errors surface
per-card without failing the whole grid.
The "Open" xterm is separate from the MissionCanvas Live Pane —
this one is scoped to the whole node's Herdr TUI (any workspace),
not a specific mission's pane. Operator toggles between nodes via
the buttons.
Verified: cargo check --workspace + tsc --noEmit both green.
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
// INFRA → Herdr sessions: browse the Herdr session snapshot from every
|
||||
// online node. One card per node with per-workspace agent-state pills
|
||||
// + a "Open in browser" button that spawns an xterm attached to that
|
||||
// node's Herdr TUI (same LivePane mechanism the MissionCanvas uses).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw, Sparkles, TerminalSquare } from "lucide-react";
|
||||
|
||||
import type { FleetNode } from "./fleet/FleetPanels";
|
||||
import {
|
||||
nodeHerdrConnector,
|
||||
useResilientTerminal,
|
||||
type TermMode,
|
||||
} from "@/components/computer/apps/terminal/core";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
type AgentStatus = "working" | "blocked" | "done" | "idle" | "unknown";
|
||||
|
||||
interface Workspace {
|
||||
workspace_id: string;
|
||||
label?: string;
|
||||
number?: number;
|
||||
agent_status?: AgentStatus;
|
||||
pane_count?: number;
|
||||
tab_count?: number;
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
workspaces?: Workspace[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<AgentStatus, string> = {
|
||||
working: "#5ec8d8",
|
||||
blocked: "#e8b465",
|
||||
done: "#5fd08a",
|
||||
idle: "#8a8a92",
|
||||
unknown: "#6a6a72",
|
||||
};
|
||||
|
||||
export function HerdrSessions() {
|
||||
const [nodes, setNodes] = useState<FleetNode[]>([]);
|
||||
const [snapshots, setSnapshots] = useState<Record<string, Snapshot | "loading" | "error">>({});
|
||||
const [openNode, setOpenNode] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/nodes");
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { nodes?: FleetNode[] };
|
||||
if (alive) setNodes((data.nodes ?? []).filter((n) => n.status === "online"));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [tick]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const results = await Promise.all(
|
||||
nodes.map(async (n) => {
|
||||
try {
|
||||
const r = await fetch(`/api/nodes/${n.id}/herdr/session`);
|
||||
if (!r.ok) return [n.id, "error" as const] as const;
|
||||
const raw = (await r.json()) as {
|
||||
error?: string;
|
||||
result?: { workspaces?: Workspace[] };
|
||||
workspaces?: Workspace[];
|
||||
};
|
||||
if (raw.error) return [n.id, { error: raw.error }] as const;
|
||||
const workspaces = raw.result?.workspaces ?? raw.workspaces ?? [];
|
||||
return [n.id, { workspaces }] as const;
|
||||
} catch {
|
||||
return [n.id, "error" as const] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!alive) return;
|
||||
const next: Record<string, Snapshot | "loading" | "error"> = {};
|
||||
for (const [id, s] of results) next[id] = s;
|
||||
setSnapshots(next);
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [nodes, tick]);
|
||||
|
||||
const refresh = useCallback(() => setTick((n) => n + 1), []);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 32, color: "#8a8a92" }}>
|
||||
No online nodes. Connect a host from the Local hardware panel first.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", overflow: "auto", padding: "24px 28px" }}>
|
||||
<div style={{ maxWidth: 1000, margin: "0 auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
|
||||
<Sparkles size={16} style={{ color: "#c9a0ff" }} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
letterSpacing: ".14em",
|
||||
color: "#c9a0ff",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Herdr sessions · {nodes.length} online node{nodes.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,255,255,.12)",
|
||||
background: "transparent",
|
||||
color: "#9a9aa2",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: "#8a8a92", margin: "6px 0 22px", lineHeight: 1.5 }}>
|
||||
Every online fleet node is running a persistent Herdr daemon. This view
|
||||
shows their live workspaces + agent states. Click Open to render the
|
||||
node's Herdr TUI in your browser (WebRTC direct where possible).
|
||||
</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 14 }}>
|
||||
{nodes.map((n) => {
|
||||
const snap = snapshots[n.id];
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "#0f0f13",
|
||||
border: "1px solid rgba(255,255,255,.08)",
|
||||
padding: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
minHeight: 160,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "#f3f3f5",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{n.hostname ?? n.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenNode(openNode === n.id ? null : n.id)}
|
||||
title="Open Herdr TUI in browser"
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid rgba(201,160,255,.4)",
|
||||
background: openNode === n.id ? "rgba(201,160,255,.15)" : "transparent",
|
||||
color: "#c9a0ff",
|
||||
fontSize: 11,
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<TerminalSquare size={11} />
|
||||
{openNode === n.id ? "Close" : "Open"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72" }}>
|
||||
{n.localIp ?? n.tailscaleIp ?? ""}
|
||||
</div>
|
||||
<SessionSummary snap={snap} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{openNode && (
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
letterSpacing: ".12em",
|
||||
color: "#c9a0ff",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{nodes.find((n) => n.id === openNode)?.hostname ??
|
||||
nodes.find((n) => n.id === openNode)?.name ??
|
||||
openNode}{" "}
|
||||
· Herdr TUI
|
||||
</div>
|
||||
<HerdrTerminal nodeId={openNode} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionSummary({ snap }: { snap: Snapshot | "loading" | "error" | undefined }) {
|
||||
if (snap === undefined) return <div style={hint}>Loading…</div>;
|
||||
if (snap === "loading") return <div style={hint}>Loading…</div>;
|
||||
if (snap === "error") return <div style={{ ...hint, color: "#ff8a7a" }}>Snapshot failed</div>;
|
||||
if (typeof snap === "object" && snap.error)
|
||||
return <div style={{ ...hint, color: "#ff8a7a" }}>{snap.error}</div>;
|
||||
const workspaces = (snap as Snapshot).workspaces ?? [];
|
||||
if (workspaces.length === 0)
|
||||
return <div style={hint}>No active workspaces on this node.</div>;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5, marginTop: 4 }}>
|
||||
{workspaces.slice(0, 8).map((w) => {
|
||||
const status = (w.agent_status ?? "unknown") as AgentStatus;
|
||||
return (
|
||||
<div
|
||||
key={w.workspace_id}
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: STATUS_COLOR[status] ?? "#6a6a72",
|
||||
flex: "none",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "#cfcfd5", flex: 1, minWidth: 0 }}>
|
||||
{w.label ?? `w${w.number}`}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
color: STATUS_COLOR[status] ?? "#6a6a72",
|
||||
letterSpacing: ".08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<span style={{ color: "#6a6a72", fontFamily: mono, fontSize: 10 }}>
|
||||
{w.pane_count ?? 0}p
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{workspaces.length > 8 && (
|
||||
<div style={{ ...hint, marginTop: 2 }}>
|
||||
+{workspaces.length - 8} more workspaces
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HerdrTerminal({ nodeId }: { nodeId: string }) {
|
||||
const [mode, setMode] = useState<TermMode>("connecting");
|
||||
const connector = useMemo(() => nodeHerdrConnector(nodeId, setMode), [nodeId]);
|
||||
const { hostRef } = useResilientTerminal({ connect: connector, autoFocus: true }, [nodeId]);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "65vh",
|
||||
minHeight: 460,
|
||||
background: "#0a0a0d",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 5,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 6,
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
letterSpacing: ".1em",
|
||||
textTransform: "uppercase",
|
||||
color:
|
||||
mode === "direct" ? "#5fd08a" : mode === "relayed" ? "#8a8a92" : "#e8b465",
|
||||
background:
|
||||
mode === "direct" ? "rgba(95,208,138,.12)" : "rgba(255,255,255,.05)",
|
||||
border: `1px solid ${
|
||||
mode === "direct" ? "rgba(95,208,138,.3)" : "rgba(255,255,255,.1)"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{mode === "direct" ? "direct" : mode === "relayed" ? "relayed" : "connecting…"}
|
||||
</div>
|
||||
<div ref={hostRef} style={{ position: "absolute", inset: 0, padding: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hint: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: "#6a6a72",
|
||||
marginTop: 4,
|
||||
};
|
||||
Reference in New Issue
Block a user