diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 4fdc692..6090909 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -118,61 +118,65 @@ return; } - const ul = el('ul'); + const ul = el('ul', { class: 'containers' }); for (const c of s.containers) { const url = `http://${s.hostname}:${c.port}/`; - const li = el('li'); - li.append( - el('a', { href: url }, c.name), - ' ', + const li = el('li', { class: 'container-row' + (c.pending ? ' pending' : '') }); + + // ── line 1: identity ───────────────────────────────────────── + const head = el('div', { class: 'head' }); + head.append( + el('a', { class: 'name', href: url }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); - if (c.needs_login) { - li.append(' ', el('a', - { class: 'role role-pending', href: url }, 'needs login →')); + if (c.pending) { + head.append(el('span', { class: 'pending-state' }, + el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…')); + } else if (c.needs_login) { + head.append(el('a', + { class: 'badge badge-warn', href: url }, 'needs login →')); } if (c.needs_update) { - li.append(' ', form( - '/rebuild/' + c.name, 'role role-pending btn-inline', 'needs update ↻', + head.append(form( + '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', 'rebuild ' + c.name + '? hot-reloads the container.', )); } - li.append(' ', el('span', { class: 'meta' }, `${c.container} :${c.port}`)); + head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`)); + li.append(head); + // ── line 2: action buttons ─────────────────────────────────── + const actions = el('div', { class: 'actions' }); if (c.running) { - li.append( - ' ', + actions.append( form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'), ); if (!c.is_manager) { - li.append( - ' ', + actions.append( form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'), ); } } else { - li.append( - ' ', + actions.append( form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'), ); } - li.append( - ' ', + actions.append( form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD', 'rebuild ' + c.name + '? hot-reloads the container.'), ); if (!c.is_manager) { - li.append( - ' ', + actions.append( form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', 'destroy ' + c.name + '? container is removed; state + creds kept.'), - ' ', form('/destroy/' + c.name, 'btn-destroy', 'PURG3', 'PURGE ' + c.name + '? container, config history, claude creds, ' + 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }), ); } + li.append(actions); + ul.append(li); } root.append(ul); @@ -304,8 +308,10 @@ renderQuestions(s); renderInbox(s); renderApprovals(s); - // Auto-refresh while a spawn is in flight; otherwise back off. - const next = s.transients.length ? 2000 : 0; + // Auto-refresh while a spawn is in flight OR while any container + // has a pending lifecycle action; otherwise back off. + const anyPending = s.containers.some((c) => c.pending); + const next = (s.transients.length || anyPending) ? 2000 : 0; if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } if (next) pollTimer = setTimeout(refreshState, next); } catch (err) { diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 10b86ac..e61c5ac 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -86,6 +86,65 @@ a:hover { } .role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(245, 194, 231, 0.08); } .role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(250, 179, 135, 0.08); } +/* Container rows: identity + meta on a flowing first line, action + buttons grouped on a second. Pending rows dim everything except + the pending-state indicator. */ +.containers { display: flex; flex-direction: column; gap: 0.4em; } +.container-row { + padding: 0.6em 0.8em; + border: 1px solid var(--border); + border-radius: 4px; + background: rgba(24, 24, 37, 0.55); + transition: opacity 200ms ease, border-color 200ms ease; +} +.container-row.pending { + border-color: var(--amber); + background: rgba(250, 179, 135, 0.05); +} +.container-row.pending .actions { opacity: 0.4; pointer-events: none; } +.container-row .head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 0.4em; +} +.container-row .head .name { + font-size: 1.05em; + font-weight: bold; +} +.container-row .head .meta { margin-left: auto; } +.container-row .actions { + display: flex; + flex-wrap: wrap; + gap: 0.4em; +} +.container-row .actions form.inline { display: inline-block; margin: 0; } +.badge { + display: inline-block; + padding: 0.05em 0.5em; + border: 1px solid; + border-radius: 2px; + font-size: 0.75em; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.badge-warn { + color: var(--amber); border-color: var(--amber); + text-shadow: 0 0 6px rgba(250, 179, 135, 0.5); +} +.pending-state { + color: var(--amber); + font-size: 0.85em; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); + animation: badge-pulse 1.6s ease-in-out infinite; +} +@keyframes badge-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} .meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; } .id { color: var(--pink); font-weight: bold; margin-right: 0.4em; } .agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; } diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 069b640..1b54efc 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -140,7 +140,12 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> bail!("refusing to destroy the manager ({name})"); } tracing::info!(%name, purge, "destroy"); - lifecycle::destroy(name).await?; + coord.set_transient(name, TransientKind::Destroying); + let result = lifecycle::destroy(name).await; + if result.is_err() { + coord.clear_transient(name); + } + result?; coord.unregister_agent(name); let runtime = Coordinator::agent_dir(name); if runtime.exists() { @@ -166,6 +171,7 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> "agent destroyed" }, ); + coord.clear_transient(name); coord.notify_manager(&HelperEvent::Destroyed { agent: name.to_owned(), }); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 389397d..ed44bea 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -54,6 +54,16 @@ pub struct TransientState { pub enum TransientKind { /// `lifecycle::spawn` is running (nixos-container create + update + start). Spawning, + /// `lifecycle::start` is running. + Starting, + /// `lifecycle::kill` is running. + Stopping, + /// `lifecycle::restart` is running. + Restarting, + /// `lifecycle::rebuild` is running (nixos-container update). + Rebuilding, + /// `actions::destroy` is running. + Destroying, } impl Coordinator { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 02b5bc9..c5f9334 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -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) -> 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) -> axum::J is_manager, needs_update, needs_login, + pending, }); } @@ -196,9 +205,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> 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, AxumPath(name): AxumPath 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, AxumPath(name): AxumPath { state.coord.unregister_agent(&logical); state @@ -363,20 +380,27 @@ async fn post_kill(State(state): State, AxumPath(name): AxumPath, - AxumPath(name): AxumPath, -) -> Response { +async fn post_restart(State(state): State, AxumPath(name): AxumPath) -> 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, AxumPath(name): AxumPath) -> Response { +async fn post_start(State(state): State, AxumPath(name): AxumPath) -> 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) -> 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 {