dashboard: agent selection + bulk action bar (#443)

per mara on #443: "dont show all the restart buttons etc., just
show state and links. instead, clicking an agent icon selects that
agent. you can select as many as you like. then you can run an
action on all of them."

selection model

- module-level selectionState = Set<string> of agent logical
  names. clicking a container-row icon toggles membership; Esc
  clears the whole set (ignored when an editable element has
  focus so typing in compose / answer / journal-search isn't
  intercepted).
- icon now has role="button" + tabindex="0" so it's
  keyboard-accessible; aria-pressed reflects the toggle state.
  hover + focus-visible get a mauve ring.
- selected rows get a .selected class — mauve outline +
  faint mauve wash on the row, brighter ring on the icon.
- on every renderContainers pass, stale selections (agents
  destroyed while selected) are pruned defensively.

sticky action bar

- new #selection-bar in index.html — fixed bottom strip with
  count chip, name list, action buttons, clear button. hidden
  when selection empty. styled to match the flow composer's
  frosted-mauve chrome (vibecore family).
- per mara's option B answer: every action button is visible;
  buttons that don't apply to the whole selection are disabled
  with a tooltip explaining WHY ("iris is already stopped" etc).
  .btn:disabled styling added.
- actions: R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3.
  per-action confirm prompt lists the names being acted on.
  when the selection includes the manager the ST0P prompt calls
  out the consequence (approvals + meta operations pause until
  manager is restarted). DESTR0Y/PURG3 stay sub-agent-only;
  including the manager disables them with a clear tooltip.
- actions POST per agent in a loop to the existing endpoints
  (/restart/{name}, /kill/{name}, etc.); no new backend wire
  surface. event-driven derived stores (containersState,
  rebuildQueueState) already update live via SSE — no manual
  refetch.
- failures from any individual POST surface in a single alert
  at the end rather than spamming dialogs mid-loop.

per-card actions removed

- the in-card R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3
  block is gone. cards now show identity / state / nav-strip /
  status text / drill-ins only — "state and links" per mara.
  the contextual needs-update chip in the head row stays.

manager stop

- the "also make manager stoppable" half of #443 ships as PR
  #445 (separate small backend change). this PR depends on #445
  for the bulk-stop button to actually work on selections
  containing the manager; until #445 merges, ST0P on a
  manager-included selection will fail individually with a 500
  for the manager (other selected agents are still stopped;
  failures bucket into the end-of-loop alert).

build clean: npm run build --workspace=@hive/dashboard. no
backend changes here.
This commit is contained in:
iris 2026-05-26 00:41:02 +02:00
commit a366c00e0e
3 changed files with 336 additions and 42 deletions

View file

@ -287,6 +287,43 @@ window.marked = marked;
if (s) renderContainers(s);
}
// ─── selection (#443) ───────────────────────────────────────────────
// Set of selected agent logical names. Toggled by clicking the
// container-row icon. When non-empty, the sticky #selection-bar
// becomes visible with the bulk actions. Per-card action buttons
// are gone — actions live in the bar.
const selectionState = new Set();
function toggleSelection(name) {
if (selectionState.has(name)) selectionState.delete(name);
else selectionState.add(name);
renderContainersFromState();
}
function clearSelection() {
if (selectionState.size === 0) return;
selectionState.clear();
renderContainersFromState();
}
// Esc clears the current selection (operator escape hatch — mirrors
// the side-panel close pattern). Ignored when an editable element
// has focus so typing in compose / answer / journal-search isn't
// intercepted.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!selectionState.size) return;
const a = document.activeElement;
if (a && (a.isContentEditable
|| a.tagName === 'INPUT'
|| a.tagName === 'TEXTAREA'
|| a.tagName === 'SELECT')) return;
e.preventDefault();
clearSelection();
});
document.addEventListener('click', (e) => {
if (e.target && e.target.closest('#selection-clear')) {
clearSelection();
}
});
// Re-derive port conflicts from the live containers map. Mirrors the
// server-side `build_port_conflicts` so the banner reacts to event
// updates instead of waiting for a /api/state refetch.
@ -445,6 +482,13 @@ window.marked = marked;
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
const ul = el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
@ -474,7 +518,12 @@ window.marked = marked;
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: 'rebuild queued')));
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
const selected = selectionState.has(c.name);
const li = el('li', {
class: 'container-row'
+ (pending ? ' pending' : '')
+ (selected ? ' selected' : ''),
});
// Topology: depth contributes left-padding; the glyph string in
// the .tree-prefix span draws the ├─ / └─ joint + continuation
// lines (`│ `) for ancestors whose subtree extends below this
@ -500,7 +549,28 @@ window.marked = marked;
// hyperhive mark (`/favicon.svg`, served by the dashboard
// itself, always reachable). (issues #195, #202)
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
const icon = el('div', { class: 'container-icon' }, iconImg);
// #443: icon is the selection toggle. Click → add/remove from
// `selectionState` → re-render. role=button + tabindex makes it
// keyboard-accessible; aria-pressed reflects the toggle state.
const icon = el('div', {
class: 'container-icon',
role: 'button',
tabindex: '0',
'aria-pressed': selected ? 'true' : 'false',
title: selected
? `deselect ${c.name} (or press Esc to clear all)`
: `select ${c.name} for bulk actions`,
}, iconImg);
icon.addEventListener('click', (e) => {
e.preventDefault();
toggleSelection(c.name);
});
icon.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSelection(c.name);
}
});
if (c.running) {
iconImg.src = `${url}icon`;
iconImg.addEventListener('error', () => {
@ -643,46 +713,15 @@ window.marked = marked;
));
}
// ── action buttons ───────────────────────────────────────────
const actions = el('div', { class: 'actions' });
if (c.running) {
actions.append(
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
'restart ' + c.name + '?', {}, { noRefresh: true }),
);
if (!c.is_manager) {
actions.append(
form('/kill/' + c.name, 'btn-stop', '■ ST0P',
'stop ' + c.name + '?', {}, { noRefresh: true }),
);
}
} else {
actions.append(
form('/start/' + c.name, 'btn-start', '▶ ST4RT',
'start ' + c.name + '?', {}, { noRefresh: true }),
);
}
actions.append(
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true }),
);
if (!c.is_manager) {
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
// wipes tombstone state which isn't event-derived yet, so it
// Both event-covered now (ContainerRemoved +
// TombstonesChanged); no /api/state refetch needed.
actions.append(
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.',
{}, { noRefresh: true }),
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, '
+ 'and notes are all WIPED. no undo.',
{ purge: 'on' }, { noRefresh: true }),
);
}
body.append(actions);
// Per-card action buttons used to live here (R3ST4RT / ST0P /
// ST4RT / R3BU1LD / DESTR0Y / PURG3). Per mara on #443: "dont
// show all the restart buttons etc., just show state and links.
// instead, clicking an agent icon selects that agent." Actions
// moved into the sticky #selection-bar (see renderSelectionBar)
// which appears when the operator has at least one agent
// selected via the icon click. The contextual `needs update ↻`
// chip in the head row stays — it's a state-hint, not an
// action button per se.
// ── drill-ins ────────────────────────────────────────────────
const drill = el('div', { class: 'drill-ins' });
@ -703,6 +742,152 @@ window.marked = marked;
ul.append(li);
}
root.append(ul);
renderSelectionBar(containers);
}
// ─── selection bar (#443) ───────────────────────────────────────────
// Sticky-bottom strip; visible when ≥1 agent selected. mara picked
// option B: show every action button, disable the ones that don't
// apply to the full selection, hover tooltip explains why. Actions
// POST per agent in a loop (no new backend wire — endpoints already
// exist and are individually idempotent / event-covered).
function renderSelectionBar(containers) {
const bar = $('selection-bar');
if (!bar) return;
const countSpan = $('selection-count');
const namesSpan = $('selection-names');
const actions = $('selection-actions');
if (!countSpan || !namesSpan || !actions) return;
const selected = containers.filter((c) => selectionState.has(c.name));
if (!selected.length) {
bar.hidden = true;
document.body.classList.remove('has-selection');
return;
}
bar.hidden = false;
document.body.classList.add('has-selection');
countSpan.textContent = selected.length === 1
? '1 agent selected'
: selected.length + ' agents selected';
namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
// Recompute action availability + tooltips per render. Each
// action declares which agents it CAN'T run on; the bar disables
// the button and surfaces the offending names in the tooltip.
actions.innerHTML = '';
const allRunning = selected.every((c) => c.running);
const allStopped = selected.every((c) => !c.running);
const noManagers = selected.every((c) => !c.is_manager);
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name);
function why(label, blockers) {
if (!blockers.length) return null;
return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
}
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
action: '/restart/',
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
});
// #443 also lifts the manager-stop guard: when the whole selection
// is running, ST0P applies — manager included. host-side hive-c0re
// keeps serving the dashboard either way. Per-agent confirm message
// calls out the manager case ("approvals pause until restart") when
// the manager is in the set.
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
action: '/kill/',
confirm: (names) => {
const hasManager = selected.some((c) => c.is_manager);
const base = `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`;
return hasManager
? base + ' approvals + meta operations pause until the manager is restarted.'
: base;
},
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
});
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
action: '/start/',
confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
});
addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
action: '/rebuild/',
confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
});
// DESTR0Y / PURG3: sub-agents only (manager has its own
// `refusing to destroy` guard at the host layer). When the
// selection includes the manager, both buttons go disabled with a
// clear reason rather than letting the operator submit and eat a
// 500.
addBulkButton(actions, 'btn-destroy', 'DESTR0Y', noManagers, selected, {
action: '/destroy/',
confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
disabledTitle: why('DESTR0Y', managerNames.map((n) => `\`${n}\` is the manager`)),
});
addBulkButton(actions, 'btn-destroy', 'PURG3', noManagers, selected, {
action: '/destroy/',
body: { purge: 'on' },
confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)),
});
}
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
const names = selected.map((c) => c.name);
const btn = el('button', {
type: 'button',
class: 'btn ' + btnClass,
}, label);
if (!enabled) {
btn.disabled = true;
if (opts.disabledTitle) btn.title = opts.disabledTitle;
}
btn.addEventListener('click', async () => {
if (btn.disabled) return;
const msg = opts.confirm(names);
if (msg && !confirm(msg)) return;
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
const failures = [];
// Sequential POSTs to keep server-side serialisation predictable
// (rebuild_queue dedups but other endpoints don't); the loop is
// short — bulk selections are typically a handful of agents.
for (const name of names) {
const body = new URLSearchParams(opts.body || {});
try {
const resp = await fetch(opts.action + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
}
} catch (err) {
failures.push(`${name}: ${err}`);
}
}
btn.disabled = false;
btn.innerHTML = original;
if (failures.length) {
alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'));
}
// Container-lifecycle events (ContainerStateChanged /
// ContainerRemoved / RebuildQueueChanged) flow over the existing
// SSE channel and update the derived stores live — no manual
// refresh needed.
});
parent.append(btn);
}
// Per-container journald viewer. Returns an inline trigger; the