Operator wants to trigger a scheduled prompt immediately
instead of waiting for the next interval. Adds a `↯ fire now`
button on every active schedule row, next to `✕ cancel all`.
Semantics (per design discussion with damocles):
- recurring schedules → out-of-band pulse, `next_fire_at_unix`
untouched; the regular cadence keeps firing on the original
schedule. Operator gets an extra fan-out, not a phase shift.
- one-shots → consumed after the manual fire. Operator's
intent reads as "the scheduled time was wrong, send NOW";
leaving the original time would be surprising.
Confirm dialog spells out the recurring-vs-one-shot
difference up front so the operator knows what they're about
to do. Button is disabled when every target is already
cancelled (nothing to fire).
Talks to `POST /api/schedules/{id}/fire-now` (damocles is
wiring the backend in parallel). Mauve styling distinguishes
it from cancel (red) and submit (amber); fits the existing
btn pattern.
docs/web-ui.md updated.
2890 lines
124 KiB
JavaScript
2890 lines
124 KiB
JavaScript
// /index.html entry point: tab renderers + tab routing + refreshState
|
||
// + notification deltas. Reads /api/state on cold load and after every
|
||
// async-form submit; live updates run through `applyXxx` mutation
|
||
// handlers triggered by the dashboard event stream. (Live SSE
|
||
// subscription on /index.html is still missing — see #406 step 3 / #408.)
|
||
//
|
||
// #406 step 1: pure helpers + side panel + OS notifications + path
|
||
// linkification moved to `./common.js`.
|
||
// #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker
|
||
// terminal, @-mention composer) moved to `./flow.js`. /flow.html now
|
||
// loads `flow.js` as its own bundle entry; this file (still named
|
||
// `app.js` for the moment; rename to `tabs.js` is the last step
|
||
// of #406) is loaded only by /index.html.
|
||
|
||
import { marked } from 'marked';
|
||
import {
|
||
$, el, esc, form,
|
||
fmtAgeSecs,
|
||
Panel, NOTIF,
|
||
makePathLink, appendText, appendLinkified,
|
||
openStream,
|
||
} from './common.js';
|
||
|
||
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||
// panel preview path. Set it here on the dashboard entry so file
|
||
// previews work; flow.js does the same for the flow-page entry.
|
||
window.marked = marked;
|
||
|
||
(() => {
|
||
// ─── constants ──────────────────────────────────────────────────────────
|
||
// Context-window badge thresholds. Preferred source is each container's
|
||
// `context_window_tokens` from /api/state (the real window for the model
|
||
// it last ran on) — thresholds are then 75% / 50% of it, matching the
|
||
// harness compaction watermarks (compact at 75%, auto-reset at 50%). The
|
||
// fixed token constants are the fallback for when that field is absent
|
||
// (agent has no turns yet, or no per-model config matched the model).
|
||
const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red
|
||
const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow
|
||
const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k)
|
||
const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k)
|
||
|
||
// Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406).
|
||
|
||
// Side panel singleton (Panel) moved to ./common.js (#406).
|
||
|
||
// Path linkification + file-preview side panel (openFilePanel,
|
||
// makePathLink, appendText, appendLinkified) moved to ./common.js (#406).
|
||
|
||
// OS notification module (NOTIF) moved to ./common.js (#406).
|
||
|
||
// Track which items we've already notified about so a re-render
|
||
// doesn't re-fire for the same row. Keyed by stable ids; reset only
|
||
// when the page reloads.
|
||
const seenApprovals = new Set();
|
||
const seenQuestions = new Set();
|
||
let seededNotify = false;
|
||
|
||
function notifyDeltas(s) {
|
||
const approvals = s.approvals || [];
|
||
const questions = s.questions || [];
|
||
if (!seededNotify) {
|
||
// First render after page load — fill the "seen" sets without
|
||
// firing notifications. We only want to notify on NEW items
|
||
// that arrived while the page is open. The inbox no longer
|
||
// needs seeding here: it's derived from the broker stream which
|
||
// does its own per-event notification on live arrival, and
|
||
// history-replayed events are silent by virtue of `fromHistory`.
|
||
for (const a of approvals) seenApprovals.add(a.id);
|
||
for (const q of questions) seenQuestions.add(q.id);
|
||
seededNotify = true;
|
||
return;
|
||
}
|
||
for (const a of approvals) {
|
||
if (seenApprovals.has(a.id)) continue;
|
||
seenApprovals.add(a.id);
|
||
const verb = a.kind === 'spawn' ? 'spawn approval'
|
||
: a.kind === 'init_config' ? 'config-init approval'
|
||
: 'config commit';
|
||
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
|
||
'hyperhive:approval:' + a.id);
|
||
}
|
||
for (const q of questions) {
|
||
if (seenQuestions.has(q.id)) continue;
|
||
seenQuestions.add(q.id);
|
||
const targetLabel = q.target || 'operator';
|
||
NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`,
|
||
q.question.slice(0, 120),
|
||
'hyperhive:question:' + q.id);
|
||
}
|
||
}
|
||
|
||
// ─── async forms ────────────────────────────────────────────────────────
|
||
document.addEventListener('submit', async (e) => {
|
||
const f = e.target;
|
||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||
e.preventDefault();
|
||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||
if (f.dataset.prompt) {
|
||
const ans = prompt(f.dataset.prompt, '');
|
||
if (ans === null) return; // operator hit Cancel
|
||
// Drop into a hidden input named after `data-prompt-field` (or
|
||
// 'note' by default) so the value rides along on the POST.
|
||
const field = f.dataset.promptField || 'note';
|
||
let input = f.querySelector(`input[name="${field}"]`);
|
||
if (!input) {
|
||
input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = field;
|
||
f.append(input);
|
||
}
|
||
input.value = ans;
|
||
}
|
||
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||
const original = btn ? btn.innerHTML : '';
|
||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
||
try {
|
||
const resp = await fetch(f.action, {
|
||
method: f.method || 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: new URLSearchParams(new FormData(f)),
|
||
redirect: 'manual',
|
||
});
|
||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||
|| (resp.status >= 200 && resp.status < 400);
|
||
if (!ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
|
||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||
return;
|
||
}
|
||
// Re-enable the button — refreshState() rebuilds most lists but
|
||
// skips forms that didn't change (e.g. the spawn form), so without
|
||
// this the spinner sticks and the button can't be clicked again.
|
||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||
// Clear text inputs whose value was just submitted.
|
||
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
||
// Forms whose endpoint already emits a DashboardEvent that
|
||
// updates the derived store can opt out of the post-submit
|
||
// /api/state refetch (the event delivers the new row faster
|
||
// than the snapshot poll anyway). Container-lifecycle forms
|
||
// still rely on the refresh since `ContainerView` isn't yet
|
||
// event-derivable.
|
||
if (!f.hasAttribute('data-no-refresh')) {
|
||
refreshState();
|
||
}
|
||
} catch (err) {
|
||
alert('action failed: ' + err);
|
||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||
}
|
||
});
|
||
|
||
// Derived container state — cold-loaded from /api/state.containers,
|
||
// then mutated live by `container_state_changed` (upsert by name)
|
||
// and `container_removed` (drop by name). The coordinator's rescan
|
||
// helper fires these after every mutation site + on a periodic poll
|
||
// in crash_watch. Keyed by ContainerView.name so the lifecycle
|
||
// forms' POST → 200 → matching event flips the row without a
|
||
// snapshot refetch.
|
||
const containersState = new Map();
|
||
function syncContainersFromSnapshot(s) {
|
||
containersState.clear();
|
||
for (const c of s.containers || []) containersState.set(c.name, c);
|
||
}
|
||
function applyContainerStateChanged(ev) {
|
||
if (!ev.container || !ev.container.name) return;
|
||
containersState.set(ev.container.name, ev.container);
|
||
renderContainersFromState();
|
||
}
|
||
function applyContainerRemoved(ev) {
|
||
if (containersState.delete(ev.name)) renderContainersFromState();
|
||
}
|
||
|
||
// Derived tombstones + meta_inputs. Both are emitted as full
|
||
// snapshots (not diffs) — the lists are tiny and recomputing
|
||
// avoids ordering races between a same-tick destroy + purge.
|
||
let tombstonesState = [];
|
||
let metaInputsState = [];
|
||
// True while a dashboard-triggered meta-update (flake lock bump +
|
||
// agent rebuild ripple) runs in the background. Cold-loaded from
|
||
// `s.meta_update_running`, then flipped live by the
|
||
// `meta_update_running` event. Drives the META INPUTS panel's
|
||
// disabled "updating…" state (issue #259).
|
||
let metaUpdateRunning = false;
|
||
function syncTombstonesFromSnapshot(s) {
|
||
tombstonesState = (s.tombstones || []).slice();
|
||
}
|
||
function syncMetaInputsFromSnapshot(s) {
|
||
metaInputsState = (s.meta_inputs || []).slice();
|
||
metaUpdateRunning = !!s.meta_update_running;
|
||
}
|
||
function applyTombstonesChanged(ev) {
|
||
tombstonesState = (ev.tombstones || []).slice();
|
||
renderTombstonesFromState();
|
||
}
|
||
function applyMetaInputsChanged(ev) {
|
||
metaInputsState = (ev.inputs || []).slice();
|
||
renderMetaInputsFromState();
|
||
}
|
||
function applyMetaUpdateRunning(ev) {
|
||
metaUpdateRunning = !!ev.running;
|
||
renderMetaInputsFromState();
|
||
}
|
||
function renderTombstonesFromState() {
|
||
renderTombstones({ tombstones: tombstonesState });
|
||
}
|
||
function renderMetaInputsFromState() {
|
||
renderMetaInputs({ meta_inputs: metaInputsState });
|
||
}
|
||
|
||
// Derived rebuild queue state — cold-loaded from
|
||
// `/api/state.rebuild_queue`, then mutated live by the
|
||
// `rebuild_queue_changed` snapshot event. Same shape as the meta-
|
||
// inputs panel (full snapshot per change, no diff).
|
||
let rebuildQueueState = [];
|
||
function syncRebuildQueueFromSnapshot(s) {
|
||
rebuildQueueState = (s.rebuild_queue || []).slice();
|
||
}
|
||
function applyRebuildQueueChanged(ev) {
|
||
rebuildQueueState = (ev.queue || []).slice();
|
||
renderRebuildQueueFromState();
|
||
// Container cards surface in-flight rebuild / meta-update ops as
|
||
// a "building..." badge (#398) — re-render the SW4RM tab so
|
||
// newly-queued / newly-running ops light up the right card,
|
||
// and finished ops fall back to the regular state badges.
|
||
renderContainersFromState();
|
||
}
|
||
// Map from agent name → highest-priority in-flight queue entry
|
||
// (`running` beats `queued`). Used by the container row renderer
|
||
// to surface "building..." / "meta-updating..." badges on the
|
||
// SW4RM tab when an op is still in the rebuild queue but no
|
||
// operator-initiated transient is set (#398).
|
||
function inFlightOpsByAgent() {
|
||
const out = new Map();
|
||
for (const e of rebuildQueueState) {
|
||
if (e.state !== 'queued' && e.state !== 'running') continue;
|
||
// spawn ops target an agent that doesn't exist yet as a
|
||
// container — the transient store already drives the
|
||
// pending row for that case. Skip here to avoid double-
|
||
// surfacing if the spawn op happens to land in the queue
|
||
// while the row exists transiently.
|
||
if (e.kind === 'spawn') continue;
|
||
const cur = out.get(e.agent);
|
||
// Prefer running over queued; otherwise keep the first match.
|
||
if (!cur || (cur.state === 'queued' && e.state === 'running')) {
|
||
out.set(e.agent, e);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
function renderRebuildQueueFromState() {
|
||
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
|
||
}
|
||
|
||
// Derived transient state — cold-loaded from /api/state.transients,
|
||
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
||
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
|
||
// the elapsed-seconds badge ticks without polling.
|
||
const transientsState = new Map();
|
||
function syncTransientsFromSnapshot(s) {
|
||
transientsState.clear();
|
||
for (const t of s.transients || []) {
|
||
// Snapshot ships `secs` (server-computed); reconstruct an
|
||
// approximate since_unix so the live ticker keeps progressing
|
||
// without surprising jumps when the next snapshot lands.
|
||
const nowUnix = Math.floor(Date.now() / 1000);
|
||
transientsState.set(t.name, {
|
||
kind: t.kind,
|
||
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
|
||
});
|
||
}
|
||
}
|
||
function applyTransientSet(ev) {
|
||
transientsState.set(ev.name, {
|
||
kind: ev.transient_kind,
|
||
since_unix: ev.since_unix,
|
||
});
|
||
renderContainersFromState();
|
||
}
|
||
function applyTransientCleared(ev) {
|
||
if (transientsState.delete(ev.name)) renderContainersFromState();
|
||
}
|
||
// Re-render using the last cached snapshot (containers come from
|
||
// /api/state, transients overlay from the derived map). The snapshot
|
||
// is stashed on window.__hyperhive_state by refreshState; on cold
|
||
// load before the first snapshot we just skip.
|
||
function renderContainersFromState() {
|
||
const s = window.__hyperhive_state;
|
||
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.
|
||
function derivePortConflicts(containers) {
|
||
const byPort = new Map();
|
||
for (const c of containers) {
|
||
if (!byPort.has(c.port)) byPort.set(c.port, []);
|
||
byPort.get(c.port).push(c.name);
|
||
}
|
||
const out = [];
|
||
for (const [port, agents] of byPort) {
|
||
if (agents.length > 1) {
|
||
agents.sort();
|
||
out.push({ port, agents });
|
||
}
|
||
}
|
||
out.sort((a, b) => a.port - b.port);
|
||
return out;
|
||
}
|
||
|
||
// ─── state rendering ────────────────────────────────────────────────────
|
||
// ─── agent topology (#363) ──────────────────────────────────────────────
|
||
// Build a forest from `ContainerView.parent` and walk depth-first to
|
||
// produce a render order with per-row depth + sibling-position info.
|
||
// Top-level (parent = null OR parent not in the container map) are
|
||
// roots. Within each level, children are sorted alphabetically by
|
||
// name; roots likewise. Cycles in the parent graph (malformed config)
|
||
// are tolerated: any container not reached via root-walk is appended
|
||
// as a root at the end, so no agent ever disappears from the list.
|
||
//
|
||
// For the visual: each row gets a textual prefix column (`├─`, `└─`,
|
||
// continuation `│ ` or padding ` ` for ancestor columns). When
|
||
// every container has `parent = null` (today's pre-#361 state), the
|
||
// tree collapses to a flat list with no glyphs and no indent — bit-
|
||
// identical to the legacy render.
|
||
function buildAgentTree(containers) {
|
||
const byName = new Map();
|
||
for (const c of containers) byName.set(c.name, c);
|
||
const children = new Map(); // parent_name → [child_name, …]
|
||
const roots = [];
|
||
for (const c of containers) {
|
||
const p = c.parent || null;
|
||
if (p == null || !byName.has(p)) {
|
||
roots.push(c.name);
|
||
} else {
|
||
const list = children.get(p) || [];
|
||
list.push(c.name);
|
||
children.set(p, list);
|
||
}
|
||
}
|
||
roots.sort();
|
||
for (const list of children.values()) list.sort();
|
||
const out = [];
|
||
const visited = new Set();
|
||
function visit(name, depth, ancestorIsLast, isLast) {
|
||
if (visited.has(name)) return;
|
||
visited.add(name);
|
||
const c = byName.get(name);
|
||
if (!c) return;
|
||
out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast });
|
||
const kids = children.get(name) || [];
|
||
kids.forEach((kid, i) =>
|
||
visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1));
|
||
}
|
||
roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1));
|
||
// Cycle safety: anything not reached lands at root level so no
|
||
// agent silently disappears when a config is malformed.
|
||
for (const c of containers) {
|
||
if (!visited.has(c.name)) visit(c.name, 0, [], true);
|
||
}
|
||
return out;
|
||
}
|
||
// Builds the .tree-prefix DOM for a row at the given depth. Each
|
||
// lane is its own positioned child so CSS can paint full-height
|
||
// vertical bars that bridge the gap between sibling rows — text
|
||
// box-drawing glyphs only paint one text-line tall, which left
|
||
// visible breaks between rows once we grew taller-than-one-line
|
||
// container cards (#388). Ancestor lanes are either continuation
|
||
// (vertical bar top→bottom+gap) or blank; the joint at this row's
|
||
// own depth is ├ (branch — vertical continues below) or └ (last —
|
||
// vertical stops at the row's icon midline). CSS at
|
||
// `.container-row .tree-prefix` paints the bars + horizontal stub.
|
||
function treePrefixDom({ depth, ancestorIsLast, isLast }) {
|
||
if (depth === 0) return null;
|
||
const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' });
|
||
// Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no
|
||
// continuation column — top-level rows are separated visually as
|
||
// top-level rows already).
|
||
for (let d = 1; d < depth; d++) {
|
||
const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line';
|
||
prefix.append(el('span', { class: cls }));
|
||
}
|
||
const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch');
|
||
prefix.append(el('span', { class: jointCls }));
|
||
return prefix;
|
||
}
|
||
|
||
function renderContainers(s) {
|
||
const root = $('containers-section');
|
||
// #containers-section only exists on /index.html. The same
|
||
// bundled app.js runs on /flow.html (which has the broker
|
||
// terminal but no container list) and on any future pages —
|
||
// `container_state_changed` SSE events arrive on every page and
|
||
// route through `applyContainerStateChanged → renderContainersFromState`,
|
||
// so without this guard the renderer throws `root is null` on
|
||
// every event (#399). Matches the no-op-when-target-absent
|
||
// convention the other renderers (renderTombstones, etc.) follow.
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
|
||
// Containers come from the derived map (event-driven) rather than
|
||
// `s.containers`; `s` still supplies hostname (for the web-ui
|
||
// link) and tombstones/meta_inputs (not event-derived yet). The
|
||
// tree builder handles the ordering — we don't pre-sort here.
|
||
const containers = Array.from(containersState.values());
|
||
const portConflicts = derivePortConflicts(containers);
|
||
const anyStale = containers.some((c) => c.needs_update);
|
||
|
||
// Port-hash collisions: rename one of the listed agents and
|
||
// rebuild. The banner sits above the agent list so it's the
|
||
// first thing the operator sees when something's wedged.
|
||
if (portConflicts.length) {
|
||
const banner = el('div', { class: 'port-conflict' },
|
||
el('strong', {}, '⚠ port collision'), ' — ');
|
||
const groups = portConflicts.map((c) =>
|
||
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
|
||
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
|
||
root.append(banner);
|
||
}
|
||
|
||
if (anyStale) {
|
||
root.append(form(
|
||
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
|
||
'rebuild every stale container?',
|
||
{}, { noRefresh: true },
|
||
));
|
||
}
|
||
|
||
if (transientsState.size) {
|
||
const ul = el('ul');
|
||
const nowUnix = Math.floor(Date.now() / 1000);
|
||
for (const [name, t] of transientsState) {
|
||
const secs = Math.max(0, nowUnix - t.since_unix);
|
||
ul.append(el('li', {},
|
||
el('span', { class: 'glyph spinner' }, '◐'), ' ',
|
||
el('span', { class: 'agent' }, name), ' ',
|
||
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
|
||
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
|
||
));
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
if (!containers.length && !transientsState.size) {
|
||
root.append(el('p', { class: 'empty' }, 'no managed containers'));
|
||
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);
|
||
// In-flight rebuild / meta-update / destroy ops per agent name.
|
||
// Surface them as "building..." style badges on the container
|
||
// card when no operator-initiated transient already covers the
|
||
// row (#398). Mara: the SW4RM tab showed an agent as stopped
|
||
// while SYST3M showed an active rebuild; cross-reference fixes
|
||
// that.
|
||
const inFlight = inFlightOpsByAgent();
|
||
for (const node of tree) {
|
||
const c = node.container;
|
||
const url = `http://${hostname}:${c.port}/`;
|
||
// Pending state is overlaid from the transient store first
|
||
// (operator-initiated spawn/destroy/rebuild — covers the
|
||
// create+start window where the container literally isn't up
|
||
// yet), then from the rebuild_queue (#398 — covers in-flight
|
||
// ops the worker is running even if no transient was set).
|
||
// `ContainerStateChanged` doesn't carry either signal.
|
||
const transientKind = transientsState.get(c.name)?.kind || null;
|
||
const op = !transientKind ? inFlight.get(c.name) : null;
|
||
const pending = transientKind
|
||
|| (op && (op.state === 'running'
|
||
? (op.kind === 'meta_update' ? 'meta-updating'
|
||
: op.kind === 'destroy' ? 'destroying'
|
||
: 'rebuilding')
|
||
: (op.kind === 'meta_update' ? 'meta-update queued'
|
||
: op.kind === 'destroy' ? 'destroy queued'
|
||
: 'rebuild queued')));
|
||
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
|
||
// row. Both are CSS-driven from the data attributes so the
|
||
// legacy flat layout (every container at depth 0) is bit-
|
||
// identical to today's render — no glyph, no indent.
|
||
if (node.depth > 0) li.dataset.depth = String(node.depth);
|
||
const prefix = treePrefixDom(node);
|
||
if (prefix) li.prepend(prefix);
|
||
|
||
// Full-height square agent icon, left of the card body. The
|
||
// icon is an <img> absolutely positioned inside a wrapper div:
|
||
// the div is the flex child and sizes itself via aspect-ratio +
|
||
// stretch, the <img> is out of flow so its load state — pending,
|
||
// loaded or broken — can never contribute intrinsic size or
|
||
// reflow the row. (issue #177)
|
||
//
|
||
// The icon points straight at the agent's `<url>/icon`. We don't
|
||
// guess whether the agent is reachable from the container row —
|
||
// we just let the <img> try, and if it actually fails to load
|
||
// (agent stopped, restarting, rebuilding — web server not
|
||
// answering) the error handler falls it back to the dimmed
|
||
// hyperhive mark (`/favicon.svg`, served by the dashboard
|
||
// itself, always reachable). (issues #195, #202)
|
||
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
|
||
// #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', () => {
|
||
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
|
||
iconImg.dataset.fallback = '1';
|
||
icon.classList.add('icon-unreachable');
|
||
iconImg.src = '/favicon.svg';
|
||
});
|
||
} else {
|
||
// Container stopped (#432) — skip the doomed `${url}icon` fetch
|
||
// and go straight to the dimmed hyperhive mark. Avoids a noisy
|
||
// failed request in the console + the brief broken-image flash.
|
||
icon.classList.add('icon-unreachable');
|
||
iconImg.src = '/favicon.svg';
|
||
}
|
||
// Card body: the three stacked content lines, right of the icon.
|
||
const body = el('div', { class: 'card-body' });
|
||
|
||
// ── identity ─────────────────────────────────────────────────
|
||
const head = el('div', { class: 'head' });
|
||
head.append(
|
||
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
|
||
el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
|
||
c.is_manager ? 'm1nd' : 'ag3nt'),
|
||
);
|
||
// Icon-only nav strip — populated async from `/api/agent/{name}/links`,
|
||
// a same-origin proxy that forwards the agent backend's own link list
|
||
// (stats / screen-if-gui / forge profile / agent-configs / extras).
|
||
// The agent backend is the single source of truth; no hardcoded link
|
||
// list here (issue #262). DOM-built — link strings come from the
|
||
// agent's process and must never reach the HTML parser.
|
||
const navStrip = el('span', { class: 'nav-strip' });
|
||
head.append(navStrip);
|
||
const forgeBase = `http://${hostname}:3000`;
|
||
const containerBase = `http://${hostname}:${c.port}`;
|
||
if (c.running) {
|
||
fetch(`/api/agent/${encodeURIComponent(c.name)}/links`)
|
||
.then((r) => (r.ok ? r.json() : []))
|
||
.then((links) => {
|
||
if (!Array.isArray(links)) return;
|
||
for (const lnk of links) {
|
||
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
|
||
: lnk.kind === 'external' ? (lnk.url || '')
|
||
: /* container */ containerBase + (lnk.url || '');
|
||
const a = el('a', {
|
||
class: 'nav-link',
|
||
href,
|
||
target: '_blank',
|
||
rel: 'noopener',
|
||
title: lnk.label || '',
|
||
});
|
||
// Plain text — agent-controlled strings stay out of innerHTML.
|
||
a.textContent = lnk.icon || lnk.label || '';
|
||
navStrip.append(a);
|
||
}
|
||
})
|
||
.catch(() => { /* graceful: agent down → no strip */ });
|
||
}
|
||
// Status / runtime badges. Pending transients always win
|
||
// (start / stop / restart / rebuild is in progress). Otherwise,
|
||
// when the container is stopped, surface a single `■ not
|
||
// running` badge; the backend has already cleared rate_limited /
|
||
// needs_login / ctx_tokens / status_text in that case (#432) so
|
||
// the rest of the chain is a no-op for stopped containers — but
|
||
// we still want SOME badge there so the row doesn't look empty.
|
||
if (pending) {
|
||
head.append(el('span', { class: 'pending-state' },
|
||
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
|
||
} else if (!c.running) {
|
||
head.append(el('span',
|
||
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
|
||
'■ not running'));
|
||
} else if (c.rate_limited) {
|
||
head.append(el('span',
|
||
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
|
||
'⊘ rate limited'));
|
||
} else if (c.needs_login) {
|
||
head.append(el('a',
|
||
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
|
||
'needs login →'));
|
||
}
|
||
if (c.needs_update) {
|
||
head.append(form(
|
||
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
|
||
'rebuild ' + c.name + '? hot-reloads the container.',
|
||
{}, { noRefresh: true },
|
||
));
|
||
}
|
||
head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
|
||
if (c.deployed_sha) {
|
||
head.append(el('span',
|
||
{ class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
|
||
`deployed:${c.deployed_sha}`));
|
||
}
|
||
if (c.pending_reminders && c.pending_reminders > 0) {
|
||
head.append(el('span',
|
||
{
|
||
class: 'badge badge-reminder',
|
||
title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
|
||
},
|
||
`⏰ ${c.pending_reminders}`));
|
||
}
|
||
if (c.ctx_tokens != null) {
|
||
const k = Math.round(c.ctx_tokens / 1000);
|
||
// Thresholds track the model's real context window when the
|
||
// backend supplies it; otherwise fall back to fixed constants.
|
||
const win = c.context_window_tokens;
|
||
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
|
||
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
|
||
const ctxClass = c.ctx_tokens >= warn ? 'badge-ctx-warn'
|
||
: c.ctx_tokens >= caution ? 'badge-ctx-caution'
|
||
: 'badge-ctx-ok';
|
||
const title = win != null
|
||
? `last turn context: ${c.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
|
||
+ `tokens (${Math.round((c.ctx_tokens / win) * 100)}% of the window)`
|
||
: `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`;
|
||
head.append(el('span',
|
||
{ class: `badge ${ctxClass}`, title },
|
||
`ctx·${k}k`));
|
||
}
|
||
body.append(head);
|
||
|
||
// ── agent status text ─────────────────────────────────────────
|
||
// Self-reported status (via set_status MCP tool) — only fresh
|
||
// while the harness is up. The backend already clears
|
||
// `status_text` on stopped containers (#432) so we can render
|
||
// unconditionally here: a stopped container simply has no
|
||
// `status_text` and skips this block naturally.
|
||
if (c.status_text) {
|
||
const nowUnix = Math.floor(Date.now() / 1000);
|
||
const ageStr = c.status_set_at != null
|
||
? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : '';
|
||
body.append(el('div', {
|
||
class: 'agent-status',
|
||
title: `agent self-reported status${ageStr}`,
|
||
},
|
||
el('span', { class: 'status-icon' }, '◈ '),
|
||
c.status_text,
|
||
el('span', { class: 'status-age' }, ageStr),
|
||
));
|
||
}
|
||
|
||
// 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' });
|
||
// Per-container journald viewer. Opens the side panel and
|
||
// fetches the last N lines; refresh re-fetches; unit selector
|
||
// narrows to the harness service (or empty = full machine).
|
||
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
||
drill.append(buildJournalTrigger(c.container, journalUnit));
|
||
// The hardcoded config-repo trigger and the agent-declared
|
||
// extras block both moved into the unified nav strip in the
|
||
// head row above (sourced from the agent backend via
|
||
// `/api/agent/{name}/links` — issue #262). Only the journald
|
||
// trigger stays here since it opens the side panel rather
|
||
// than a link.
|
||
body.append(drill);
|
||
|
||
li.append(icon, body);
|
||
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
|
||
// click opens the side panel and fetches the last N lines. Refresh
|
||
// re-fetches; the unit toggle switches between the harness service
|
||
// and the full machine journal.
|
||
function buildJournalTrigger(containerName, defaultUnit) {
|
||
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
|
||
'↳ logs · ' + containerName);
|
||
trigger.addEventListener('click', () => {
|
||
const body = el('div', { class: 'journal-body' });
|
||
const controls = el('div', { class: 'journal-controls' });
|
||
const unitSelect = el('select', { class: 'journal-unit' });
|
||
unitSelect.append(
|
||
el('option', { value: defaultUnit }, defaultUnit),
|
||
el('option', { value: '' }, '(full machine journal)'),
|
||
);
|
||
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
||
'↻ refresh');
|
||
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
||
let fetching = false;
|
||
async function fetchLogs() {
|
||
if (fetching) return;
|
||
fetching = true;
|
||
pre.textContent = 'fetching…';
|
||
const unit = unitSelect.value;
|
||
const params = new URLSearchParams({ lines: '500' });
|
||
if (unit) params.set('unit', unit);
|
||
try {
|
||
const resp = await fetch('/api/journal/' + containerName + '?' + params);
|
||
const text = await resp.text();
|
||
if (!resp.ok) {
|
||
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||
} else {
|
||
pre.textContent = text || '(empty)';
|
||
// Auto-scroll the panel to the newest lines on fresh fetch.
|
||
const sb = $('side-panel-body');
|
||
if (sb) sb.scrollTop = sb.scrollHeight;
|
||
}
|
||
} catch (err) {
|
||
pre.textContent = 'fetch failed: ' + err;
|
||
} finally {
|
||
fetching = false;
|
||
}
|
||
}
|
||
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
|
||
unitSelect.addEventListener('change', fetchLogs);
|
||
controls.append(unitSelect, refresh);
|
||
body.append(controls, pre);
|
||
Panel.open('logs · ' + containerName, body);
|
||
fetchLogs();
|
||
});
|
||
return trigger;
|
||
}
|
||
|
||
function renderTombstones(s) {
|
||
const root = $('tombstones-section');
|
||
// #tombstones-section only lives on /index.html (SYST3M tab);
|
||
// no-op on /flow.html and any other page that loads the shared
|
||
// bundle without the dashboard's tab panes (#399).
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
if (!s.tombstones || !s.tombstones.length) {
|
||
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
|
||
return;
|
||
}
|
||
const fmtBytes = (n) => {
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||
};
|
||
const fmtAge = (ts) => {
|
||
if (!ts) return '?';
|
||
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
|
||
if (d <= 0) return 'today';
|
||
if (d === 1) return '1 day ago';
|
||
return d + ' days ago';
|
||
};
|
||
const ul = el('ul', { class: 'containers' });
|
||
for (const t of s.tombstones) {
|
||
const li = el('li', { class: 'container-row tombstone' });
|
||
const head = el('div', { class: 'head' });
|
||
head.append(
|
||
el('span', { class: 'name' }, t.name),
|
||
el('span', { class: 'badge badge-muted' }, 'destroyed'),
|
||
);
|
||
if (t.has_creds) {
|
||
head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
|
||
}
|
||
head.append(el('span', { class: 'meta' },
|
||
`${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
|
||
li.append(head);
|
||
|
||
const actions = el('div', { class: 'actions' });
|
||
// Reuse the existing spawn form pattern via /request-spawn — operator
|
||
// can queue an approval that recreates the agent with the same name
|
||
// and reuses the kept state.
|
||
const respawn = el('form', {
|
||
method: 'POST', action: '/request-spawn',
|
||
class: 'inline', 'data-async': '',
|
||
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
|
||
});
|
||
respawn.append(
|
||
el('input', { type: 'hidden', name: 'name', value: t.name }),
|
||
el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
|
||
);
|
||
actions.append(respawn);
|
||
actions.append(form(
|
||
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
||
'PURGE ' + t.name + '? config history, claude creds, '
|
||
+ 'and notes are all WIPED. no undo.',
|
||
{}, { noRefresh: true },
|
||
));
|
||
li.append(actions);
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
// Derived question state — cold-loaded from /api/state, then mutated
|
||
// live by `question_added` / `question_resolved` dashboard events.
|
||
const QUESTION_HISTORY_LIMIT = 20;
|
||
const questionsState = { pending: [], history: [] };
|
||
function syncQuestionsFromSnapshot(s) {
|
||
questionsState.pending = (s.questions || []).slice();
|
||
questionsState.history = (s.question_history || []).slice();
|
||
}
|
||
function applyQuestionAdded(ev) {
|
||
if (questionsState.pending.some((q) => q.id === ev.id)) return;
|
||
questionsState.pending.push({
|
||
id: ev.id,
|
||
asker: ev.asker,
|
||
question: ev.question,
|
||
options: ev.options || [],
|
||
multi: !!ev.multi,
|
||
asked_at: ev.asked_at,
|
||
deadline_at: ev.deadline_at ?? null,
|
||
target: ev.target || null,
|
||
question_refs: ev.question_refs || [],
|
||
});
|
||
renderQuestions();
|
||
}
|
||
function applyQuestionResolved(ev) {
|
||
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
|
||
const existing = idx >= 0 ? questionsState.pending[idx] : null;
|
||
if (idx >= 0) questionsState.pending.splice(idx, 1);
|
||
// Idempotent: a snapshot re-sync (issue #163) can carry this same
|
||
// answered row in `question_history` while a live event also
|
||
// delivers it — guard the unshift so history can't double a row.
|
||
if (!questionsState.history.some((h) => h.id === ev.id)) {
|
||
questionsState.history.unshift({
|
||
id: ev.id,
|
||
asker: existing?.asker || '?',
|
||
question: existing?.question || '',
|
||
options: existing?.options || [],
|
||
multi: existing?.multi || false,
|
||
asked_at: existing?.asked_at || ev.answered_at,
|
||
answered_at: ev.answered_at,
|
||
answer: ev.answer,
|
||
answerer: ev.answerer,
|
||
target: existing?.target ?? ev.target ?? null,
|
||
question_refs: existing?.question_refs || [],
|
||
answer_refs: ev.answer_refs || [],
|
||
});
|
||
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
||
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
||
}
|
||
}
|
||
renderQuestions();
|
||
}
|
||
// Filter selection for the questions section. Persisted so the
|
||
// operator's preferred view (all / operator-targeted / peer)
|
||
// survives a reload.
|
||
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
|
||
function getQuestionsFilter() {
|
||
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
|
||
}
|
||
function setQuestionsFilter(v) {
|
||
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
|
||
renderQuestions();
|
||
}
|
||
function questionMatchesFilter(q, filter) {
|
||
if (filter === 'all') return true;
|
||
if (filter === 'operator') return !q.target;
|
||
if (filter === 'peer') return !!q.target;
|
||
// `agent:<name>` matches when the agent appears as asker OR target.
|
||
if (filter.startsWith('agent:')) {
|
||
const name = filter.slice('agent:'.length);
|
||
return q.asker === name || q.target === name;
|
||
}
|
||
return true;
|
||
}
|
||
function renderQuestions() {
|
||
const root = $('questions-section');
|
||
// #questions-section only lives on /index.html (Y3R C4LL tab);
|
||
// no-op on /flow.html etc. — the bundled app.js runs everywhere
|
||
// and `question_added` / `question_resolved` SSE events route
|
||
// through here (#399).
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||
const allPending = questionsState.pending;
|
||
const activeFilter = getQuestionsFilter();
|
||
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
|
||
|
||
// Filter chips. Always include `all` / `operator` / `peer`; add
|
||
// per-agent chips for any agent that appears as asker or target
|
||
// in the pending list so the operator can isolate a single
|
||
// thread without typing.
|
||
const participants = new Set();
|
||
for (const q of allPending) {
|
||
participants.add(q.asker);
|
||
if (q.target) participants.add(q.target);
|
||
}
|
||
const filterRow = el('div', { class: 'questions-filters' });
|
||
const mkChip = (value, label) => {
|
||
const b = el('button', {
|
||
type: 'button',
|
||
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
|
||
}, label);
|
||
b.addEventListener('click', () => setQuestionsFilter(value));
|
||
return b;
|
||
};
|
||
filterRow.append(
|
||
mkChip('all', `all · ${allPending.length}`),
|
||
mkChip('operator', '@operator'),
|
||
mkChip('peer', '@peer'),
|
||
);
|
||
for (const name of Array.from(participants).sort()) {
|
||
filterRow.append(mkChip('agent:' + name, '@' + name));
|
||
}
|
||
root.append(filterRow);
|
||
|
||
if (!pending.length) {
|
||
root.append(el('p', { class: 'empty' },
|
||
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
|
||
}
|
||
const ul = el('ul', { class: 'questions' });
|
||
for (const q of pending) {
|
||
const targetLabel = q.target || 'operator';
|
||
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
|
||
const head = el('div', { class: 'q-head' },
|
||
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
|
||
el('span', { class: 'msg-from' }, q.asker), ' ',
|
||
el('span', { class: 'msg-sep' }, '→'), ' ',
|
||
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
|
||
el('span', { class: 'msg-sep' }, 'asks:'),
|
||
);
|
||
if (q.deadline_at) {
|
||
// Tag the chip with its deadline so the global 1s ticker
|
||
// (set up just below this function) can refresh the text
|
||
// without re-rendering the whole questions section
|
||
// (issue #335).
|
||
const ttlEl = el('span', {
|
||
class: 'q-ttl', 'data-deadline': String(q.deadline_at),
|
||
});
|
||
ttlEl.textContent = formatTtl(
|
||
q.deadline_at - Math.floor(Date.now() / 1000),
|
||
);
|
||
head.append(' ', ttlEl);
|
||
}
|
||
const qBody = el('div', { class: 'q-body' });
|
||
appendLinkified(qBody, q.question, q.question_refs);
|
||
li.append(head, qBody);
|
||
const f = el('form', {
|
||
method: 'POST', action: '/answer-question/' + q.id,
|
||
class: 'qform', 'data-async': '', 'data-no-refresh': '',
|
||
});
|
||
const hasOptions = q.options && q.options.length;
|
||
const isMulti = !!q.multi && hasOptions;
|
||
const freeText = el('textarea', {
|
||
name: 'answer-free', rows: '2', autocomplete: 'off',
|
||
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
|
||
+ ' (shift+enter for newline)',
|
||
});
|
||
// Enter submits; shift+enter inserts a newline (textarea default).
|
||
freeText.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
f.requestSubmit();
|
||
}
|
||
});
|
||
const optionGroup = el('div', { class: 'q-options' });
|
||
if (hasOptions) {
|
||
for (const opt of q.options) {
|
||
const inputType = isMulti ? 'checkbox' : 'radio';
|
||
const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
|
||
const input = el('input', { type: inputType, name: 'choice', value: opt, id });
|
||
const label = el('label', { for: id }, ' ' + opt);
|
||
optionGroup.append(el('div', { class: 'q-option' }, input, label));
|
||
}
|
||
}
|
||
// On submit, build the final `answer` field from selected
|
||
// options + free-text, joined by ', '. This lets the operator
|
||
// pick options AND add free text in the same form.
|
||
f.addEventListener('submit', (ev) => {
|
||
const parts = [];
|
||
for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
|
||
parts.push(cb.value);
|
||
}
|
||
const ft = (freeText.value || '').trim();
|
||
if (ft) parts.push(ft);
|
||
const merged = parts.join(', ');
|
||
// Replace the existing hidden `answer` (if any) with the merged value.
|
||
const existing = f.querySelector('input[name="answer"]');
|
||
if (existing) existing.remove();
|
||
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
|
||
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
|
||
}, true);
|
||
if (hasOptions) f.append(optionGroup);
|
||
const buttons = el('div', { class: 'q-buttons' });
|
||
// On peer threads the operator's answer is an override —
|
||
// mark the button so it's clear what the click does (the
|
||
// backend permits it via OperatorQuestions::answer's
|
||
// answerer-auth rule).
|
||
const answerLabel = q.target
|
||
? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
|
||
: (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
|
||
buttons.append(
|
||
el('button', {
|
||
type: 'submit',
|
||
class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
|
||
title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
|
||
}, answerLabel),
|
||
);
|
||
f.append(
|
||
el('div', { class: 'q-free' }, freeText),
|
||
buttons,
|
||
);
|
||
li.append(f);
|
||
// Separate form so the cancel button doesn't get the answer
|
||
// merge-on-submit handler attached to the main form.
|
||
const cancelTargetLabel = q.target ? q.target : 'asker';
|
||
const cancelForm = el('form', {
|
||
method: 'POST', action: '/cancel-question/' + q.id,
|
||
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
|
||
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
|
||
+ '"[cancelled]" as the answer.',
|
||
});
|
||
cancelForm.append(
|
||
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
|
||
);
|
||
li.append(cancelForm);
|
||
ul.append(li);
|
||
}
|
||
if (pending.length) root.append(ul);
|
||
|
||
// Answered question history
|
||
const hist = questionsState.history;
|
||
if (hist.length) {
|
||
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
|
||
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
|
||
const hul = el('ul', { class: 'questions questions-answered' });
|
||
for (const q of hist) {
|
||
const targetLabel = q.target || 'operator';
|
||
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
|
||
const head = el('div', { class: 'q-head' },
|
||
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
|
||
el('span', { class: 'msg-from' }, q.asker), ' ',
|
||
el('span', { class: 'msg-sep' }, '→'), ' ',
|
||
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
|
||
el('span', { class: 'msg-sep' }, 'asked:'),
|
||
);
|
||
const histBody = el('div', { class: 'q-body' });
|
||
appendLinkified(histBody, q.question, q.question_refs);
|
||
const ansText = el('span', { class: 'q-answer-text' });
|
||
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
|
||
const ansLine = el('div', { class: 'q-answer' },
|
||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||
ansText,
|
||
);
|
||
li.append(head, histBody, ansLine);
|
||
hul.append(li);
|
||
}
|
||
details.append(hul);
|
||
root.append(details);
|
||
}
|
||
}
|
||
|
||
// Format a remaining-seconds count as the `⏳ …` TTL chip text on a
|
||
// question card. Bucketed at minutes / hours so a long deadline stays
|
||
// readable; "expiring…" once the deadline has passed (the host-side
|
||
// ttl-watchdog will fire shortly).
|
||
function formatTtl(remaining) {
|
||
if (remaining <= 0) return 'expiring…';
|
||
if (remaining < 60) return '⏳ ' + remaining + 's';
|
||
if (remaining < 3600) {
|
||
return '⏳ ' + Math.floor(remaining / 60) + 'm '
|
||
+ (remaining % 60) + 's';
|
||
}
|
||
return '⏳ ' + Math.floor(remaining / 3600) + 'h '
|
||
+ Math.floor((remaining % 3600) / 60) + 'm';
|
||
}
|
||
|
||
// Single page-wide ticker that refreshes every TTL chip in place
|
||
// each second (issue #335). Renderers stamp `data-deadline` on the
|
||
// chip; this just updates `textContent`, no re-render of the
|
||
// questions section. No-op when no chips are on screen, so the
|
||
// cost is negligible.
|
||
setInterval(() => {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => {
|
||
const deadline = Number(node.getAttribute('data-deadline'));
|
||
if (!Number.isFinite(deadline)) return;
|
||
node.textContent = formatTtl(deadline - now);
|
||
});
|
||
}, 1000);
|
||
|
||
// Operator-inbox derived store moved to ./flow.js (#406 step 2).
|
||
|
||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||
// Derived approval state — cold-loaded from /api/state, then mutated
|
||
// live by `approval_added` / `approval_resolved` dashboard events.
|
||
// `pending` is the open queue (newest-first); `history` is the last
|
||
// 30 resolved rows.
|
||
const APPROVAL_HISTORY_LIMIT = 30;
|
||
const approvalsState = { pending: [], history: [] };
|
||
function syncApprovalsFromSnapshot(s) {
|
||
approvalsState.pending = (s.approvals || []).slice();
|
||
approvalsState.history = (s.approval_history || []).slice();
|
||
}
|
||
function applyApprovalAdded(ev) {
|
||
// Upsert by id so a snapshot that already included the row (cold
|
||
// load + event lands at the same tick) doesn't double it.
|
||
const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
|
||
const row = {
|
||
id: ev.id,
|
||
agent: ev.agent,
|
||
kind: ev.approval_kind,
|
||
sha_short: ev.sha_short || null,
|
||
diff: ev.diff || null,
|
||
description: ev.description || null,
|
||
// The ApprovalAdded event carries no requested_at; a live-added
|
||
// approval was queued just now, so client-now is accurate — and
|
||
// consistent with how fmtAgo compares everything to client-now.
|
||
// A later /api/state cold-load swaps in the server value. (#272)
|
||
requested_at: ev.requested_at != null
|
||
? ev.requested_at : Math.floor(Date.now() / 1000),
|
||
};
|
||
if (existing >= 0) approvalsState.pending[existing] = row;
|
||
else approvalsState.pending.push(row);
|
||
renderApprovals();
|
||
}
|
||
function applyApprovalResolved(ev) {
|
||
// Drop from pending; prepend to history (newest-first), cap at 30.
|
||
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
|
||
// Idempotent: a snapshot re-sync (issue #163) can carry this same
|
||
// resolved row in `approval_history` while a live event also
|
||
// delivers it — guard the unshift so history can't double a row.
|
||
if (!approvalsState.history.some((h) => h.id === ev.id)) {
|
||
approvalsState.history.unshift({
|
||
id: ev.id,
|
||
agent: ev.agent,
|
||
kind: ev.approval_kind,
|
||
sha_short: ev.sha_short || null,
|
||
status: ev.status,
|
||
resolved_at: ev.resolved_at,
|
||
note: ev.note || null,
|
||
description: ev.description || null,
|
||
});
|
||
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
|
||
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
|
||
}
|
||
}
|
||
renderApprovals();
|
||
}
|
||
// Classify each unified-diff line by its leading char so
|
||
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
|
||
// `.diff-ctx` colour the output. Built as text-only spans (no
|
||
// innerHTML) so there's no HTML-escape surface.
|
||
function buildDiffPre(text) {
|
||
const pre = el('pre', { class: 'diff' });
|
||
for (const raw of String(text).split('\n')) {
|
||
let cls = 'diff-ctx';
|
||
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
|
||
else if (raw.startsWith('@')) cls = 'diff-hunk';
|
||
else if (raw.startsWith('+')) cls = 'diff-add';
|
||
else if (raw.startsWith('-')) cls = 'diff-del';
|
||
const span = document.createElement('span');
|
||
span.className = cls;
|
||
span.textContent = raw + '\n';
|
||
pre.appendChild(span);
|
||
}
|
||
return pre;
|
||
}
|
||
|
||
// Open an approval's diff in the side panel with a 3-way base
|
||
// toggle: vs applied (running tree), vs last-approved, vs previous
|
||
// proposal. `applied` uses the diff already shipped on the approval
|
||
// for instant paint; the other two fetch /api/approval-diff.
|
||
function openDiffPanel(a) {
|
||
const bases = [
|
||
['applied', 'vs applied'],
|
||
['approved', 'vs last-approved'],
|
||
['previous', 'vs previous proposal'],
|
||
];
|
||
const tabs = el('div', { class: 'diff-base-tabs' });
|
||
const host = el('div', { class: 'diff-host' });
|
||
async function selectBase(base) {
|
||
for (const btn of tabs.children) {
|
||
btn.classList.toggle('active', btn.dataset.base === base);
|
||
}
|
||
if (base === 'applied' && a.diff != null) {
|
||
host.replaceChildren(buildDiffPre(a.diff));
|
||
return;
|
||
}
|
||
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
|
||
try {
|
||
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
|
||
const text = await resp.text();
|
||
host.replaceChildren(resp.ok
|
||
? buildDiffPre(text)
|
||
: el('div', { class: 'meta' }, 'error: ' + text));
|
||
} catch (e) {
|
||
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
|
||
}
|
||
}
|
||
for (const [base, label] of bases) {
|
||
const btn = el('button',
|
||
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
|
||
btn.addEventListener('click', () => selectBase(base));
|
||
tabs.append(btn);
|
||
}
|
||
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
|
||
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
|
||
selectBase('applied');
|
||
}
|
||
|
||
function renderApprovals() {
|
||
const root = $('approvals-section');
|
||
// #approvals-section only lives on /index.html (Y3R C4LL tab);
|
||
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
|
||
// events route through here on every page that loads the bundle
|
||
// (#399).
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
|
||
// Spawn request form: submitting it queues a Spawn approval that
|
||
// lands in this same list, so the form belongs here rather than on
|
||
// the containers list (the agent doesn't exist yet).
|
||
const spawn = el('form', {
|
||
method: 'POST', action: '/request-spawn',
|
||
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
|
||
});
|
||
spawn.append(
|
||
el('input', {
|
||
name: 'name',
|
||
placeholder: 'new agent name (≤9 chars)',
|
||
maxlength: '9', required: '', autocomplete: 'off',
|
||
}),
|
||
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
|
||
);
|
||
root.append(spawn);
|
||
|
||
const pending = approvalsState.pending;
|
||
const history = approvalsState.history;
|
||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
||
const tabs = el('div', { class: 'approval-tabs' });
|
||
const pendingTab = el(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
|
||
},
|
||
`pending · ${pending.length}`,
|
||
);
|
||
const historyTab = el(
|
||
'button',
|
||
{
|
||
type: 'button',
|
||
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
|
||
},
|
||
`history · ${history.length}`,
|
||
);
|
||
pendingTab.addEventListener('click', () => {
|
||
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
|
||
renderApprovals();
|
||
});
|
||
historyTab.addEventListener('click', () => {
|
||
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
|
||
renderApprovals();
|
||
});
|
||
tabs.append(pendingTab, historyTab);
|
||
root.append(tabs);
|
||
|
||
if (active === 'history') {
|
||
renderApprovalHistory(root, history);
|
||
return;
|
||
}
|
||
|
||
if (!pending.length) {
|
||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||
return;
|
||
}
|
||
// forge link base — only when the hive-forge container is up.
|
||
const fs = window.__hyperhive_state;
|
||
const hostname = (fs && fs.hostname) || window.location.hostname;
|
||
const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null;
|
||
|
||
const ul = el('ul', { class: 'approvals' });
|
||
for (const a of pending) {
|
||
const isApply = a.kind === 'apply_commit';
|
||
const isInit = a.kind === 'init_config';
|
||
const li = el('li', { class: 'approval-card' });
|
||
|
||
// ── identity header ──────────────────────────────────────────
|
||
const head = el('div', { class: 'approval-head' },
|
||
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
|
||
el('span', { class: 'id' }, '#' + a.id),
|
||
el('span', { class: 'agent' }, a.agent),
|
||
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
|
||
isApply ? 'apply' : isInit ? 'init' : 'spawn'),
|
||
);
|
||
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
|
||
// When the approval was requested — relative time, right-aligned.
|
||
// Goes amber once it's been pending an hour so a stale request is
|
||
// obvious at a glance. (issue #272)
|
||
if (a.requested_at != null) {
|
||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
|
||
head.append(el('span', {
|
||
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
|
||
title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
|
||
}, 'requested ' + fmtAgo(a.requested_at)));
|
||
}
|
||
li.append(head);
|
||
|
||
// ── what-changed body ────────────────────────────────────────
|
||
const body = el('div', { class: 'approval-body' });
|
||
if (a.description) {
|
||
body.append(el('div', { class: 'approval-description' }, a.description));
|
||
}
|
||
if (isApply) {
|
||
const drill = el('div', { class: 'drill-ins' });
|
||
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
|
||
'↳ view diff');
|
||
diffBtn.addEventListener('click', () => openDiffPanel(a));
|
||
drill.append(diffBtn);
|
||
if (forgeBase && a.sha_short) {
|
||
drill.append(el('a', {
|
||
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
|
||
title: 'this proposal commit on the hive forge',
|
||
}, '↳ commit on forge ↗'));
|
||
}
|
||
body.append(drill);
|
||
} else {
|
||
body.append(el('span', { class: 'meta' },
|
||
isInit
|
||
? 'scaffold proposed config repo — manager customises agent.nix before spawn'
|
||
: 'new sub-agent — container will be created on approve'));
|
||
}
|
||
li.append(body);
|
||
|
||
// ── decision actions ─────────────────────────────────────────
|
||
// Deny prompts the operator for an optional reason; the submit
|
||
// handler stashes it into a hidden `note` input that rides along
|
||
// on the POST and is surfaced to the manager via
|
||
// HelperEvent::ApprovalResolved { note }.
|
||
const denyForm = el('form', {
|
||
method: 'POST', action: '/deny/' + a.id,
|
||
class: 'inline', 'data-async': '', 'data-no-refresh': '',
|
||
'data-prompt': 'reason for denying (optional, sent to manager):',
|
||
});
|
||
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||
li.append(el('div', { class: 'approval-actions' },
|
||
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
||
denyForm,
|
||
));
|
||
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
function renderApprovalHistory(root, history) {
|
||
if (!history.length) {
|
||
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
|
||
return;
|
||
}
|
||
const ul = el('ul', { class: 'approvals approvals-history' });
|
||
for (const a of history) {
|
||
const li = el('li');
|
||
const row = el('div', { class: 'row' });
|
||
const glyph = a.status === 'approved' ? '✓'
|
||
: a.status === 'denied' ? '✗'
|
||
: '⚠';
|
||
row.append(
|
||
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
|
||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||
el('span', { class: 'agent' }, a.agent), ' ',
|
||
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : 'spawn'), ' ',
|
||
);
|
||
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
|
||
row.append(
|
||
el('span', { class: 'status status-' + a.status }, a.status), ' ',
|
||
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
|
||
);
|
||
li.append(row);
|
||
if (a.note) {
|
||
li.append(el('div', { class: 'history-note' }, a.note));
|
||
}
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
// Relative time, anchored to now. resolved_at is unix seconds (server-
|
||
// authored), so we don't have to worry about client/server clock skew
|
||
// for sub-minute precision.
|
||
function fmtAgo(unixSecs) {
|
||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
|
||
if (ageSec < 60) return ageSec + 's ago';
|
||
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
|
||
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
|
||
return Math.floor(ageSec / 86400) + 'd ago';
|
||
}
|
||
|
||
function renderMetaInputs(s) {
|
||
const root = $('meta-inputs-section');
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
const inputs = s.meta_inputs || [];
|
||
if (!inputs.length) {
|
||
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
|
||
return;
|
||
}
|
||
if (metaUpdateRunning) {
|
||
root.append(el('p', { class: 'meta-update-running' },
|
||
'⏳ meta-update running — flake lock bump + affected agents rebuilding. '
|
||
+ 'watch the agent cards for per-rebuild progress.'));
|
||
}
|
||
const form = el('form', {
|
||
method: 'POST',
|
||
action: '/meta-update',
|
||
class: 'meta-inputs-form',
|
||
'data-async': '',
|
||
// run_meta_update emits MetaInputsChanged once the lock
|
||
// bump finishes; per-agent rebuilds fire their own
|
||
// ContainerStateChanged. No /api/state refetch needed.
|
||
'data-no-refresh': '',
|
||
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
|
||
});
|
||
// Bulk select — the full input tree gets long; ticking each box
|
||
// one by one is tedious (issue #275).
|
||
const bulk = el('div', { class: 'meta-inputs-bulk' });
|
||
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
|
||
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
|
||
bulk.append('bulk: ', selAll, ' ', selNone);
|
||
form.append(bulk);
|
||
const ul = el('ul', { class: 'meta-inputs' });
|
||
for (const inp of inputs) {
|
||
// `name` is a slash-path from the meta root. Indent depth = its
|
||
// segment count; the row label shows just the leaf segment, the
|
||
// full path stays as the checkbox value + the label title.
|
||
const depth = (inp.name.match(/\//g) || []).length;
|
||
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
|
||
const li = el('li');
|
||
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
|
||
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
|
||
const cb = el('input', {
|
||
type: 'checkbox',
|
||
name: 'meta_input_' + inp.name,
|
||
id,
|
||
value: inp.name,
|
||
'data-meta-input': inp.name,
|
||
});
|
||
const label = el('label', { for: id, title: inp.name });
|
||
label.append(cb);
|
||
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
|
||
label.append(
|
||
el('span', { class: 'meta-input-name' }, leaf), ' ',
|
||
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
|
||
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
|
||
);
|
||
if (inp.url) {
|
||
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
|
||
'· ' + truncate(inp.url, 48)));
|
||
}
|
||
li.append(label);
|
||
ul.append(li);
|
||
}
|
||
form.append(ul);
|
||
// Hidden input the POST handler reads — populated at submit
|
||
// time from the checkbox states. axum's Form extractor doesn't
|
||
// natively decode repeated keys, so we join into one CSV.
|
||
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
|
||
form.append(hidden);
|
||
const btn = el('button', {
|
||
type: 'submit',
|
||
class: 'btn btn-meta-update',
|
||
disabled: '',
|
||
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
|
||
form.append(btn);
|
||
function refreshDisabled() {
|
||
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
|
||
// Stay disabled while an update is already in flight — no
|
||
// stacking a second run on top of the rebuild ripple.
|
||
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
|
||
else btn.setAttribute('disabled', '');
|
||
}
|
||
form.addEventListener('change', refreshDisabled);
|
||
function setAllChecked(val) {
|
||
for (const b of form.querySelectorAll('input[data-meta-input]')) {
|
||
b.checked = val;
|
||
}
|
||
refreshDisabled();
|
||
}
|
||
selAll.addEventListener('click', () => setAllChecked(true));
|
||
selNone.addEventListener('click', () => setAllChecked(false));
|
||
form.addEventListener('submit', () => {
|
||
const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
|
||
.map((b) => b.dataset.metaInput);
|
||
hidden.value = selected.join(',');
|
||
});
|
||
root.append(form);
|
||
}
|
||
|
||
function truncate(s, n) {
|
||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||
}
|
||
|
||
// ─── rebuild queue ──────────────────────────────────────────────────────
|
||
// Glyph + verb per QueueKind. Mirrors the labels used in
|
||
// hive-c0re::rebuild_queue::QueueKind::as_str.
|
||
const QUEUE_KIND_GLYPH = {
|
||
rebuild: '↻',
|
||
meta_update: '◆',
|
||
spawn: '✨',
|
||
destroy: '🗑',
|
||
};
|
||
const QUEUE_STATE_GLYPH = {
|
||
queued: '⏸',
|
||
running: '▶',
|
||
done: '✔',
|
||
failed: '✖',
|
||
cancelled: '⊘',
|
||
};
|
||
|
||
function renderRebuildQueue(s) {
|
||
const root = $('rebuild-queue-section');
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
const queue = s.rebuild_queue || [];
|
||
if (!queue.length) {
|
||
root.append(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
|
||
return;
|
||
}
|
||
// Index by id for parent lookup.
|
||
const byId = new Map(queue.map((e) => [e.id, e]));
|
||
// Top-level entries first; children render nested under their parent.
|
||
const tops = queue.filter((e) => e.parent_id == null);
|
||
const childrenOf = new Map();
|
||
for (const e of queue) {
|
||
if (e.parent_id != null) {
|
||
if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
|
||
childrenOf.get(e.parent_id).push(e);
|
||
}
|
||
}
|
||
const ul = el('ul', { class: 'rebuild-queue' });
|
||
for (const top of tops) {
|
||
ul.append(renderQueueEntry(top, byId));
|
||
for (const child of childrenOf.get(top.id) || []) {
|
||
ul.append(renderQueueEntry(child, byId, true));
|
||
}
|
||
}
|
||
// Children whose parent isn't in the snapshot (history-evicted) still render flat.
|
||
const orphans = queue.filter(
|
||
(e) => e.parent_id != null && !byId.has(e.parent_id),
|
||
);
|
||
for (const o of orphans) {
|
||
ul.append(renderQueueEntry(o, byId, true));
|
||
}
|
||
root.append(ul);
|
||
}
|
||
|
||
function renderQueueEntry(entry, _byId, isChild) {
|
||
const li = el('li', {
|
||
class: 'rebuild-queue-entry rqe-' + entry.state,
|
||
'data-id': String(entry.id),
|
||
});
|
||
if (isChild) li.classList.add('rqe-child');
|
||
// State glyph + kind + agent.
|
||
li.append(
|
||
el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
|
||
' ',
|
||
el('span', { class: 'rqe-kind', title: entry.kind },
|
||
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
|
||
' ',
|
||
el('code', { class: 'rqe-agent' }, entry.agent),
|
||
);
|
||
// Source chip (manual / meta_update / auto_update / crash_recover).
|
||
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
|
||
// Timing: queued Xs ago when pending, elapsed when running,
|
||
// finished Xs ago for terminal.
|
||
if (entry.state === 'queued') {
|
||
li.append(' ', el('span', { class: 'rqe-when' }, '· queued ' + fmtAgo(entry.enqueued_at)));
|
||
} else if (entry.state === 'running' && entry.started_at) {
|
||
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
|
||
li.append(' ', el('span', {
|
||
class: 'rqe-when',
|
||
'data-rqe-elapsed': String(entry.started_at),
|
||
}, '· ' + fmtElapsed(elapsed)));
|
||
} else if (entry.finished_at) {
|
||
li.append(' ', el('span', { class: 'rqe-when' }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
|
||
}
|
||
// Reason (truncated; full text on hover).
|
||
if (entry.reason) {
|
||
const r = entry.reason.split('\n')[0];
|
||
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
|
||
}
|
||
// Error block, when failed.
|
||
if (entry.error) {
|
||
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
|
||
}
|
||
return li;
|
||
}
|
||
|
||
function fmtElapsed(secs) {
|
||
if (secs < 60) return secs + 's running';
|
||
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
|
||
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
|
||
}
|
||
|
||
// Tick once per second to refresh "running Xs" badges in place
|
||
// (mirrors the question-TTL ticker pattern from #335).
|
||
setInterval(() => {
|
||
for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
|
||
const started = parseInt(span.dataset.rqeElapsed, 10);
|
||
if (!started) continue;
|
||
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - started));
|
||
span.textContent = '· ' + fmtElapsed(elapsed);
|
||
}
|
||
}, 1000);
|
||
|
||
// ─── reminders ──────────────────────────────────────────────────────────
|
||
// Reminders aren't part of /api/state (separate sqlite table, separate
|
||
// mutation cadence). Refresh fires alongside refreshState() so a
|
||
// cancel POST or a cold load both reflect within the same tick. A
|
||
// periodic poll isn't necessary — new reminders are queued by the
|
||
// agents themselves and the operator already sees them next time
|
||
// they interact with the page.
|
||
async function refreshReminders() {
|
||
const root = $('reminders-section');
|
||
if (!root) return;
|
||
try {
|
||
const resp = await fetch('/api/reminders');
|
||
if (!resp.ok) {
|
||
root.innerHTML = '';
|
||
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
|
||
return;
|
||
}
|
||
const rows = await resp.json();
|
||
renderReminders(rows);
|
||
} catch (err) {
|
||
root.innerHTML = '';
|
||
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
|
||
}
|
||
}
|
||
function renderReminders(rows) {
|
||
const root = $('reminders-section');
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
if (!rows.length) {
|
||
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
|
||
return;
|
||
}
|
||
const ul = el('ul', { class: 'reminders' });
|
||
for (const r of rows) {
|
||
const failed = (r.attempt_count || 0) > 0;
|
||
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
|
||
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
|
||
const dueLabel = dueIn <= 0
|
||
? `overdue ${fmtAgo(r.due_at)}`
|
||
: `in ${fmtDuration(dueIn)}`;
|
||
const head = el('div', { class: 'reminder-head' },
|
||
el('span', { class: 'agent' }, r.agent), ' ',
|
||
el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
|
||
' ',
|
||
el('span', { class: 'meta' }, `· id ${r.id}`),
|
||
);
|
||
if (r.file_path) {
|
||
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
|
||
appendLinkified(head, r.file_path);
|
||
}
|
||
if (failed) {
|
||
head.append(' ', el('span',
|
||
{
|
||
class: 'badge badge-warn',
|
||
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
|
||
},
|
||
`⚠ ${r.attempt_count} failed`));
|
||
}
|
||
const body = el('div', { class: 'reminder-body' });
|
||
appendLinkified(body, r.message);
|
||
li.append(head, body);
|
||
if (r.last_error) {
|
||
li.append(el('div', { class: 'reminder-error' },
|
||
el('span', { class: 'msg-sep' }, 'error: '),
|
||
r.last_error,
|
||
));
|
||
}
|
||
const actions = el('div', { class: 'reminder-actions' });
|
||
if (failed) {
|
||
// Retry resets the failure counters so the scheduler picks
|
||
// the row up again on its next 5s tick. No data-no-refresh
|
||
// — the resulting refreshState re-fires refreshReminders.
|
||
const retryForm = el('form', {
|
||
method: 'POST', action: '/retry-reminder/' + r.id,
|
||
class: 'inline', 'data-async': '',
|
||
});
|
||
retryForm.append(el('button',
|
||
{ type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
|
||
actions.append(retryForm);
|
||
}
|
||
const cancelForm = el('form', {
|
||
method: 'POST', action: '/cancel-reminder/' + r.id,
|
||
class: 'inline', 'data-async': '',
|
||
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
|
||
});
|
||
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
|
||
actions.append(cancelForm);
|
||
li.append(actions);
|
||
ul.append(li);
|
||
}
|
||
root.append(ul);
|
||
}
|
||
function fmtDuration(secs) {
|
||
if (secs < 60) return secs + 's';
|
||
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
|
||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
|
||
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
|
||
}
|
||
|
||
// ─── scheduled prompts (#459) ──────────────────────────────────────────
|
||
// Backend (#444) exposes `/api/schedules` (snapshot), `/api/schedules`
|
||
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
|
||
// (whole or per-target). No SSE channel for schedule mutations yet,
|
||
// so we refresh on tab activation + after every submit/cancel POST.
|
||
// Local cache lets `refreshTabCounts` show the active count without
|
||
// re-fetching every second.
|
||
let schedulesState = [];
|
||
// #474 — schedule ids whose inline edit form is currently open.
|
||
// The set survives across refreshSchedules() calls so a state
|
||
// poll doesn't yank the form out from under the operator. Per-id
|
||
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
|
||
// also rides the refresh.
|
||
const editingSchedules = new Set();
|
||
const scheduleEditCarry = new Map();
|
||
async function refreshSchedules() {
|
||
const listRoot = $('schedules-section');
|
||
if (!listRoot) return;
|
||
try {
|
||
const resp = await fetch('/api/schedules');
|
||
if (!resp.ok) {
|
||
listRoot.innerHTML = '';
|
||
listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
|
||
return;
|
||
}
|
||
schedulesState = await resp.json();
|
||
} catch (err) {
|
||
listRoot.innerHTML = '';
|
||
listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
|
||
return;
|
||
}
|
||
renderScheduleNewForm();
|
||
renderSchedulesList();
|
||
}
|
||
// Active = at least one target still alive (no `cancelled_at_unix`)
|
||
// AND the whole schedule isn't cancelled. Drives the tab pill count.
|
||
function activeScheduleCount() {
|
||
let n = 0;
|
||
for (const s of schedulesState) {
|
||
if (s.cancelled_at_unix) continue;
|
||
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
|
||
}
|
||
return n;
|
||
}
|
||
// Interval composer (#466) — shared between the new-schedule form
|
||
// and the edit-schedule form (#474). Builds the preset chip row +
|
||
// d/h/m/s sub-fields + live preview, and returns helpers to read
|
||
// and write the value.
|
||
//
|
||
// The composer carries no opinion on what "0 / blank" means at the
|
||
// semantic layer: it just reports the integer total. Callers decide
|
||
// whether to treat 0 as `null` (one-shot) for their own submit path.
|
||
function buildIntervalComposer({
|
||
label = 'interval (blank / all-zero = one-shot)',
|
||
namePrefix = 'interval_',
|
||
initialSeconds = 0,
|
||
} = {}) {
|
||
const wrapper = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, label));
|
||
const presets = [
|
||
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
|
||
['1h', 3600], ['6h', 21600], ['12h', 43200],
|
||
['1d', 86400], ['7d', 604800],
|
||
];
|
||
const presetsRow = el('div', { class: 'schedule-interval-presets' });
|
||
for (const [presetLabel, secs] of presets) {
|
||
presetsRow.append(el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-interval-preset',
|
||
'data-secs': String(secs),
|
||
}, presetLabel));
|
||
}
|
||
presetsRow.append(el('button', {
|
||
type: 'button',
|
||
class: 'btn btn-interval-preset btn-interval-oneshot',
|
||
'data-secs': '0',
|
||
}, 'one-shot'));
|
||
wrapper.append(presetsRow);
|
||
|
||
const partsRow = el('div', { class: 'schedule-interval-parts' });
|
||
const mkPart = (suffix, unit) => {
|
||
const inp = el('input', {
|
||
type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0',
|
||
class: 'schedule-interval-num',
|
||
});
|
||
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
|
||
return inp;
|
||
};
|
||
const dInp = mkPart('d', 'd');
|
||
const hInp = mkPart('h', 'h');
|
||
const mInp = mkPart('m', 'm');
|
||
const sInp = mkPart('s', 's');
|
||
wrapper.append(partsRow);
|
||
|
||
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
|
||
wrapper.append(preview);
|
||
|
||
function getSeconds() {
|
||
const n = (v) => {
|
||
const x = parseInt(v, 10);
|
||
return Number.isFinite(x) && x > 0 ? x : 0;
|
||
};
|
||
return n(dInp.value) * 86400 + n(hInp.value) * 3600
|
||
+ n(mInp.value) * 60 + n(sInp.value);
|
||
}
|
||
function fillFromSeconds(total) {
|
||
const t = Math.max(0, Math.floor(total));
|
||
const d = Math.floor(t / 86400);
|
||
const h = Math.floor((t % 86400) / 3600);
|
||
const m = Math.floor((t % 3600) / 60);
|
||
const s = t % 60;
|
||
dInp.value = d > 0 ? String(d) : '';
|
||
hInp.value = h > 0 ? String(h) : '';
|
||
mInp.value = m > 0 ? String(m) : '';
|
||
sInp.value = s > 0 ? String(s) : '';
|
||
}
|
||
function setParts({ d, h, m, s }) {
|
||
if (d !== undefined) dInp.value = d;
|
||
if (h !== undefined) hInp.value = h;
|
||
if (m !== undefined) mInp.value = m;
|
||
if (s !== undefined) sInp.value = s;
|
||
}
|
||
function updatePreview() {
|
||
const total = getSeconds();
|
||
preview.textContent = total > 0
|
||
? '↻ every ' + fmtDuration(total)
|
||
: 'one-shot (fires once at first-fire time)';
|
||
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
|
||
}
|
||
for (const inp of [dInp, hInp, mInp, sInp]) {
|
||
inp.addEventListener('input', updatePreview);
|
||
}
|
||
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
|
||
b.addEventListener('click', () => {
|
||
fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0);
|
||
updatePreview();
|
||
});
|
||
}
|
||
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
|
||
updatePreview();
|
||
|
||
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
|
||
}
|
||
|
||
function renderScheduleNewForm() {
|
||
const root = $('schedule-new-section');
|
||
if (!root) return;
|
||
// Preserve any field the operator was mid-typing in by reading
|
||
// current values before we re-render. Only `body` + `description`
|
||
// are big enough to feel — the others are toggle/datetime/number.
|
||
const carry = readScheduleFormCarry(root);
|
||
root.innerHTML = '';
|
||
|
||
// Targets multi-select pulls from the live containers list. The
|
||
// backend accepts any string (forward-compat for richer recipients),
|
||
// so we also surface the special `operator` and `manager` names
|
||
// as fixed options on top.
|
||
const candidateTargets = ['operator', 'manager'];
|
||
const containerNames = Array.from(containersState.values())
|
||
.map((c) => c.name)
|
||
.filter((n) => n !== 'manager' && n !== 'operator')
|
||
.sort();
|
||
for (const n of containerNames) candidateTargets.push(n);
|
||
|
||
const form_ = el('form', { class: 'schedule-new-form' });
|
||
form_.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
submitNewSchedule(form_);
|
||
});
|
||
|
||
const targetsLabel = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, 'targets'));
|
||
const targetsBox = el('div', { class: 'schedule-targets' });
|
||
const restoredTargets = new Set(carry.targets);
|
||
for (const name of candidateTargets) {
|
||
const id_ = 'st-' + name;
|
||
const cb = el('input', {
|
||
type: 'checkbox', name: 'targets', value: name, id: id_,
|
||
});
|
||
if (restoredTargets.has(name)) cb.checked = true;
|
||
targetsBox.append(el('label', { class: 'schedule-target-chip', for: id_ },
|
||
cb, el('span', {}, name)));
|
||
}
|
||
targetsLabel.append(targetsBox);
|
||
form_.append(targetsLabel);
|
||
|
||
const bodyLabel = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, 'prompt body'));
|
||
const bodyInput = el('textarea', {
|
||
name: 'body', rows: '4', required: 'required', placeholder: 'message delivered to each target at fire time',
|
||
});
|
||
bodyInput.value = carry.body;
|
||
bodyLabel.append(bodyInput);
|
||
form_.append(bodyLabel);
|
||
|
||
const firstFireLabel = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, 'first fire'));
|
||
const firstFireInput = el('input', {
|
||
type: 'datetime-local', name: 'first_fire', required: 'required',
|
||
});
|
||
// Default to "5 minutes from now" so the operator has a sensible
|
||
// pre-filled value (also future-positive so worker doesn't fire
|
||
// immediately on a stale-clock accident).
|
||
firstFireInput.value = carry.first_fire
|
||
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
|
||
firstFireLabel.append(firstFireInput);
|
||
form_.append(firstFireLabel);
|
||
|
||
// Interval composer (#466). See `buildIntervalComposer` below —
|
||
// shared between the new-schedule form and the edit-schedule form
|
||
// (#474) so the same chip/d/h/m/s widget works for both flows.
|
||
const intervalCx = buildIntervalComposer({
|
||
label: 'interval (blank / all-zero = one-shot)',
|
||
namePrefix: 'interval_',
|
||
initialSeconds: 0,
|
||
});
|
||
form_.append(intervalCx.wrapper);
|
||
// Restore from carry. Prefer the new split fields; fall back to
|
||
// legacy single seconds value if the page was reloaded mid-edit
|
||
// against an older bundle.
|
||
if (carry.interval_d || carry.interval_h
|
||
|| carry.interval_m || carry.interval_s) {
|
||
intervalCx.setParts({
|
||
d: carry.interval_d, h: carry.interval_h,
|
||
m: carry.interval_m, s: carry.interval_s,
|
||
});
|
||
} else if (carry.interval) {
|
||
const legacy = parseInt(carry.interval, 10);
|
||
if (Number.isFinite(legacy) && legacy > 0) intervalCx.fillFromSeconds(legacy);
|
||
}
|
||
intervalCx.updatePreview();
|
||
|
||
const descLabel = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
|
||
const descInput = el('input', {
|
||
type: 'text', name: 'description', placeholder: 'shown on the schedule card',
|
||
});
|
||
descInput.value = carry.description;
|
||
descLabel.append(descInput);
|
||
form_.append(descLabel);
|
||
|
||
const actions = el('div', { class: 'schedule-actions' });
|
||
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '+ qu3ue prompt');
|
||
actions.append(submit);
|
||
form_.append(actions);
|
||
|
||
root.append(form_);
|
||
}
|
||
function readScheduleFormCarry(root) {
|
||
return {
|
||
targets: Array.from(root.querySelectorAll('input[name="targets"]:checked')).map((i) => i.value),
|
||
body: root.querySelector('textarea[name="body"]')?.value || '',
|
||
first_fire: root.querySelector('input[name="first_fire"]')?.value || '',
|
||
// #466: interval is now split into d/h/m/s sub-fields. Keep the
|
||
// legacy `interval` key in case an older bundle is in the page.
|
||
interval: root.querySelector('input[name="interval"]')?.value || '',
|
||
interval_d: root.querySelector('input[name="interval_d"]')?.value || '',
|
||
interval_h: root.querySelector('input[name="interval_h"]')?.value || '',
|
||
interval_m: root.querySelector('input[name="interval_m"]')?.value || '',
|
||
interval_s: root.querySelector('input[name="interval_s"]')?.value || '',
|
||
description: root.querySelector('input[name="description"]')?.value || '',
|
||
};
|
||
}
|
||
function isoForDatetimeLocal(date) {
|
||
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
|
||
// in the user's local timezone (with no trailing Z). Build it
|
||
// by hand rather than slicing toISOString which is always UTC.
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
}
|
||
async function submitNewSchedule(form_) {
|
||
const fd = new FormData(form_);
|
||
const targets = fd.getAll('targets').map(String);
|
||
const body = String(fd.get('body') || '').trim();
|
||
const firstFireStr = String(fd.get('first_fire') || '');
|
||
const description = String(fd.get('description') || '').trim();
|
||
|
||
if (!targets.length) { alert('schedule must have at least one target'); return; }
|
||
if (!body) { alert('prompt body must be non-empty'); return; }
|
||
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
|
||
const firstFireDate = new Date(firstFireStr);
|
||
if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; }
|
||
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
|
||
// Interval composer (#466): combine d/h/m/s into total seconds.
|
||
// Negative values are rejected; total == 0 means one-shot, same
|
||
// semantic the backend expects when interval_seconds is null.
|
||
const partSecs = (name, mult) => {
|
||
const raw = String(fd.get(name) || '').trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
const partD = partSecs('interval_d', 86400);
|
||
const partH = partSecs('interval_h', 3600);
|
||
const partM = partSecs('interval_m', 60);
|
||
const partS = partSecs('interval_s', 1);
|
||
if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) {
|
||
alert('interval fields must be non-negative integers (or blank for one-shot)');
|
||
return;
|
||
}
|
||
const intervalTotal = partD + partH + partM + partS;
|
||
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
|
||
const payload = { targets, body, first_fire_at_unix };
|
||
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
|
||
if (description) payload.description = description;
|
||
const submitBtn = form_.querySelector('button[type="submit"]');
|
||
const originalLabel = submitBtn ? submitBtn.innerHTML : '';
|
||
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="spinner">◐</span> queueing…'; }
|
||
try {
|
||
const resp = await fetch('/api/schedules', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
return;
|
||
}
|
||
// Clear the form (form_ is about to be re-rendered fresh below).
|
||
form_.reset();
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
alert('schedule submit failed: ' + err);
|
||
} finally {
|
||
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; }
|
||
}
|
||
}
|
||
function renderSchedulesList() {
|
||
const root = $('schedules-section');
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
if (!schedulesState.length) {
|
||
root.append(el('p', { class: 'empty' }, 'no schedules queued'));
|
||
return;
|
||
}
|
||
const ul = el('ul', { class: 'schedules' });
|
||
// Active schedules first (still firing), then cancelled tail.
|
||
const sorted = schedulesState.slice().sort((a, b) => {
|
||
const aDone = a.cancelled_at_unix ? 1 : 0;
|
||
const bDone = b.cancelled_at_unix ? 1 : 0;
|
||
if (aDone !== bDone) return aDone - bDone;
|
||
return a.next_fire_at_unix - b.next_fire_at_unix;
|
||
});
|
||
for (const s of sorted) ul.append(renderScheduleCard(s));
|
||
root.append(ul);
|
||
}
|
||
function renderScheduleCard(s) {
|
||
const cancelled = !!s.cancelled_at_unix;
|
||
const li = el('li', { class: 'schedule-row' + (cancelled ? ' schedule-cancelled' : '') });
|
||
const head = el('div', { class: 'schedule-head' });
|
||
head.append(
|
||
el('span', { class: 'meta' }, '#' + s.id), ' ',
|
||
el('span', { class: 'rqe-source rqe-source-' + (s.source && s.source.kind === 'approval' ? 'approval' : 'manual') },
|
||
s.source && s.source.kind === 'approval' ? 'approval' : 'operator'),
|
||
);
|
||
if (cancelled) {
|
||
head.append(' ', el('span', { class: 'badge badge-muted', title: 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString() }, 'cancelled'));
|
||
} else {
|
||
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
|
||
const dueLabel = dueIn <= 0
|
||
? `overdue ${fmtAgo(s.next_fire_at_unix)}`
|
||
: `next fire in ${fmtDuration(dueIn)}`;
|
||
head.append(' ', el('span', {
|
||
class: 'meta',
|
||
title: new Date(s.next_fire_at_unix * 1000).toISOString(),
|
||
}, dueLabel));
|
||
}
|
||
if (s.interval_seconds) {
|
||
head.append(' ', el('span',
|
||
{ class: 'badge badge-muted', title: 'recurring' },
|
||
`↻ every ${fmtDuration(s.interval_seconds)}`));
|
||
} else {
|
||
head.append(' ', el('span', { class: 'badge badge-muted', title: 'one-shot' }, 'one-shot'));
|
||
}
|
||
head.append(' ', el('span', { class: 'meta' }, '· owner ' + s.owner));
|
||
li.append(head);
|
||
|
||
if (s.description) {
|
||
li.append(el('div', { class: 'schedule-description' }, s.description));
|
||
}
|
||
const body = el('div', { class: 'schedule-body' });
|
||
appendLinkified(body, s.body);
|
||
li.append(body);
|
||
|
||
// Targets table — one row per recipient with cancel button +
|
||
// last-fire metadata.
|
||
const targets = s.targets || [];
|
||
if (targets.length) {
|
||
const table = el('table', { class: 'schedule-targets-table' });
|
||
const thead = el('thead', {}, el('tr', {},
|
||
el('th', {}, 'target'),
|
||
el('th', {}, 'last fire'),
|
||
el('th', {}, 'last result'),
|
||
el('th', {}, ''),
|
||
));
|
||
table.append(thead);
|
||
const tbody = el('tbody', {});
|
||
for (const t of targets) {
|
||
const tCancelled = !!t.cancelled_at_unix;
|
||
const tr = el('tr', { class: tCancelled ? 'schedule-target-cancelled' : '' });
|
||
tr.append(el('td', {}, t.target));
|
||
tr.append(el('td', { class: 'meta' },
|
||
t.last_fired_at_unix
|
||
? fmtAgo(t.last_fired_at_unix) + ' ago'
|
||
: '—'));
|
||
tr.append(el('td', { class: 'meta' }, t.last_result || '—'));
|
||
const actionTd = el('td', {});
|
||
if (!cancelled && !tCancelled) {
|
||
const btn = el('button', { type: 'button', class: 'btn btn-deny btn-inline-small' }, '✕');
|
||
btn.title = 'cancel just this target';
|
||
btn.addEventListener('click', () => cancelScheduleTargets(s.id, [t.target]));
|
||
actionTd.append(btn);
|
||
} else if (tCancelled) {
|
||
actionTd.append(el('span', { class: 'meta' }, '✕ cancelled'));
|
||
}
|
||
tr.append(actionTd);
|
||
tbody.append(tr);
|
||
}
|
||
table.append(tbody);
|
||
li.append(table);
|
||
}
|
||
|
||
if (!cancelled) {
|
||
const actions = el('div', { class: 'schedule-actions' });
|
||
// Fire-now (#467): out-of-band manual pulse. Recurring schedules
|
||
// get an extra fan-out without disturbing `next_fire_at`; one-shots
|
||
// are consumed (cancelled after fan-out), per damocles's design.
|
||
// Disabled when every target is cancelled — there's nothing to
|
||
// fire to. Backend: POST /api/schedules/{id}/fire-now.
|
||
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
|
||
const fireBtn = el('button', { type: 'button', class: 'btn btn-fire-now' }, '↯ fire now');
|
||
const isOneShot = !s.interval_seconds;
|
||
fireBtn.title = isOneShot
|
||
? 'fire this schedule once, immediately (one-shot — schedule is consumed after the manual fire)'
|
||
: 'fire this schedule once, immediately (recurring — next regularly-scheduled fire is unaffected)';
|
||
if (!activeTargets.length) {
|
||
fireBtn.disabled = true;
|
||
fireBtn.title = 'every target is cancelled — nothing to fire';
|
||
}
|
||
fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target)));
|
||
actions.append(fireBtn);
|
||
const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' },
|
||
editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit');
|
||
editBtn.title = 'edit body / description / interval / next-fire (targets stay immutable)';
|
||
editBtn.addEventListener('click', () => {
|
||
if (editingSchedules.has(s.id)) {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
} else {
|
||
editingSchedules.add(s.id);
|
||
}
|
||
renderSchedulesList();
|
||
});
|
||
actions.append(editBtn);
|
||
const cancelAll = el('button', { type: 'button', class: 'btn btn-deny' }, '✕ cancel all');
|
||
cancelAll.title = 'cancel the whole schedule';
|
||
cancelAll.addEventListener('click', () => cancelScheduleAll(s.id));
|
||
actions.append(cancelAll);
|
||
li.append(actions);
|
||
if (editingSchedules.has(s.id)) {
|
||
li.append(renderScheduleEditForm(s));
|
||
}
|
||
}
|
||
return li;
|
||
}
|
||
|
||
// #474 — inline edit form. Renders inside the schedule row when the
|
||
// row's `✎ edit` button is toggled on. Pre-filled with current
|
||
// values; submit PATCHes /api/schedules/{id}. Targets stay
|
||
// immutable (per damocles's backend; the workaround for retargeting
|
||
// is cancel + new schedule). Mid-edit field values survive a
|
||
// state-poll refresh via `scheduleEditCarry`.
|
||
function renderScheduleEditForm(s) {
|
||
const wrapper = el('div', { class: 'schedule-edit-form-wrapper' });
|
||
const form_ = el('form', { class: 'schedule-edit-form' });
|
||
form_.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
submitEditSchedule(s, form_);
|
||
});
|
||
|
||
const carry = scheduleEditCarry.get(s.id) || {};
|
||
|
||
const bodyLabel = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, 'body'));
|
||
const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' });
|
||
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
|
||
bodyLabel.append(bodyInput);
|
||
form_.append(bodyLabel);
|
||
|
||
const descLabel = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, 'description (blank to clear)'));
|
||
const descInput = el('input', { type: 'text', name: 'description' });
|
||
descInput.value = carry.description !== undefined
|
||
? carry.description
|
||
: (s.description || '');
|
||
descLabel.append(descInput);
|
||
form_.append(descLabel);
|
||
|
||
const firstFireLabel = el('label', { class: 'schedule-field' },
|
||
el('span', { class: 'schedule-field-label' }, 'next fire'));
|
||
const firstFireInput = el('input', {
|
||
type: 'datetime-local', name: 'next_fire', required: 'required',
|
||
});
|
||
firstFireInput.value = carry.next_fire !== undefined
|
||
? carry.next_fire
|
||
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000));
|
||
firstFireLabel.append(firstFireInput);
|
||
form_.append(firstFireLabel);
|
||
|
||
const intervalCx = buildIntervalComposer({
|
||
label: 'interval (blank / all-zero = flip to one-shot)',
|
||
namePrefix: 'edit_interval_',
|
||
initialSeconds: 0,
|
||
});
|
||
form_.append(intervalCx.wrapper);
|
||
if (carry.interval_d !== undefined || carry.interval_h !== undefined
|
||
|| carry.interval_m !== undefined || carry.interval_s !== undefined) {
|
||
intervalCx.setParts({
|
||
d: carry.interval_d || '',
|
||
h: carry.interval_h || '',
|
||
m: carry.interval_m || '',
|
||
s: carry.interval_s || '',
|
||
});
|
||
} else if (s.interval_seconds) {
|
||
intervalCx.fillFromSeconds(s.interval_seconds);
|
||
}
|
||
intervalCx.updatePreview();
|
||
|
||
// Persist carry on every input so a refresh repaint preserves it.
|
||
form_.addEventListener('input', () => {
|
||
const fd = new FormData(form_);
|
||
scheduleEditCarry.set(s.id, {
|
||
body: String(fd.get('body') || ''),
|
||
description: String(fd.get('description') || ''),
|
||
next_fire: String(fd.get('next_fire') || ''),
|
||
interval_d: String(fd.get('edit_interval_d') || ''),
|
||
interval_h: String(fd.get('edit_interval_h') || ''),
|
||
interval_m: String(fd.get('edit_interval_m') || ''),
|
||
interval_s: String(fd.get('edit_interval_s') || ''),
|
||
});
|
||
});
|
||
|
||
// Targets read-only callout. Per #474 design: targets stay
|
||
// immutable, cancel+new is the retarget workaround.
|
||
const targetList = (s.targets || [])
|
||
.filter((t) => !t.cancelled_at_unix)
|
||
.map((t) => t.target)
|
||
.join(', ');
|
||
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
|
||
'targets (' + (targetList || 'none active') + ') are immutable — '
|
||
+ 'cancel + new schedule if you need a different recipient set.'));
|
||
|
||
const actions = el('div', { class: 'schedule-actions' });
|
||
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
|
||
const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel');
|
||
cancelEdit.addEventListener('click', () => {
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
});
|
||
actions.append(submit, cancelEdit);
|
||
form_.append(actions);
|
||
|
||
wrapper.append(form_);
|
||
return wrapper;
|
||
}
|
||
|
||
async function submitEditSchedule(originalSchedule, form_) {
|
||
const s = originalSchedule;
|
||
const fd = new FormData(form_);
|
||
const newBody = String(fd.get('body') || '').trim();
|
||
const newDescription = String(fd.get('description') || '').trim();
|
||
const newNextFireStr = String(fd.get('next_fire') || '');
|
||
|
||
if (!newBody) { alert('body must be non-empty'); return; }
|
||
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
|
||
const newNextFireDate = new Date(newNextFireStr);
|
||
if (Number.isNaN(newNextFireDate.getTime())) {
|
||
alert('next-fire is not a valid datetime'); return;
|
||
}
|
||
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
|
||
|
||
// Compute interval total from the d/h/m/s fields (composer uses
|
||
// `edit_interval_*` names in this form).
|
||
const partSecs = (name, mult) => {
|
||
const raw = String(fd.get(name) || '').trim();
|
||
if (!raw) return 0;
|
||
const n = parseInt(raw, 10);
|
||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||
return n * mult;
|
||
};
|
||
const partD = partSecs('edit_interval_d', 86400);
|
||
const partH = partSecs('edit_interval_h', 3600);
|
||
const partM = partSecs('edit_interval_m', 60);
|
||
const partS = partSecs('edit_interval_s', 1);
|
||
if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) {
|
||
alert('interval fields must be non-negative integers');
|
||
return;
|
||
}
|
||
const intervalTotal = partD + partH + partM + partS;
|
||
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
||
|
||
// Build the PATCH body. Only include keys for fields that
|
||
// actually changed; explicit `null` clears description / flips
|
||
// recurring→one-shot.
|
||
const patch = {};
|
||
if (newBody !== s.body) patch.body = newBody;
|
||
if (newDescription !== (s.description || '')) {
|
||
patch.description = newDescription || null;
|
||
}
|
||
if (newNextFireUnix !== s.next_fire_at_unix) {
|
||
patch.next_fire_at_unix = newNextFireUnix;
|
||
}
|
||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||
patch.interval_seconds = newIntervalSeconds;
|
||
}
|
||
if (!Object.keys(patch).length) {
|
||
// No-op submit. Treat as "close edit form".
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
renderSchedulesList();
|
||
return;
|
||
}
|
||
|
||
const submitBtn = form_.querySelector('button[type="submit"]');
|
||
const originalLabel = submitBtn ? submitBtn.textContent : '';
|
||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; }
|
||
try {
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch),
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
return;
|
||
}
|
||
editingSchedules.delete(s.id);
|
||
scheduleEditCarry.delete(s.id);
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
alert('edit failed: ' + err);
|
||
} finally {
|
||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
|
||
}
|
||
}
|
||
async function fireScheduleNow(id, isOneShot, targets) {
|
||
const targetList = targets.length ? targets.join(', ') : '(no active targets)';
|
||
const prompt = isOneShot
|
||
? `fire schedule #${id} now to ${targetList}?\n\n`
|
||
+ 'this is a ONE-SHOT — firing now consumes the schedule. '
|
||
+ 'the scheduled fire time will no longer trigger.'
|
||
: `fire schedule #${id} now to ${targetList}?\n\n`
|
||
+ 'this is RECURRING — sends an extra pulse out-of-band. '
|
||
+ 'the regular cadence keeps firing on schedule.';
|
||
if (!confirm(prompt)) return;
|
||
try {
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
alert('fire-now failed: ' + err);
|
||
}
|
||
}
|
||
async function cancelScheduleAll(id) {
|
||
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
|
||
await postScheduleCancel(id, null);
|
||
}
|
||
async function cancelScheduleTargets(id, targets) {
|
||
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
|
||
await postScheduleCancel(id, targets);
|
||
}
|
||
async function postScheduleCancel(id, targets) {
|
||
try {
|
||
const opts = {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
};
|
||
if (targets) opts.body = JSON.stringify({ targets });
|
||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => '');
|
||
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||
return;
|
||
}
|
||
await refreshSchedules();
|
||
} catch (err) {
|
||
alert('cancel failed: ' + err);
|
||
}
|
||
}
|
||
|
||
// ─── state polling ──────────────────────────────────────────────────────
|
||
let pollTimer = null;
|
||
// Sections whose innerHTML gets blown away on each refresh. If the
|
||
// operator is typing in one of them, skip the refresh — the next
|
||
// tick (or a manual action) will pick it up after they blur.
|
||
const MANAGED_SECTION_IDS = [
|
||
'containers-section',
|
||
'tombstones-section',
|
||
'questions-section',
|
||
'inbox-section',
|
||
'approvals-section',
|
||
'meta-inputs-section',
|
||
'rebuild-queue-section',
|
||
'reminders-section',
|
||
'schedule-new-section',
|
||
'schedules-section',
|
||
];
|
||
// <details> sections that should survive a refresh need a stable
|
||
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
||
// sections and records which keys are currently open; restoreOpenDetails
|
||
// re-applies after the render. (Long-content drill-ins — file
|
||
// previews, diffs, logs, config — open in the side panel instead,
|
||
// which lives outside the managed sections and survives re-render
|
||
// on its own.)
|
||
function snapshotOpenDetails() {
|
||
const open = new Set();
|
||
for (const id of MANAGED_SECTION_IDS) {
|
||
const sect = document.getElementById(id);
|
||
if (!sect) continue;
|
||
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
||
if (d.open) open.add(d.dataset.restoreKey);
|
||
}
|
||
}
|
||
return open;
|
||
}
|
||
function restoreOpenDetails(open) {
|
||
if (!open.size) return;
|
||
for (const id of MANAGED_SECTION_IDS) {
|
||
const sect = document.getElementById(id);
|
||
if (!sect) continue;
|
||
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
||
if (open.has(d.dataset.restoreKey)) d.open = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
function operatorIsTyping() {
|
||
const el_ = document.activeElement;
|
||
if (!el_ || el_ === document.body) return false;
|
||
const tag = el_.tagName;
|
||
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
|
||
return MANAGED_SECTION_IDS.some((id) => {
|
||
const sect = document.getElementById(id);
|
||
return sect && sect.contains(el_);
|
||
});
|
||
}
|
||
async function refreshState() {
|
||
// Don't yank the form out from under the operator. Try again
|
||
// shortly on the next tick; eventually they'll blur and the
|
||
// refresh lands.
|
||
if (operatorIsTyping()) {
|
||
if (pollTimer) clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(refreshState, 2000);
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch('/api/state');
|
||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||
const s = await resp.json();
|
||
// Stash the latest snapshot for any sub-widget that wants a
|
||
// synchronous read (e.g. the compose autocomplete pulls agent
|
||
// names from here instead of refetching on every keystroke).
|
||
window.__hyperhive_state = s;
|
||
const openDetails = snapshotOpenDetails();
|
||
// Sync transients + containers first so renderContainers below
|
||
// sees the current derived maps (it reads from
|
||
// `transientsState` + `containersState`, not from `s.*`).
|
||
syncTransientsFromSnapshot(s);
|
||
syncContainersFromSnapshot(s);
|
||
syncTombstonesFromSnapshot(s);
|
||
syncMetaInputsFromSnapshot(s);
|
||
syncRebuildQueueFromSnapshot(s);
|
||
renderContainers(s);
|
||
renderTombstones(s);
|
||
// Sync the derived approvals + questions stores from the
|
||
// snapshot, then render. Live `*_added` / `*_resolved` events
|
||
// mutate the stores directly and re-render without a snapshot
|
||
// refetch.
|
||
syncQuestionsFromSnapshot(s);
|
||
renderQuestions();
|
||
// (renderInbox now lives in ./flow.js — dashboard has no
|
||
// #inbox-section element to render into.)
|
||
syncApprovalsFromSnapshot(s);
|
||
renderApprovals();
|
||
renderMetaInputs(s);
|
||
renderRebuildQueue(s);
|
||
refreshReminders();
|
||
refreshSchedules();
|
||
restoreOpenDetails(openDetails);
|
||
notifyDeltas(s);
|
||
// No periodic refresh timer. Phase 6 covers every container
|
||
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
||
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
|
||
// approvals + questions + transients have their own events;
|
||
// broker traffic flows through the SSE channel. The only
|
||
// /api/state fetches are the initial cold load and the
|
||
// post-submit refetch on forms without `data-no-refresh`
|
||
// (tombstones, meta-input updates).
|
||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||
} catch (err) {
|
||
console.error('refreshState failed', err);
|
||
// Schedule a single retry on transient errors so the page
|
||
// recovers from a brief network blip without making the
|
||
// operator reload.
|
||
pollTimer = setTimeout(refreshState, 5000);
|
||
}
|
||
}
|
||
refreshState();
|
||
NOTIF.bind();
|
||
Panel.bind();
|
||
|
||
// ─── live updates: dashboard event stream (#406 step 3) ────────────────
|
||
// The dashboard subscribes to /dashboard/stream for live mutation
|
||
// events so the SW4RM / Y3R C4LL / SYST3M panes update without an
|
||
// operator action triggering a refreshState. Pre-step-2 this wiring
|
||
// lived inside the broker-terminal IIFE which only fired on /flow.html
|
||
// — meaning /index.html only updated on cold load + after async-form
|
||
// submits.
|
||
//
|
||
// Bare `EventSource` (no terminal infrastructure needed — the
|
||
// dashboard doesn't render broker rows). Each event's `kind` is
|
||
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
|
||
// `sent` / `delivered`, anything new the backend adds) silently
|
||
// no-op. On (re)connect we kick a refreshState() to recover events
|
||
// lost during the disconnect window (same pattern as flow.js's
|
||
// onStreamOpen).
|
||
//
|
||
// `#408` will give /index.html its own stream that omits the
|
||
// broker traffic the dashboard never uses; for now both pages
|
||
// subscribe to `/dashboard/stream` and filter client-side.
|
||
const MUTATION_HANDLERS = {
|
||
approval_added: applyApprovalAdded,
|
||
approval_resolved: applyApprovalResolved,
|
||
question_added: applyQuestionAdded,
|
||
question_resolved: applyQuestionResolved,
|
||
transient_set: applyTransientSet,
|
||
transient_cleared: applyTransientCleared,
|
||
container_state_changed: applyContainerStateChanged,
|
||
container_removed: applyContainerRemoved,
|
||
tombstones_changed: applyTombstonesChanged,
|
||
meta_inputs_changed: applyMetaInputsChanged,
|
||
meta_update_running: applyMetaUpdateRunning,
|
||
rebuild_queue_changed: applyRebuildQueueChanged,
|
||
};
|
||
(function bindDashboardStream() {
|
||
// #448: route the EventSource through a SharedWorker so all open
|
||
// hyperhive tabs share ONE backend SSE connection. Survives
|
||
// Firefox's per-tab connection throttling under many-open-tabs
|
||
// pressure (the actual mara symptom). `openStream` returns an
|
||
// EventSource-shaped facade so the rest of this IIFE is unchanged;
|
||
// graceful fallback to direct `new EventSource` when SharedWorker
|
||
// isn't supported.
|
||
const es = openStream('/dashboard/stream');
|
||
es.onmessage = (e) => {
|
||
let ev;
|
||
try { ev = JSON.parse(e.data); } catch { return; }
|
||
const h = MUTATION_HANDLERS[ev.kind];
|
||
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
||
try { h(ev); }
|
||
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
|
||
};
|
||
es.onopen = () => {
|
||
// Re-sync to recover events that fired during a disconnect
|
||
// window (issue #163). Initial connect also fires onopen — the
|
||
// first refreshState() above and this one race, but refreshState
|
||
// is idempotent so the second call just overwrites with the
|
||
// freshest snapshot. Cheap on a quiescent server, fine to repeat.
|
||
refreshState();
|
||
};
|
||
es.onerror = () => {
|
||
// EventSource auto-reconnects; nothing to do beyond logging.
|
||
console.debug('dashboard SSE error, will retry');
|
||
};
|
||
})();
|
||
|
||
// ─── tab routing (#369) ────────────────────────────────────────────────
|
||
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
|
||
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
|
||
// a tab — it's a separate page (`/flow.html`) reached via the
|
||
// tab-strip link. Tab routing only applies when the tab DOM is
|
||
// present (e.g. not on the flow page itself, where these elements
|
||
// don't exist and the loop no-ops).
|
||
const TABS = ['swarm', 'call', 'system', 'schedules'];
|
||
function activateTab(name) {
|
||
const target = TABS.includes(name) ? name : TABS[0];
|
||
for (const t of TABS) {
|
||
const tab = $('tab-' + t);
|
||
const pane = $('tab-pane-' + t);
|
||
if (tab) tab.classList.toggle('active', t === target);
|
||
if (pane) pane.classList.toggle('tab-pane-active', t === target);
|
||
}
|
||
// #459: schedules pane has no SSE channel yet (PR C follow-up), so
|
||
// re-fetch on activation so the operator never lands on stale data.
|
||
if (target === 'schedules') refreshSchedules();
|
||
}
|
||
function syncTabFromHash() {
|
||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||
activateTab(h);
|
||
}
|
||
window.addEventListener('hashchange', syncTabFromHash);
|
||
syncTabFromHash();
|
||
|
||
// Tab count pills — pure derived data from the existing state
|
||
// stores so SSE-driven updates flow through without extra plumbing.
|
||
// Set `hidden` when the count is zero so the pill doesn't draw
|
||
// attention to an empty room.
|
||
function setTabCount(tab, n) {
|
||
const el_ = $('tab-count-' + tab);
|
||
if (!el_) return;
|
||
el_.textContent = String(n);
|
||
el_.hidden = n <= 0;
|
||
}
|
||
/** Recompute every tab's count from the current state. Called on
|
||
* every renderXxx that's tab-relevant. */
|
||
function refreshTabCounts() {
|
||
// SW4RM — flag any container that's stale (needs_update). Empty
|
||
// when everyone's current. Container-row pulse signals state
|
||
// transitions; the pill catches "deploy-pending" specifically.
|
||
let swarm = 0;
|
||
for (const c of containersState.values()) {
|
||
if (c.needs_update) swarm++;
|
||
}
|
||
setTabCount('swarm', swarm);
|
||
// Y3R C4LL — pending approvals + operator-targeted questions.
|
||
const callCount =
|
||
(approvalsState?.pending?.length ?? 0) +
|
||
(questionsState?.pending?.length ?? 0);
|
||
setTabCount('call', callCount);
|
||
// SYST3M — queued + running rebuild_queue entries (terminal
|
||
// entries are kept for history but aren't 'attention').
|
||
let sysCount = 0;
|
||
if (rebuildQueueState) {
|
||
for (const e of rebuildQueueState) {
|
||
if (e.state === 'Queued' || e.state === 'Running') sysCount++;
|
||
}
|
||
}
|
||
setTabCount('system', sysCount);
|
||
// SCH3DUL3S — count of schedules with at least one still-active
|
||
// target (whole-schedule cancellation or all-targets-cancelled
|
||
// means "not waiting on the worker"; those don't pull attention).
|
||
setTabCount('schedules', activeScheduleCount());
|
||
// FL0W pill count: lives in ./flow.js now (it has the inbox
|
||
// derived store). Dashboard tab strip's `#tab-count-flow` slot
|
||
// stays hidden by default; future plumbing could broadcast the
|
||
// count via localStorage / BroadcastChannel if both pages are
|
||
// open.
|
||
}
|
||
// Poll the state stores on a 1s tick to keep the pill counts in
|
||
// sync. The state stores are mutated synchronously by every SSE
|
||
// event + refreshState call, so polling them is correct and cheap
|
||
// — no per-renderer hookup needed.
|
||
refreshTabCounts();
|
||
setInterval(refreshTabCounts, 1000);
|
||
|
||
// Flow-specific IIFEs moved to ./flow.js (#406 step 2): inbox-pill
|
||
// wiring, the broker terminal init, and the @-mention composer.
|
||
// /index.html no longer loads them — only /flow.html does.
|
||
})();
|