|
|
|
|
@ -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,147 @@ 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 approvals +
|
|
|
|
|
// meta-input updates still work without the manager up, so we
|
|
|
|
|
// don't special-case the confirm prompt when the manager is in
|
|
|
|
|
// the selection (mara: "dont special case manager for stopping").
|
|
|
|
|
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
|
|
|
|
|
action: '/kill/',
|
|
|
|
|
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
|
|
|
|
|
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
|
|
|
|
|
|