diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts index 408cf233..ce30cf5e 100644 --- a/frontend/src/api/gateway.ts +++ b/frontend/src/api/gateway.ts @@ -8,6 +8,8 @@ export interface JoinedAgent { label: string; address: string; registered_at: number; + /// Unix timestamp of the last heartbeat from this agent. + last_seen: number; /// Project this agent is assigned to, if any. assigned_project?: string; } @@ -102,4 +104,11 @@ export const gatewayApi = { { method: "DELETE" }, ); }, + + /// Send a heartbeat for an agent to update its last-seen timestamp. + heartbeat(id: string): Promise { + return gatewayRequest(`/gateway/agents/${id}/heartbeat`, { + method: "POST", + }); + }, }; diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx index 96e77f1e..d2430160 100644 --- a/frontend/src/components/GatewayPanel.tsx +++ b/frontend/src/components/GatewayPanel.tsx @@ -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 = { + 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); @@ -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 (
-
{agent.label}
+
+ {agent.label} + + {statusLabel} + +
{agent.address}
Registered {registeredAt} + {agent.assigned_project && ( + + · Project: {agent.assigned_project} + + )}