huskies: merge 568_story_gateway_ui_connected_agents_dashboard

This commit is contained in:
dave
2026-04-15 18:20:39 +00:00
parent d68614e26a
commit 149a383447
3 changed files with 172 additions and 7 deletions
+77 -6
View File
@@ -3,12 +3,42 @@
/// 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 project assignment and "Remove" buttons.
/// - 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, useState } = React;
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<AgentStatus, string> = {
idle: "#6e7681",
working: "#3fb950",
disconnected: "#f85149",
};
const STATUS_LABELS: Record<AgentStatus, string> = {
idle: "Idle",
working: "Working",
disconnected: "Disconnected",
};
function TokenDisplay({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
@@ -100,7 +130,9 @@ function AgentRow({
onAssign: (id: string, project: string | null) => void;
}) {
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
const isAssigned = Boolean(agent.assigned_project);
const status = agentStatus(agent);
const statusColor = STATUS_COLORS[status];
const statusLabel = STATUS_LABELS[status];
return (
<div
@@ -121,18 +153,38 @@ function AgentRow({
width: "8px",
height: "8px",
borderRadius: "50%",
background: isAssigned ? "#3fb950" : "#6e7681",
background: statusColor,
flexShrink: 0,
}}
title={isAssigned ? "Assigned" : "Idle (unassigned)"}
title={statusLabel}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</span>
<span
data-testid={`agent-status-${agent.id}`}
style={{
fontSize: "0.75em",
padding: "1px 6px",
borderRadius: "10px",
background: `${statusColor}22`,
color: statusColor,
border: `1px solid ${statusColor}44`,
}}
>
{statusLabel}
</span>
</div>
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>
{agent.address}
</div>
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
Registered {registeredAt}
{agent.assigned_project && (
<span style={{ marginLeft: "8px", color: "#8b949e" }}>
· Project: {agent.assigned_project}
</span>
)}
</div>
</div>
<select
@@ -191,7 +243,13 @@ export function GatewayPanel() {
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)
@@ -200,6 +258,19 @@ export function GatewayPanel() {
.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 () => {