dashboard: point frontend at the /api/* backend routes

Phase 2 of the dashboard route consolidation. The backend now double-registers
every bare top-level route (approve/deny/kill/restart/start/rebuild/destroy/
update-all/answer-question/cancel-question/purge-tombstone/matrix-account-login/
cancel-reminder/retry-reminder/request-spawn/op-send/meta-update/dashboard-stream/
dashboard-history) at an additional /api/<same> path. Switch every dashboard-pkg
fetch / EventSource / form action to the /api/ form so "backend = /api/*" holds
on the frontend side too.

Bare paths still answer, so this is independently deployable; once it ships the
backend can drop the bare registrations (phase 3). webhook/knowledge stays a
distinct webhook prefix (forge-driven, no SPA caller). app.js's /rebuild,/start
are the agent harness UI (different server) and are untouched.

Part of #1846 (phase 2).
This commit is contained in:
iris 2026-06-22 00:45:26 +02:00
commit 905b38b6c7
9 changed files with 36 additions and 36 deletions

View file

@ -252,7 +252,7 @@ export function renderApprovals() {
if (savedSpawnName) spawnNameInput.value = savedSpawnName;
if (spawnHadFocus) spawnNameInput.focus();
const spawn = el('form', {
method: 'POST', action: '/request-spawn',
method: 'POST', action: '/api/request-spawn',
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
});
spawn.append(
@ -371,13 +371,13 @@ export function renderApprovals() {
// on the POST and is surfaced to the manager via
// HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', {
method: 'POST', action: '/deny/' + a.id,
method: 'POST', action: '/api/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 }),
form('/api/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
denyForm,
));
@ -542,7 +542,7 @@ function buildQuestionLi(q) {
appendLinkified(qBody, q.question, q.question_refs);
li.append(head, qBody);
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
method: 'POST', action: '/api/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '',
});
const hasOptions = q.options && q.options.length;
@ -611,7 +611,7 @@ function buildQuestionLi(q) {
// 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,
method: 'POST', action: '/api/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
+ '"[cancelled]" as the answer.',

View file

@ -48,7 +48,7 @@
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Driven by /api/state.rebuild_queue cold-load +
live `rebuild_queue_changed` over /dashboard/stream. Default tab. -->
live `rebuild_queue_changed` over /api/dashboard/stream. Default tab. -->
<section class="core-pane" id="core-pane-rebuild" data-tab-pane="rebuild"
role="tabpanel" aria-labelledby="core-tab-rebuild">
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>

View file

@ -51,7 +51,7 @@ function renderMetaInputs(s) {
}
const f = el('form', {
method: 'POST',
action: '/meta-update',
action: '/api/meta-update',
class: 'meta-inputs-form',
'data-async': '',
'data-no-refresh': '',
@ -331,7 +331,7 @@ function renderTombstones(s) {
const actions = el('div', { class: 'actions' });
const respawn = el('form', {
method: 'POST', action: '/request-spawn',
method: 'POST', action: '/api/request-spawn',
class: 'inline', 'data-async': '',
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
});
@ -341,7 +341,7 @@ function renderTombstones(s) {
);
actions.append(respawn);
actions.append(form(
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
'/api/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
'PURGE ' + t.name + '? config history, claude creds, '
+ 'and notes are all WIPED. no undo.',
{}, { noRefresh: true },
@ -545,7 +545,7 @@ async function init() {
await refreshState();
const es = openStream('/dashboard/stream');
const es = openStream('/api/dashboard/stream');
if (es) {
es.onmessage = (e) => {
let ev;

View file

@ -209,7 +209,7 @@ import {
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: '/dashboard/history',
historyUrl: '/api/dashboard/history',
// Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
@ -218,7 +218,7 @@ import {
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl: '/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
// Route through the SharedWorker — see docs/web-ui.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream
@ -383,7 +383,7 @@ import {
// /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row on its own;
// no /api/state refetch needed.
const resp = await fetch('/op-send', {
const resp = await fetch('/api/op-send', {
method: 'POST',
body: new URLSearchParams(fd),
});

View file

@ -240,7 +240,7 @@ import { createTabStrip } from '@hive/shared/tabs.js';
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
buildRefreshTimer = setTimeout(fetchBuild, 2000);
};
const es = openStream('/dashboard/stream');
const es = openStream('/api/dashboard/stream');
if (es) {
es.onmessage = (e) => {
let ev;

View file

@ -139,7 +139,7 @@ async function submitLogin(e) {
btn.textContent = 'logging in…';
try {
const resp = await fetch('/matrix-account-login', {
const resp = await fetch('/api/matrix-account-login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fd),

View file

@ -88,7 +88,7 @@ function renderReminders(rows) {
// 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,
method: 'POST', action: '/api/retry-reminder/' + r.id,
class: 'inline', 'data-async': '',
});
retryForm.append(el('button',
@ -96,7 +96,7 @@ function renderReminders(rows) {
actions.append(retryForm);
}
const cancelForm = el('form', {
method: 'POST', action: '/cancel-reminder/' + r.id,
method: 'POST', action: '/api/cancel-reminder/' + r.id,
class: 'inline', 'data-async': '',
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
});

View file

@ -2,7 +2,7 @@
// every server-sent event out to every connected tab via MessagePort.
//
// Problem this solves: every dashboard / agent tab opens its own
// `EventSource('/dashboard/stream')`. Browsers cap concurrent
// `EventSource('/api/dashboard/stream')`. Browsers cap concurrent
// connections per host (~6), and Firefox throttles / disconnects
// background tabs when many are open. The result: tabs silently
// drop the SSE, fall behind, and only catch up on focus.
@ -15,8 +15,8 @@
// Wire protocol (port.postMessage payloads):
//
// tab → worker
// { kind: 'subscribe', url: '/dashboard/stream' }
// { kind: 'unsubscribe', url: '/dashboard/stream' }
// { kind: 'subscribe', url: '/api/dashboard/stream' }
// { kind: 'unsubscribe', url: '/api/dashboard/stream' }
//
// worker → tab
// { kind: 'open', url: '...' } relayed from EventSource.onopen,

View file

@ -364,16 +364,16 @@ window.marked = marked;
// Show only actions that are applicable in the current state.
if (c.running) {
dropdown.append(
menuItem('↺ R3ST4RT', { action: '/restart/', confirm: `restart ${c.name}?` }),
menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
menuItem('↺ R3ST4RT', { action: '/api/restart/', confirm: `restart ${c.name}?` }),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
);
} else {
dropdown.append(
menuItem('▶ ST4RT', { action: '/start/', confirm: `start ${c.name}?` }),
menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }),
);
}
dropdown.append(
menuItem('↻ R3BU1LD', { action: '/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this
@ -387,11 +387,11 @@ window.marked = marked;
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/destroy/',
action: '/api/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/destroy/',
action: '/api/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
@ -722,7 +722,7 @@ window.marked = marked;
}
if (c.needs_update) {
head.append(form(
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
'/api/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true },
));
@ -802,7 +802,7 @@ window.marked = marked;
if (anyStale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'/api/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
{}, { noRefresh: true },
));
@ -1001,32 +1001,32 @@ window.marked = marked;
}
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
action: '/restart/',
action: '/api/restart/',
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
});
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
action: '/kill/',
action: '/api/kill/',
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
confirmLabel: '■ stop',
graceful: true,
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
});
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
action: '/start/',
action: '/api/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/',
action: '/api/rebuild/',
confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
});
addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, {
action: '/destroy/',
action: '/api/destroy/',
confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
});
addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, {
action: '/destroy/',
action: '/api/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.`,
});
@ -1487,7 +1487,7 @@ window.marked = marked;
// lost during the disconnect window (same pattern as flow.js's
// onStreamOpen).
//
// Both /dashboard.html and /flow.html subscribe to `/dashboard/stream`
// Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream`
// and filter client-side — the dashboard ignores broker traffic
// and the inbox ignores mutation events.
const MUTATION_HANDLERS = {
@ -1514,7 +1514,7 @@ window.marked = marked;
// paragraph) for the design + Firefox throttling motivation.
// `openStream` returns an EventSource-shaped facade with a graceful
// direct-EventSource fallback when SharedWorker isn't supported.
const es = openStream('/dashboard/stream');
const es = openStream('/api/dashboard/stream');
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }