dashboard: spinners on in-flight lifecycle actions + cleaner row layout

backend:
- TransientKind grows Starting / Stopping / Restarting / Rebuilding /
  Destroying alongside the existing Spawning. each dashboard handler
  (start/restart/kill/rebuild/destroy) wraps the lifecycle call with
  set_transient + clear_transient so the dashboard knows what's in
  flight. transient kind is surfaced inline on ContainerView.pending
  (existing-container actions) — only Spawning (pre-creation) lands
  in the separate transients list.

frontend:
- container row is now two lines: identity + meta on top, action
  buttons below. less cluttered, leaves room for the pending state
  pill. pending rows dim their actions and surface a pulsing
  '◐ spawning… / starting… / stopping… / restarting… / rebuilding…
  / destroying…' indicator next to the name.
- 'needs login' / 'needs update' chips moved into a unified .badge
  styling for consistency.
- auto-refresh kicks in not only on transient spawn but on any
  container with a pending action.
This commit is contained in:
müde 2026-05-15 19:49:43 +02:00
parent 300be8afa9
commit c337cc06f8
5 changed files with 157 additions and 38 deletions

View file

@ -120,6 +120,11 @@ struct ContainerView {
running: bool,
needs_update: bool,
needs_login: bool,
/// When a lifecycle action is in flight on this container, the kind
/// (`starting`, `stopping`, etc.) so the JS can render a spinner +
/// disable other buttons.
#[serde(skip_serializing_if = "Option::is_none")]
pending: Option<&'static str>,
}
#[derive(Serialize)]
@ -176,6 +181,9 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
} else {
!claude_has_session(&Coordinator::agent_claude_dir(&logical))
};
let pending = transient_snapshot
.get(&logical)
.map(|st| transient_label(st.kind));
containers.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
@ -184,6 +192,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
is_manager,
needs_update,
needs_login,
pending,
});
}
@ -196,9 +205,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
})
.map(|(name, st)| TransientView {
name,
kind: match st.kind {
crate::coordinator::TransientKind::Spawning => "spawning",
},
kind: transient_label(st.kind),
secs: st.since.elapsed().as_secs(),
})
.collect();
@ -337,10 +344,15 @@ async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<St
"rebuild: hyperhive_flake has no canonical path; manual rebuild only via `hive-c0re rebuild`",
);
};
let result = crate::auto_update::rebuild_agent(&state.coord, &name, &current_rev).await;
let logical = strip_container_prefix(&name);
state
.coord
.set_transient(&logical, crate::coordinator::TransientKind::Rebuilding);
let result = crate::auto_update::rebuild_agent(&state.coord, &logical, &current_rev).await;
state.coord.clear_transient(&logical);
match result {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("rebuild {name} failed: {e:#}")),
Err(e) => error_response(&format!("rebuild {logical} failed: {e:#}")),
}
}
@ -349,7 +361,12 @@ async fn post_kill(State(state): State<AppState>, AxumPath(name): AxumPath<Strin
if logical == lifecycle::MANAGER_NAME {
return error_response("kill: refusing to stop the manager");
}
match lifecycle::kill(&logical).await {
state
.coord
.set_transient(&logical, crate::coordinator::TransientKind::Stopping);
let result = lifecycle::kill(&logical).await;
state.coord.clear_transient(&logical);
match result {
Ok(()) => {
state.coord.unregister_agent(&logical);
state
@ -363,20 +380,27 @@ async fn post_kill(State(state): State<AppState>, AxumPath(name): AxumPath<Strin
}
}
async fn post_restart(
State(_state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
async fn post_restart(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
let logical = strip_container_prefix(&name);
match lifecycle::restart(&logical).await {
state
.coord
.set_transient(&logical, crate::coordinator::TransientKind::Restarting);
let result = lifecycle::restart(&logical).await;
state.coord.clear_transient(&logical);
match result {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("restart {logical} failed: {e:#}")),
}
}
async fn post_start(State(_state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
async fn post_start(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
let logical = strip_container_prefix(&name);
match lifecycle::start(&logical).await {
state
.coord
.set_transient(&logical, crate::coordinator::TransientKind::Starting);
let result = lifecycle::start(&logical).await;
state.coord.clear_transient(&logical);
match result {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("start {logical} failed: {e:#}")),
}
@ -416,6 +440,20 @@ async fn post_update_all(State(state): State<AppState>) -> Response {
}
}
fn transient_label(k: crate::coordinator::TransientKind) -> &'static str {
use crate::coordinator::TransientKind::{
Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping,
};
match k {
Spawning => "spawning",
Starting => "starting",
Stopping => "stopping",
Restarting => "restarting",
Rebuilding => "rebuilding",
Destroying => "destroying",
}
}
/// 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 {