huskies: merge 568_story_gateway_ui_connected_agents_dashboard
This commit is contained in:
@@ -8,6 +8,8 @@ export interface JoinedAgent {
|
|||||||
label: string;
|
label: string;
|
||||||
address: string;
|
address: string;
|
||||||
registered_at: number;
|
registered_at: number;
|
||||||
|
/// Unix timestamp of the last heartbeat from this agent.
|
||||||
|
last_seen: number;
|
||||||
/// Project this agent is assigned to, if any.
|
/// Project this agent is assigned to, if any.
|
||||||
assigned_project?: string;
|
assigned_project?: string;
|
||||||
}
|
}
|
||||||
@@ -102,4 +104,11 @@ export const gatewayApi = {
|
|||||||
{ method: "DELETE" },
|
{ method: "DELETE" },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Send a heartbeat for an agent to update its last-seen timestamp.
|
||||||
|
heartbeat(id: string): Promise<void> {
|
||||||
|
return gatewayRequest<void>(`/gateway/agents/${id}/heartbeat`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,42 @@
|
|||||||
/// Provides:
|
/// Provides:
|
||||||
/// - An "Add Agent" button that generates a one-time join token.
|
/// - An "Add Agent" button that generates a one-time join token.
|
||||||
/// - Instructions for running a build agent with the 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 * as React from "react";
|
||||||
import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway";
|
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 }) {
|
function TokenDisplay({ token }: { token: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -100,7 +130,9 @@ function AgentRow({
|
|||||||
onAssign: (id: string, project: string | null) => void;
|
onAssign: (id: string, project: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -121,18 +153,38 @@ function AgentRow({
|
|||||||
width: "8px",
|
width: "8px",
|
||||||
height: "8px",
|
height: "8px",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: isAssigned ? "#3fb950" : "#6e7681",
|
background: statusColor,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
title={isAssigned ? "Assigned" : "Idle (unassigned)"}
|
title={statusLabel}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }}>
|
<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" }}>
|
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>
|
||||||
{agent.address}
|
{agent.address}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
|
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
|
||||||
Registered {registeredAt}
|
Registered {registeredAt}
|
||||||
|
{agent.assigned_project && (
|
||||||
|
<span style={{ marginLeft: "8px", color: "#8b949e" }}>
|
||||||
|
· Project: {agent.assigned_project}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
@@ -191,7 +243,13 @@ export function GatewayPanel() {
|
|||||||
const [newProjectUrl, setNewProjectUrl] = useState("");
|
const [newProjectUrl, setNewProjectUrl] = useState("");
|
||||||
const [addingProject, setAddingProject] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
|
// Initial load.
|
||||||
gatewayApi
|
gatewayApi
|
||||||
.listAgents()
|
.listAgents()
|
||||||
.then(setAgents)
|
.then(setAgents)
|
||||||
@@ -200,6 +258,19 @@ export function GatewayPanel() {
|
|||||||
.getGatewayInfo()
|
.getGatewayInfo()
|
||||||
.then((info) => setProjects(info.projects))
|
.then((info) => setProjects(info.projects))
|
||||||
.catch(() => setProjects([]));
|
.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 () => {
|
const handleAddAgent = useCallback(async () => {
|
||||||
|
|||||||
+86
-1
@@ -65,6 +65,10 @@ pub struct JoinedAgent {
|
|||||||
pub address: String,
|
pub address: String,
|
||||||
/// Unix timestamp when the agent registered.
|
/// Unix timestamp when the agent registered.
|
||||||
pub registered_at: f64,
|
pub registered_at: f64,
|
||||||
|
/// Unix timestamp of the last heartbeat from this agent. Defaults to `registered_at`
|
||||||
|
/// for agents loaded from persisted state that predate the heartbeat feature.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_seen: f64,
|
||||||
/// Project this agent is assigned to, if any.
|
/// Project this agent is assigned to, if any.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub assigned_project: Option<String>,
|
pub assigned_project: Option<String>,
|
||||||
@@ -691,11 +695,13 @@ pub async fn gateway_register_agent_handler(
|
|||||||
tokens.remove(&req.token);
|
tokens.remove(&req.token);
|
||||||
drop(tokens);
|
drop(tokens);
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().timestamp() as f64;
|
||||||
let agent = JoinedAgent {
|
let agent = JoinedAgent {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
label: req.label,
|
label: req.label,
|
||||||
address: req.address,
|
address: req.address,
|
||||||
registered_at: chrono::Utc::now().timestamp() as f64,
|
registered_at: now,
|
||||||
|
last_seen: now,
|
||||||
assigned_project: None,
|
assigned_project: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -815,6 +821,38 @@ pub async fn gateway_assign_agent_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /gateway/agents/:id/heartbeat` — update an agent's last-seen timestamp.
|
||||||
|
///
|
||||||
|
/// Build agents should call this periodically (e.g. every 30 s) so the gateway
|
||||||
|
/// can distinguish live agents from disconnected ones. Returns 204 No Content on
|
||||||
|
/// success or 404 if the agent ID is not found.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_heartbeat_handler(
|
||||||
|
PoemPath(id): PoemPath<String>,
|
||||||
|
state: Data<&Arc<GatewayState>>,
|
||||||
|
) -> Response {
|
||||||
|
let found = {
|
||||||
|
let mut agents = state.joined_agents.write().await;
|
||||||
|
match agents.iter_mut().find(|a| a.id == id) {
|
||||||
|
None => false,
|
||||||
|
Some(a) => {
|
||||||
|
a.last_seen = chrono::Utc::now().timestamp() as f64;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if found {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.body(Body::empty())
|
||||||
|
} else {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::from("agent not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Health aggregation endpoint ──────────────────────────────────────
|
// ── Health aggregation endpoint ──────────────────────────────────────
|
||||||
|
|
||||||
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
|
/// HTTP GET `/health` handler for the gateway — aggregates health from all projects.
|
||||||
@@ -1620,6 +1658,10 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|||||||
"/gateway/agents/:id/assign",
|
"/gateway/agents/:id/assign",
|
||||||
poem::post(gateway_assign_agent_handler),
|
poem::post(gateway_assign_agent_handler),
|
||||||
)
|
)
|
||||||
|
.at(
|
||||||
|
"/gateway/agents/:id/heartbeat",
|
||||||
|
poem::post(gateway_heartbeat_handler),
|
||||||
|
)
|
||||||
// Serve the embedded React frontend so the gateway has a UI.
|
// Serve the embedded React frontend so the gateway has a UI.
|
||||||
.at(
|
.at(
|
||||||
"/assets/*path",
|
"/assets/*path",
|
||||||
@@ -2064,6 +2106,7 @@ enabled = false
|
|||||||
label: "agent-1".into(),
|
label: "agent-1".into(),
|
||||||
address: "ws://a:3001/crdt-sync".into(),
|
address: "ws://a:3001/crdt-sync".into(),
|
||||||
registered_at: 0.0,
|
registered_at: 0.0,
|
||||||
|
last_seen: 0.0,
|
||||||
assigned_project: None,
|
assigned_project: None,
|
||||||
});
|
});
|
||||||
let app = poem::Route::new()
|
let app = poem::Route::new()
|
||||||
@@ -2085,6 +2128,7 @@ enabled = false
|
|||||||
label: "to-delete".into(),
|
label: "to-delete".into(),
|
||||||
address: "ws://x:3001/crdt-sync".into(),
|
address: "ws://x:3001/crdt-sync".into(),
|
||||||
registered_at: 0.0,
|
registered_at: 0.0,
|
||||||
|
last_seen: 0.0,
|
||||||
assigned_project: None,
|
assigned_project: None,
|
||||||
});
|
});
|
||||||
let app = poem::Route::new()
|
let app = poem::Route::new()
|
||||||
@@ -2112,4 +2156,45 @@ enabled = false
|
|||||||
let resp = cli.delete("/gateway/agents/no-such-id").send().await;
|
let resp = cli.delete("/gateway/agents/no-such-id").send().await;
|
||||||
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heartbeat_updates_last_seen() {
|
||||||
|
let state = make_test_state();
|
||||||
|
state.joined_agents.write().await.push(JoinedAgent {
|
||||||
|
id: "hb-id".into(),
|
||||||
|
label: "hb-agent".into(),
|
||||||
|
address: "ws://hb:3001/crdt-sync".into(),
|
||||||
|
registered_at: 0.0,
|
||||||
|
last_seen: 0.0,
|
||||||
|
assigned_project: None,
|
||||||
|
});
|
||||||
|
let app = poem::Route::new()
|
||||||
|
.at(
|
||||||
|
"/gateway/agents/:id/heartbeat",
|
||||||
|
poem::post(gateway_heartbeat_handler),
|
||||||
|
)
|
||||||
|
.data(state.clone());
|
||||||
|
let cli = poem::test::TestClient::new(app);
|
||||||
|
let resp = cli.post("/gateway/agents/hb-id/heartbeat").send().await;
|
||||||
|
assert_eq!(resp.0.status(), StatusCode::NO_CONTENT);
|
||||||
|
let agents = state.joined_agents.read().await;
|
||||||
|
assert!(agents[0].last_seen > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn heartbeat_unknown_id_returns_not_found() {
|
||||||
|
let state = make_test_state();
|
||||||
|
let app = poem::Route::new()
|
||||||
|
.at(
|
||||||
|
"/gateway/agents/:id/heartbeat",
|
||||||
|
poem::post(gateway_heartbeat_handler),
|
||||||
|
)
|
||||||
|
.data(state.clone());
|
||||||
|
let cli = poem::test::TestClient::new(app);
|
||||||
|
let resp = cli
|
||||||
|
.post("/gateway/agents/no-such-id/heartbeat")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user