/// Gateway management panel shown when huskies runs in `--gateway` mode. /// /// Provides: /// - A cross-project pipeline status view showing active stories per project. /// - Clicking a project card switches to it. /// - An "Add Agent" button that generates a one-time join token. /// - Instructions for running a build agent with the token. /// - A list of connected agents with per-agent status, project assignment, and "Remove" buttons. /// - Auto-refresh every 5 seconds so new agents and disconnections appear without a page reload. import * as React from "react"; import { gatewayApi, type JoinedAgent, type GatewayProject, type AllProjectsPipeline, type Pipeline, type PipelineItem, type Status, } from "../api/gateway"; /// Resolve an item's pipeline column. Servers running the new (story 1085) /// backend send `pipeline`; older servers only send `stage` so we fall back to /// mapping the bucket name onto the new column vocabulary. function itemPipeline(item: PipelineItem): Pipeline { if (item.pipeline) return item.pipeline; switch (item.stage) { case "current": return "coding"; case "qa": return "qa"; case "merge": return "merge"; case "done": return "done"; case "archived": return "archived"; default: return "backlog"; } } /// Resolve an item's badge. Falls back to `merge_failure`/`blocked` on /// legacy servers that don't yet emit `status`. function itemStatus(item: PipelineItem): Status { if (item.status) return item.status; if (item.merge_failure) return "merge-failure"; if (item.blocked) return "blocked"; if (item.stage === "done") return "done"; return "active"; } const { useCallback, useEffect, useRef, useState } = React; /// Seconds of silence before an agent is considered disconnected. const DISCONNECT_THRESHOLD_SECS = 60; /// Poll the agent list this often (milliseconds). const POLL_INTERVAL_MS = 5_000; type AgentStatus = "idle" | "working" | "disconnected"; /// Derive an agent's display status from its last-seen timestamp and project assignment. function agentStatus(agent: JoinedAgent): AgentStatus { const nowSecs = Date.now() / 1000; if (nowSecs - agent.last_seen > DISCONNECT_THRESHOLD_SECS) { return "disconnected"; } return agent.assigned_project ? "working" : "idle"; } const STATUS_COLORS: Record = { idle: "#6e7681", working: "#3fb950", disconnected: "#f85149", }; const STATUS_LABELS: Record = { idle: "Idle", working: "Working", disconnected: "Disconnected", }; const PIPELINE_COLORS: Record = { backlog: "#8b949e", coding: "#3fb950", qa: "#d2a679", merge: "#79c0ff", done: "#6e7681", closed: "#6e7681", archived: "#6e7681", }; const PIPELINE_LABELS: Record = { backlog: "Backlog", coding: "In Progress", qa: "QA", merge: "Merging", done: "Done", closed: "Closed", archived: "Archived", }; /// A single story row inside a project pipeline card. /** Render one story row in a gateway-aggregate panel: `# ` with status badge. */ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) { const pipeline = itemPipeline(item); const status = itemStatus(item); const agentStatus = item.agent?.status; let color: string; let label: string; let frozenPrefix = ""; // Frozen items keep their underlying pipeline column but get a ❄️ badge. // (AC 4 — story 1085, subsumes the freeze-hides-item bug.) if (status === "frozen") { color = "#79c0ff"; label = "❄ FROZEN"; frozenPrefix = "❄ "; } else if (status === "merge-failure" || status === "merge-failure-final") { // Done items never reach this branch — `Stage::status()` returns // `Status::Done` for done items (AC 4). if (agentStatus === "running") { color = "#e3b341"; label = "⟳ RECOVERING"; } else if (agentStatus === "pending") { color = "#e3b341"; label = "⏳ QUEUED"; } else { color = "#f85149"; label = status === "merge-failure-final" ? "⛔ FAILED (FINAL)" : "✕ FAILED"; } } else if (status === "blocked") { if (agentStatus === "running") { color = "#e3b341"; label = "⟳ RECOVERING"; } else if (agentStatus === "pending") { color = "#e3b341"; label = "⏳ QUEUED"; } else { color = "#f85149"; label = "⊘ BLOCKED"; } } else if (status === "review-hold") { color = "#d2a679"; label = "REVIEW HOLD"; } else if (status === "abandoned") { color = "#6e7681"; label = "ABANDONED"; } else if (status === "superseded") { color = "#6e7681"; label = "SUPERSEDED"; } else if (status === "rejected") { color = "#f85149"; label = "REJECTED"; } else if (pipeline === "merge" && agentStatus === "running") { color = "#58a6ff"; label = "▶ MERGING"; } else if (pipeline === "merge" && agentStatus === "pending") { color = "#e3b341"; label = "⏳ QUEUED"; } else if (pipeline === "merge") { color = "#6e7681"; if (mergeQueuePos === 1) { label = "NEXT IN QUEUE"; } else if (mergeQueuePos != null) { label = `awaiting-slot (#${mergeQueuePos})`; } else { label = "awaiting-slot"; } } else { color = PIPELINE_COLORS[pipeline] ?? "#8b949e"; label = PIPELINE_LABELS[pipeline] ?? pipeline; } const isMergeActive = pipeline === "merge" && status === "active" && agentStatus === "running"; const idNum = item.story_id.match(/^(\d+)/)?.[1]; return (
{label} {idNum && #{idNum}{" "}} {frozenPrefix}{item.name}
); } function TokenDisplay({ token }: { token: string }) { const [copied, setCopied] = useState(false); const envCmd = `HUSKIES_JOIN_TOKEN=${token} huskies agent --rendezvous `; const flagCmd = `huskies agent --rendezvous --join-token ${token}`; const copyToClipboard = useCallback((text: string) => { void navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, []); return (
Token generated — run the build agent with one of:
{envCmd}
{flagCmd}
This token is single-use. Generate a new one for each agent.
); } function AgentRow({ agent, projects, onRemove, onAssign, }: { agent: JoinedAgent; projects: GatewayProject[]; onRemove: (id: string) => void; onAssign: (id: string, project: string | null) => void; }) { const registeredAt = new Date(agent.registered_at * 1000).toLocaleString(); const status = agentStatus(agent); const statusColor = STATUS_COLORS[status]; const statusLabel = STATUS_LABELS[status]; return (
{agent.label} {statusLabel}
{agent.address}
Registered {registeredAt} {agent.assigned_project && ( · Project: {agent.assigned_project} )}
); } type TabKey = "backlog" | "in-progress" | "done" | "archived"; const TAB_STORAGE_KEY = "gateway_selected_tab"; /// Read the persisted tab from localStorage, defaulting to "in-progress". function readStoredTab(): TabKey { const stored = localStorage.getItem(TAB_STORAGE_KEY); if ( stored === "backlog" || stored === "in-progress" || stored === "done" || stored === "archived" ) { return stored; } return "in-progress"; } /// Aggregate pipeline items from all projects for a given tab. function aggregateItems( pipeline: AllProjectsPipeline, tab: TabKey, ): { project: string; items: PipelineItem[] }[] { return Object.entries(pipeline.projects) .map(([project, status]) => { if (status.error) return { project, items: [] }; if (tab === "backlog") { return { project, items: (status.backlog ?? []).map((b) => ({ story_id: b.story_id, name: b.name, stage: "backlog", pipeline: "backlog" as Pipeline, status: "active" as Status, })), }; } if (tab === "in-progress") { return { project, items: (status.active ?? []).filter( (i) => itemPipeline(i) !== "done", ), }; } if (tab === "done") { return { project, items: (status.active ?? []).filter((i) => itemPipeline(i) === "done"), }; } // archived return { project, items: status.archived ?? [] }; }) .filter((g) => g.items.length > 0); } /// Count total items across all projects for a given tab. function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number { return Object.values(pipeline.projects).reduce((sum, status) => { if (status.error) return sum; if (tab === "backlog") return sum + (status.backlog_count ?? 0); if (tab === "in-progress") { return ( sum + (status.active ?? []).filter((i) => itemPipeline(i) !== "done").length ); } if (tab === "done") { return ( sum + (status.active ?? []).filter((i) => itemPipeline(i) === "done").length ); } return sum + (status.archived ?? []).length; }, 0); } /// Tab bar button. function TabButton({ label, count, active, onClick, }: { label: string; count: number; active: boolean; onClick: () => void; }) { return ( ); } /// A project-labelled story row used in the aggregate tab view. function ProjectStoryRow({ project, item, showProject, mergeQueuePos, }: { project: string; item: PipelineItem; showProject: boolean; mergeQueuePos?: number; }) { return (
{showProject && ( {project} )}
); } const IN_PROGRESS_PIPELINE_LABELS: Record<"coding" | "qa" | "merge", string> = { coding: "Coding", qa: "QA", merge: "Merging", }; /// In Progress tab content — items grouped by their `pipeline` column. /// /// Frozen items appear in the column corresponding to their underlying /// `Stage::resume_to` (server-side), so they always show up in-place. function InProgressTabContent({ groups, }: { groups: { project: string; items: PipelineItem[] }[]; }) { const allItems = groups.flatMap((g) => g.items.map((item) => ({ project: g.project, item })), ); const multiProject = new Set(allItems.map((x) => x.project)).size > 1; const byPipeline = { coding: allItems.filter((x) => itemPipeline(x.item) === "coding"), qa: allItems.filter((x) => itemPipeline(x.item) === "qa"), merge: allItems.filter((x) => itemPipeline(x.item) === "merge"), }; const pipelines = (["coding", "qa", "merge"] as const).filter( (p) => byPipeline[p].length > 0, ); // Compute queue position among "clean" awaiting-merge items: pipeline=merge, // status=active, and no agent currently running. const mergeQueuePosMap = new Map(); let queuePos = 0; for (const { project, item } of byPipeline.merge) { if (itemStatus(item) === "active" && item.agent?.status !== "running") { queuePos += 1; mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos); } } if (allItems.length === 0) { return (

No items in progress.

); } return (
{pipelines.map((p) => (
{IN_PROGRESS_PIPELINE_LABELS[p]}{" "} ({byPipeline[p].length})
{byPipeline[p].map(({ project, item }) => ( ))}
))}
); } /// Flat list tab content for Backlog, Done, and Archived. function FlatTabContent({ groups, emptyMessage, }: { groups: { project: string; items: PipelineItem[] }[]; emptyMessage: string; }) { const allItems = groups.flatMap((g) => g.items.map((item) => ({ project: g.project, item })), ); const multiProject = new Set(allItems.map((x) => x.project)).size > 1; if (allItems.length === 0) { return (

{emptyMessage}

); } return (
{allItems.map(({ project, item }) => ( ))}
); } /// Gateway management panel — rendered when running in `--gateway` mode. export function GatewayPanel() { const [agents, setAgents] = useState([]); const [projects, setProjects] = useState([]); const [token, setToken] = useState(null); const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); const [pipeline, setPipeline] = useState(null); const [selectedTab, setSelectedTab] = useState(readStoredTab); // Keep stable refs so polling intervals don't recreate on state changes. const setAgentsRef = useRef(setAgents); setAgentsRef.current = setAgents; const setPipelineRef = useRef(setPipeline); setPipelineRef.current = setPipeline; useEffect(() => { // Initial load. gatewayApi .listAgents() .then(setAgents) .catch(() => setAgents([])); gatewayApi .getGatewayInfo() .then((info) => setProjects(info.projects)) .catch(() => setProjects([])); gatewayApi .getAllProjectsPipeline() .then(setPipeline) .catch(() => setPipeline(null)); // Poll so the dashboard auto-updates as agents connect/disconnect and // stories move through pipelines. const timer = setInterval(() => { gatewayApi .listAgents() .then((updated) => setAgentsRef.current(updated)) .catch(() => {}); gatewayApi .getAllProjectsPipeline() .then((updated) => setPipelineRef.current(updated)) .catch(() => {}); }, POLL_INTERVAL_MS); return () => clearInterval(timer); }, []); const handleAddAgent = useCallback(async () => { setGenerating(true); setError(null); setToken(null); try { const result = await gatewayApi.generateToken(); setToken(result.token); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setGenerating(false); } }, []); const handleRemoveAgent = useCallback(async (id: string) => { try { await gatewayApi.removeAgent(id); setAgents((prev) => prev.filter((a) => a.id !== id)); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, []); const handleAssignAgent = useCallback( async (id: string, project: string | null) => { try { const updated = await gatewayApi.assignAgent(id, project); setAgents((prev) => prev.map((a) => (a.id === updated.id ? updated : a)), ); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, [], ); const handleSelectTab = useCallback((tab: TabKey) => { setSelectedTab(tab); localStorage.setItem(TAB_STORAGE_KEY, tab); }, []); return (

Huskies Gateway

Manage build agents connected to this gateway.

{/* Cross-project pipeline tabs */}
{/* Tab bar */}
{( [ { key: "backlog", label: "Backlog" }, { key: "in-progress", label: "In Progress" }, { key: "done", label: "Done" }, { key: "archived", label: "Archived" }, ] as { key: TabKey; label: string }[] ).map(({ key, label }) => ( handleSelectTab(key)} /> ))}
{/* Tab content */} {pipeline ? ( <> {selectedTab === "backlog" && ( )} {selectedTab === "in-progress" && ( )} {selectedTab === "done" && ( )} {selectedTab === "archived" && ( )} ) : (

Loading pipeline status…

)}
{/* Add Agent */}

Add Agent

{token && }
{/* Agent list */}

Connected Agents{" "} {agents.length > 0 && ( ({agents.length}) )}

{agents.length === 0 ? (

No agents connected yet. Click "Add Agent" to generate a join token.

) : (
{agents.map((agent) => ( ))}
)}
{/* Project management */}

Projects{" "} {projects.length > 0 && ( ({projects.length}) )}

{/* Existing projects list */} {projects.map((p) => (
{p.name}
{p.url}
))}
{error && (
{error}
)}
); }