/// 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 PipelineItem, } from "../api/gateway"; 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 STAGE_COLORS: Record = { current: "#3fb950", qa: "#d2a679", merge: "#79c0ff", done: "#6e7681", }; const STAGE_LABELS: Record = { current: "In Progress", qa: "QA", merge: "Merging", done: "Done", }; /// A single story row inside a project pipeline card. function StoryRow({ item }: { item: PipelineItem }) { const color = STAGE_COLORS[item.stage] ?? "#8b949e"; const label = STAGE_LABELS[item.stage] ?? item.stage; return (
{label} {item.name}
); } /// Pipeline status card for a single project. function ProjectPipelineCard({ name, pipeline, isActive, onSwitch, }: { name: string; pipeline: AllProjectsPipeline["projects"][string]; isActive: boolean; onSwitch: (name: string) => void; }) { const activeItems = pipeline.active ?? []; const backlogCount = pipeline.backlog_count ?? 0; const hasError = Boolean(pipeline.error); return (
onSwitch(name)} style={{ padding: "12px 16px", background: "#161b22", border: `1px solid ${isActive ? "#238636" : "#30363d"}`, borderRadius: "8px", marginBottom: "8px", cursor: "pointer", }} >
0 ? "8px" : 0, }} > {name} {isActive && ( active )} {backlogCount > 0 ? `${backlogCount} in backlog` : ""}
{hasError ? (
{pipeline.error}
) : activeItems.length === 0 ? (
No active stories
) : (
{activeItems.map((item) => ( ))}
)}
); } 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} )}
); } /// 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); // 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 handleSwitchProject = useCallback(async (name: string) => { setError(null); try { const result = await gatewayApi.switchProject(name); if (!result.ok) { setError(result.error ?? "Failed to switch project"); return; } // Refresh pipeline to reflect new active project. const updated = await gatewayApi.getAllProjectsPipeline(); setPipeline(updated); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, []); return (

Huskies Gateway

Manage build agents connected to this gateway.

{/* Cross-project pipeline status */}

Pipeline Status

{pipeline ? ( Object.entries(pipeline.projects).map(([name, status]) => ( )) ) : (

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}
)}
); }