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:
Omar Sobh
2026-07-20 11:30:52 -07:00
parent 0d4cb2c2bc
commit bf4af48c80
6 changed files with 390 additions and 2 deletions
+7 -1
View File
@@ -509,7 +509,11 @@ async fn handle_frame(
// polls that pane's agent_status; `herdr_read` scrapes its recent // polls that pane's agent_status; `herdr_read` scrapes its recent
// transcript. Node just shells out to the `herdr` binary — the // transcript. Node just shells out to the `herdr` binary — the
// Herdr background daemon is expected to already be running. // Herdr background daemon is expected to already be running.
op @ ("herdr_dispatch" | "herdr_status" | "herdr_read") => { op @ ("herdr_dispatch"
| "herdr_status"
| "herdr_read"
| "herdr_workspaces"
| "herdr_snapshot") => {
if let Some(id) = v.get("id").and_then(Value::as_u64) { if let Some(id) = v.get("id").and_then(Value::as_u64) {
let (ok, output) = herdr_op(op, &v).await; let (ok, output) = herdr_op(op, &v).await;
let _ = out.send( let _ = out.send(
@@ -1043,6 +1047,8 @@ async fn herdr_op(op: &str, v: &Value) -> (bool, String) {
]) ])
.await .await
} }
"herdr_workspaces" => run(vec!["workspace".into(), "list".into()]).await,
"herdr_snapshot" => run(vec!["api".into(), "snapshot".into()]).await,
_ => (false, format!("unknown herdr op {op}")), _ => (false, format!("unknown herdr op {op}")),
} }
} }
+17
View File
@@ -100,6 +100,23 @@ pub async fn status(
.map_err(|e| format!("status not json: {e}: {}", truncate(&out.output, 200))) .map_err(|e| format!("status not json: {e}: {}", truncate(&out.output, 200)))
} }
/// Fetch the full session snapshot from a node's Herdr daemon
/// (`herdr api snapshot`). Returns raw JSON so the frontend can render
/// workspaces + tabs + panes + agent states without a schema hop.
pub async fn snapshot(
hub: Arc<NodeHub>,
node_id: NodeId,
) -> Result<Value, String> {
let out = hub
.call_timeout(node_id, "herdr_snapshot", json!({}), 15)
.await?;
if !out.ok {
return Err(format!("snapshot failed: {}", truncate(&out.output, 300)));
}
serde_json::from_str(&out.output)
.map_err(|e| format!("snapshot not json: {e}: {}", truncate(&out.output, 200)))
}
/// Pull the last `lines` of the pane's scrollback (unwrapped) — used /// Pull the last `lines` of the pane's scrollback (unwrapped) — used
/// to persist a completed run's transcript. /// to persist a completed run's transcript.
pub async fn read_transcript( pub async fn read_transcript(
+4
View File
@@ -159,6 +159,10 @@ pub fn router(state: AppState) -> Router {
"/api/nodes/{id}/tools/{tool}/update", "/api/nodes/{id}/tools/{tool}/update",
post(routes::nodes::tool_update), post(routes::nodes::tool_update),
) )
.route(
"/api/nodes/{id}/herdr/session",
get(routes::nodes::herdr_session),
)
.route("/api/nodes/{id}", delete(routes::nodes::remove)) .route("/api/nodes/{id}", delete(routes::nodes::remove))
.route( .route(
"/api/fleet/beszel", "/api/fleet/beszel",
+18
View File
@@ -142,6 +142,24 @@ pub async fn sandbox_check(
} }
} }
/// `GET /api/nodes/{id}/herdr/session` — full Herdr session snapshot
/// for a node (workspaces + tabs + panes + agent states). Used by the
/// INFRA Herdr surface to browse per-node Herdr activity.
pub async fn herdr_session(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let node_id = NodeId::from(id);
nodes::get(&state.pool, node_id, user.workspace_id)
.await?
.ok_or(ApiError::NotFound)?;
match crate::fleet_herdr::snapshot(state.node_hub.clone(), node_id).await {
Ok(snap) => Ok(Json(snap)),
Err(e) => Ok(Json(json!({ "error": e }))),
}
}
/// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped). /// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped).
pub async fn remove( pub async fn remove(
State(state): State<AppState>, State(state): State<AppState>,
@@ -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,
};
@@ -4,9 +4,10 @@
// 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, Network, Server, Terminal, type LucideIcon } from "lucide-react"; import { Box, ChevronDown, Cloud, HardDrive, Network, Server, Sparkles, Terminal, type LucideIcon } from "lucide-react";
import { FleetOverview, LocalHardware } from "./fleet/FleetPanels"; import { FleetOverview, LocalHardware } from "./fleet/FleetPanels";
import { HerdrSessions } from "./HerdrSessions";
const mono = "'Geist Mono', ui-monospace, monospace"; const mono = "'Geist Mono', ui-monospace, monospace";
@@ -19,6 +20,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: "fleet", icon: Network, label: "Fleet", desc: "An overview of every machine connected to your fleet." },
{ id: "herdr", icon: Sparkles, label: "Herdr sessions", desc: "Live Herdr workspaces + panes across every online node." },
{ 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." },
@@ -63,6 +65,7 @@ export function InfraSidebar({ selected, onSelect }: { selected: string | null;
/** Center content for the selected infra category. */ /** 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 === "fleet") return <FleetOverview />;
if (selected === "herdr") return <HerdrSessions />;
if (selected === "local") return <LocalHardware />; 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" }}>