huskies: merge 568_story_gateway_ui_connected_agents_dashboard
This commit is contained in:
+86
-1
@@ -65,6 +65,10 @@ pub struct JoinedAgent {
|
||||
pub address: String,
|
||||
/// Unix timestamp when the agent registered.
|
||||
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.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_project: Option<String>,
|
||||
@@ -691,11 +695,13 @@ pub async fn gateway_register_agent_handler(
|
||||
tokens.remove(&req.token);
|
||||
drop(tokens);
|
||||
|
||||
let now = chrono::Utc::now().timestamp() as f64;
|
||||
let agent = JoinedAgent {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
label: req.label,
|
||||
address: req.address,
|
||||
registered_at: chrono::Utc::now().timestamp() as f64,
|
||||
registered_at: now,
|
||||
last_seen: now,
|
||||
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 ──────────────────────────────────────
|
||||
|
||||
/// 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",
|
||||
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.
|
||||
.at(
|
||||
"/assets/*path",
|
||||
@@ -2064,6 +2106,7 @@ enabled = false
|
||||
label: "agent-1".into(),
|
||||
address: "ws://a:3001/crdt-sync".into(),
|
||||
registered_at: 0.0,
|
||||
last_seen: 0.0,
|
||||
assigned_project: None,
|
||||
});
|
||||
let app = poem::Route::new()
|
||||
@@ -2085,6 +2128,7 @@ enabled = false
|
||||
label: "to-delete".into(),
|
||||
address: "ws://x:3001/crdt-sync".into(),
|
||||
registered_at: 0.0,
|
||||
last_seen: 0.0,
|
||||
assigned_project: None,
|
||||
});
|
||||
let app = poem::Route::new()
|
||||
@@ -2112,4 +2156,45 @@ enabled = false
|
||||
let resp = cli.delete("/gateway/agents/no-such-id").send().await;
|
||||
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