CI on 6ffbe97 failed on two auto-fixable gates. Both fixed:
* cargo fmt --all — rustfmt applied across the surface touched
by the last ~20 commits (world.rs, security_scan.rs,
routes/{missions,nodes,terminal}.rs, fleet_herdr.rs,
mission_workspace.rs, benchmark_runner.rs, mission_refiner.rs,
lib.rs, tests/mission_orchestrator.rs, cm-db/repo/{missions,teams}.rs,
bins/clawmates-node/src/main.rs)
* eslint apostrophe escapes in HerdrSessions + MissionWizard
* eslint max-lines: extracted EditMissionModal + RefineDiffModal
(each ~200 LoC) into their own files. MissionCanvas drops from
1424 to 1026, comfortably under both the 1250 eslint cap and the
1500 CI budget.
New files:
frontend/src/components/dashboard/EditMissionModal.tsx (211 LoC)
frontend/src/components/dashboard/RefineDiffModal.tsx (208 LoC)
Verified locally: cargo fmt --check clean, cargo check clean,
mission_orchestrator test 3/3 pass, tsc + eslint --quiet both silent.
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
"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,
|
|
};
|