From 59041d4f03dc943dd7563b239d0a3606690a60ac Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 26 Jul 2026 02:52:45 +0200 Subject: [PATCH] feat(dashboard): add paused badge and pause/resume toggle to agent cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ContainerView.paused is true, show a clickable yellow `⏸ paused` badge on the agent card that POSTs to the new /api/resume/{name} endpoint to un-park the turn loop. The badge doubles as the resume button so the state is self-documenting and one click to fix. The agent action menu gains ⏸ P4US3 (when not paused) and ▶ R3SUM3 (when paused), orthogonal to the running/stopped start/stop actions. On the backend, /api/pause/{name} and /api/resume/{name} POST routes wire to Coordinator::set_paused and trigger an immediate rescan so the badge flips via the existing SSE ContainerUpdate without polling. Depends on the ContainerView.paused field and Coordinator::set_paused added in the parent PR. --- frontend/packages/dashboard/src/common.css | 6 +++ frontend/packages/dashboard/src/swarm.js | 22 ++++++++++ hive-c0re/src/dashboard/lifecycle_ops.rs | 50 ++++++++++++++++++++++ hive-c0re/src/dashboard/mod.rs | 2 + 4 files changed, 80 insertions(+) diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 1ddd0858..8ae53ae8 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -81,6 +81,12 @@ code { color: var(--purple); border-color: var(--purple); text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 40%, transparent); } +/* Paused agent: turn loop parked, container still up. Clickable (btn-inline): + clicking sends POST /api/resume/{name} so the badge doubles as a resume button. */ +.badge-paused { + color: var(--yellow); border-color: var(--yellow); + background: color-mix(in srgb, var(--yellow) 10%, transparent); +} /* Active Claude model badge on dashboard container rows. */ .badge-model { color: var(--blue); border-color: var(--blue); diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index 88a9875a..11e72478 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -307,7 +307,19 @@ function buildAgentMenu(c, forgeBase) { menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }), ); } + // Pause/resume is orthogonal to running: a paused stopped agent boots + // paused; a paused running agent keeps its container but drives no turns. + if (c.paused) { + dropdown.append( + menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }), + ); + } else { + dropdown.append( + menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }), + ); + } dropdown.append( + menuSep(), menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }), menuSep(), // Deep-link to the AGENT log tab pre-filtered to this container. @@ -457,6 +469,7 @@ function containerRowFingerprint(c, node, pending, opRunning, selected, askerCount, targetCount, gatewayLinks, hostname) { return JSON.stringify({ running: c.running, + paused: c.paused, needs_login: c.needs_login, needs_update: c.needs_update, active_model: c.active_model, @@ -657,6 +670,15 @@ function buildContainerLi(c, node, opts) { { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, 'needs login →')); } + if (c.paused) { + // Paused badge is also a resume button: clicking POSTs /api/resume/{name} + // which removes the marker and flips the badge off via the SSE rescan. + head.append(form( + '/api/resume/' + c.name, 'badge badge-paused btn-inline', '⏸ paused', + `resume ${c.name}? the turn loop restarts and drains queued messages.`, + {}, { noRefresh: true }, + )); + } if (c.needs_update) { head.append(form( '/api/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', diff --git a/hive-c0re/src/dashboard/lifecycle_ops.rs b/hive-c0re/src/dashboard/lifecycle_ops.rs index 4f2bdb9f..a6c7ce0a 100644 --- a/hive-c0re/src/dashboard/lifecycle_ops.rs +++ b/hive-c0re/src/dashboard/lifecycle_ops.rs @@ -137,6 +137,56 @@ pub(super) async fn post_start( (StatusCode::OK, "ok").into_response() } +/// `POST /api/pause/{name}` — write the pause marker for `name`. +/// +/// Unlike the lifecycle ops above this is not a DAG: it writes a single +/// marker file, which the harness stats at the top of its serve loop. +/// Works on stopped containers too (the marker is sticky and takes effect +/// when the container next boots). Triggers an immediate rescan so the +/// `paused` badge flips on the dashboard without waiting for the next +/// periodic sweep. +pub(super) async fn post_pause( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + // guard_agent_name already validated `logical` as a well-formed Ident. + let Ok(ident) = hive_types::Ident::parse(&logical) else { + unreachable!("guard_agent_name passed an invalid ident"); + }; + if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, true) { + return error_response(&format!("pause {logical}: {e}")); + } + state.coord.rescan_containers_and_emit().await; + (StatusCode::OK, "ok").into_response() +} + +/// `POST /api/resume/{name}` — remove the pause marker for `name`. +/// +/// The inverse of `post_pause`. Removing a non-existent marker is a no-op +/// (idempotent). Triggers an immediate rescan so the paused badge clears. +pub(super) async fn post_resume( + State(state): State, + AxumPath(name): AxumPath, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + // guard_agent_name already validated `logical` as a well-formed Ident. + let Ok(ident) = hive_types::Ident::parse(&logical) else { + unreachable!("guard_agent_name passed an invalid ident"); + }; + if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, false) { + return error_response(&format!("resume {logical}: {e}")); + } + state.coord.rescan_containers_and_emit().await; + (StatusCode::OK, "ok").into_response() +} + pub(super) async fn post_update_all(State(state): State) -> Response { let containers = lifecycle::list().await.unwrap_or_default(); for container in containers { diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 7334a7d5..cdb35f55 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -193,6 +193,8 @@ pub async fn serve( .route("/api/restart/{name}", post(lifecycle_ops::post_restart)) .route("/api/start/{name}", post(lifecycle_ops::post_start)) .route("/api/rebuild/{name}", post(lifecycle_ops::post_rebuild)) + .route("/api/pause/{name}", post(lifecycle_ops::post_pause)) + .route("/api/resume/{name}", post(lifecycle_ops::post_resume)) .route("/api/update-all", post(lifecycle_ops::post_update_all)) .route( "/api/infra-container/{name}/{action}",