From 8428c693e0d34c8afa9d87ba8890786571fc0496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 17:00:56 +0200 Subject: [PATCH] dashboard: stop/restart per-container + update-all when any stale --- hive-c0re/assets/dashboard.css | 2 + hive-c0re/src/dashboard.rs | 106 ++++++++++++++++++++++++++++++++- hive-c0re/src/lifecycle.rs | 26 ++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index f2f1741..b5bbaaf 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -98,6 +98,8 @@ ul form.inline { display: inline-block; } .btn-deny { color: var(--red); border-color: var(--red); } .btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } +.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } +.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-talk { color: var(--cyan); border-color: var(--cyan); } .btn-spawn { color: var(--amber); border-color: var(--amber); } .spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 30c90ee..8afafd9 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -42,7 +42,10 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) .route("/destroy/{name}", post(post_destroy)) + .route("/kill/{name}", post(post_kill)) + .route("/restart/{name}", post(post_restart)) .route("/rebuild/{name}", post(post_rebuild)) + .route("/update-all", post(post_update_all)) .route("/request-spawn", post(post_request_spawn)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); @@ -65,6 +68,15 @@ async fn index(headers: HeaderMap, State(state): State) -> Html = + std::collections::HashMap::new(); + for c in &containers { + let logical = c + .strip_prefix(lifecycle::AGENT_PREFIX) + .unwrap_or(c.as_str()) + .to_owned(); + running.insert(c.clone(), lifecycle::is_running(&logical).await); + } let approvals = gc_orphans( &state.coord, state.coord.approvals.pending().unwrap_or_default(), @@ -83,7 +95,8 @@ async fn index(headers: HeaderMap, State(state): State) -> Html\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{ASYNC_FORMS_JS}\n{MSG_FLOW_JS}\n\n\n", - containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname), + containers = + render_containers(&containers, &running, &transient, current_rev.as_deref(), &hostname), )) } @@ -154,6 +167,67 @@ async fn post_rebuild(State(state): State, AxumPath(name): AxumPath, AxumPath(name): AxumPath) -> Response { + let logical = strip_container_prefix(&name); + if logical == lifecycle::MANAGER_NAME { + return error_response("kill: refusing to stop the manager"); + } + match lifecycle::kill(&logical).await { + Ok(()) => { + state.coord.unregister_agent(&logical); + Redirect::to("/").into_response() + } + Err(e) => error_response(&format!("kill {logical} failed: {e:#}")), + } +} + +async fn post_restart(State(_state): State, AxumPath(name): AxumPath) -> Response { + let logical = strip_container_prefix(&name); + match lifecycle::restart(&logical).await { + Ok(()) => Redirect::to("/").into_response(), + Err(e) => error_response(&format!("restart {logical} failed: {e:#}")), + } +} + +async fn post_update_all(State(state): State) -> Response { + let Some(current_rev) = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake) + else { + return error_response("update-all: hyperhive_flake has no canonical path"); + }; + let containers = lifecycle::list().await.unwrap_or_default(); + let mut errors = Vec::new(); + for container in containers { + let logical = if container == lifecycle::MANAGER_NAME { + lifecycle::MANAGER_NAME.to_owned() + } else if let Some(n) = container.strip_prefix(lifecycle::AGENT_PREFIX) { + n.to_owned() + } else { + continue; + }; + if !crate::auto_update::agent_needs_update(&logical, ¤t_rev) { + continue; + } + if let Err(e) = + crate::auto_update::rebuild_agent(&state.coord, &logical, ¤t_rev).await + { + errors.push(format!("{logical}: {e:#}")); + } + } + if errors.is_empty() { + Redirect::to("/").into_response() + } else { + error_response(&format!("update-all partial failure:\n{}", errors.join("\n"))) + } +} + +/// Convert either a logical name or a container name back to the logical +/// name. Sub-agents are `h-foo` → `foo`; manager stays `hm1nd`. +fn strip_container_prefix(name: &str) -> String { + name.strip_prefix(lifecycle::AGENT_PREFIX) + .unwrap_or(name) + .to_owned() +} + async fn post_destroy(State(state): State, AxumPath(name): AxumPath) -> Response { match actions::destroy(&state.coord, &name).await { Ok(()) => Redirect::to("/").into_response(), @@ -174,6 +248,7 @@ fn error_response(message: &str) -> Response { fn render_containers( containers: &[String], + running: &std::collections::HashMap, transient: &std::collections::HashMap, current_rev: Option<&str>, hostname: &str, @@ -181,6 +256,16 @@ fn render_containers( let mut out = String::from( "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\n", ); + // "update all" header button only when at least one container is stale. + if let Some(rev) = current_rev { + let any_stale = containers.iter().any(|c| { + let logical = c.strip_prefix(AGENT_PREFIX).unwrap_or(c); + crate::auto_update::agent_needs_update(logical, rev) + }); + if any_stale { + out.push_str("
\n"); + } + } out.push_str("
\n \n \n
\n

spawn requests queue as approvals. operator approves below to actually create the container.

\n"); // Render in-flight spawns first so the operator sees feedback immediately. if !transient.is_empty() { @@ -208,11 +293,19 @@ fn render_containers( } out.push_str("
    \n"); for container in containers { + let is_running = running.get(container).copied().unwrap_or(false); if container == MANAGER_NAME { let update_badge = update_badge_for(MANAGER_NAME, current_rev); + let restart_btn = if is_running { + format!( + "
    \n", + ) + } else { + String::new() + }; let _ = writeln!( out, - "
  • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}\n
    \n
  • ", + "
  • ▓█▓▒░ {container} m1nd{update_badge} :{MANAGER_PORT}\n{restart_btn}
    \n
  • ", ); } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { let port = lifecycle::agent_web_port(name); @@ -225,9 +318,16 @@ fn render_containers( ) }; let update_badge = update_badge_for(name, current_rev); + let running_buttons = if is_running { + format!( + "
    \n
    \n", + ) + } else { + String::new() + }; let _ = writeln!( out, - "
  • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n
    \n
    \n
  • ", + "
  • ▒░▒░░ {name} ag3nt{login_badge}{update_badge} {container} :{port}\n{running_buttons}
    \n
    \n
  • ", ); } } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 3602119..6cbef1b 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -126,6 +126,32 @@ pub async fn kill(name: &str) -> Result<()> { run(&["stop", &container]).await } +pub async fn start(name: &str) -> Result<()> { + validate(name)?; + let container = container_name(name); + run(&["start", &container]).await +} + +/// Stop + start without regenerating any config. For "kick the container" +/// without touching the flake or nspawn flags. +pub async fn restart(name: &str) -> Result<()> { + kill(name).await?; + start(name).await +} + +/// True when the container's systemd unit is active. Used by the dashboard +/// to gate stop/restart buttons. +pub async fn is_running(name: &str) -> bool { + let container = container_name(name); + let unit = format!("container@{container}.service"); + Command::new("systemctl") + .args(["is-active", "--quiet", &unit]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + /// Fully tear down a sub-agent's container: stop + remove via `nixos-container /// destroy`, then clean our own systemd drop-in. Leaves it to the caller to /// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir.