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:
parent
300be8afa9
commit
c337cc06f8
5 changed files with 157 additions and 38 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, ¤t_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, ¤t_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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue