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

@ -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) {

View file

@ -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; }