/// Gateway management panel shown when huskies runs in `--gateway` mode. /// /// Provides: /// - 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 } 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", }; 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); // Add-project form state const [newProjectName, setNewProjectName] = useState(""); const [newProjectUrl, setNewProjectUrl] = useState(""); const [addingProject, setAddingProject] = useState(false); // Keep a stable ref to setAgents so the polling interval doesn't need to // be recreated when the agents list changes. const setAgentsRef = useRef(setAgents); setAgentsRef.current = setAgents; useEffect(() => { // Initial load. gatewayApi .listAgents() .then(setAgents) .catch(() => setAgents([])); gatewayApi .getGatewayInfo() .then((info) => setProjects(info.projects)) .catch(() => setProjects([])); // Poll the agent list so the dashboard auto-updates when agents connect // or disconnect. const timer = setInterval(() => { gatewayApi .listAgents() .then((updated) => setAgentsRef.current(updated)) .catch(() => { // Swallow poll errors to avoid spamming the error banner. }); }, 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 handleAddProject = useCallback(async () => { const name = newProjectName.trim(); const url = newProjectUrl.trim(); if (!name || !url) return; setAddingProject(true); setError(null); try { const created = await gatewayApi.addProject(name, url); setProjects((prev) => [...prev, created]); setNewProjectName(""); setNewProjectUrl(""); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setAddingProject(false); } }, [newProjectName, newProjectUrl]); const handleRemoveProject = useCallback(async (name: string) => { if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) { return; } setError(null); try { await gatewayApi.removeProject(name); setProjects((prev) => prev.filter((p) => p.name !== name)); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, []); return (

Huskies Gateway

Manage build agents connected to this gateway.

{/* 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}
))} {/* Add project form */}
Name
setNewProjectName(e.target.value)} style={{ width: "100%", padding: "6px 10px", borderRadius: "4px", border: "1px solid #30363d", background: "#0d1117", color: "#e6edf3", fontSize: "0.85em", }} />
Container URL
setNewProjectUrl(e.target.value)} style={{ width: "100%", padding: "6px 10px", borderRadius: "4px", border: "1px solid #30363d", background: "#0d1117", color: "#e6edf3", fontSize: "0.85em", }} />
{error && (
{error}
)}
); }