diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js
index fb423e42..ba351a83 100644
--- a/frontend/packages/dashboard/src/common.js
+++ b/frontend/packages/dashboard/src/common.js
@@ -46,6 +46,67 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
return f;
};
+// Page-level submit interceptor for `data-async` forms — the pattern every
+// dashboard action button uses (meta-update, spawn, cancel, purge, …).
+// Without this a `data-async` form POSTs natively and the browser navigates
+// to the bare `ok` response page. Each page that renders such forms must
+// call this once at boot (dashboard via ./tabs.js, /core.html via ./core.js).
+// `onSuccess` runs after a successful submit unless the form opts out with
+// `data-no-refresh` (forms whose mutation arrives faster via an SSE event).
+export function bindAsyncForms(onSuccess) {
+ 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 = '◐'; }
+ 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 — the refresh rebuilds most lists but skips
+ // forms that didn't change, so without this the spinner sticks.
+ 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 = ''; });
+ if (!f.hasAttribute('data-no-refresh') && typeof onSuccess === 'function') {
+ onSuccess();
+ }
+ } catch (err) {
+ alert('action failed: ' + err);
+ if (btn) { btn.disabled = false; btn.innerHTML = original; }
+ }
+ });
+}
+
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
// render helper live in the dashboard-internal `./util.js`, not here —
// their phrasing ("X running", "X ago") is dashboard-specific, so they
diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js
index 32050261..b1e98ecf 100644
--- a/frontend/packages/dashboard/src/core.js
+++ b/frontend/packages/dashboard/src/core.js
@@ -11,7 +11,7 @@
// dashboard SYST3M tab); the dashboard keeps its own copies for now —
// de-duplication + removing the SYST3M tab is a deliberate follow-up.
-import { $, el, form, openStream, initServerWarnings } from './common.js';
+import { $, el, form, openStream, initServerWarnings, bindAsyncForms } from './common.js';
import { fmtAgo, fmtElapsed, truncate } from './util.js';
import { createTabStrip } from '@hive/shared/tabs.js';
@@ -503,9 +503,28 @@ const SSE_HANDLERS = {
};
// ─── boot ─────────────────────────────────────────────────────────────────
+// Re-fetch /api/state, re-sync the derived state, re-render. Used for the
+// cold load and as the post-submit refresh for `data-async` forms whose
+// mutation doesn't arrive via an SSE event (e.g. the meta-update / spawn /
+// purge actions that opt out of `data-no-refresh`).
+async function refreshState() {
+ try {
+ const resp = await fetch('/api/state');
+ if (resp.ok) syncFromSnapshot(await resp.json());
+ } catch {
+ // best-effort: the page keeps its last-rendered state
+ }
+ renderAll();
+}
+
async function init() {
initServerWarnings();
+ // `data-async` form submit interceptor — without this the meta-update /
+ // cancel / respawn / purge buttons POST natively and the browser navigates
+ // to the bare `ok` response page.
+ bindAsyncForms(() => refreshState());
+
// Hash-routed sub-tab strip; default Rebuild Queue. Container-load
// polling runs only while the LOAD sub-tab is open (cpu is a short
// two-sample read each refresh on the server).
@@ -517,15 +536,7 @@ async function init() {
},
});
- try {
- const resp = await fetch('/api/state');
- if (resp.ok) {
- syncFromSnapshot(await resp.json());
- }
- } catch {
- // best-effort: the page still renders its empty states
- }
- renderAll();
+ await refreshState();
const es = openStream('/dashboard/stream');
if (es) {
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index 2a7ef3b8..cf00e721 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -16,7 +16,7 @@ import {
fmtAgeSecs,
Panel, NOTIF,
makePathLink, appendText, appendLinkified,
- openStream, renderServerWarnings,
+ openStream, renderServerWarnings, bindAsyncForms,
} from './common.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import { containersState, syncContainersFromSnapshot } from './state.js';
@@ -90,64 +90,10 @@ window.marked = marked;
}
// ─── 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 = '◐'; }
- 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; }
- }
- });
+ // Shared `data-async` submit interceptor (now in common.js so /core.html's
+ // bundle gets it too). On success it re-runs refreshState unless the form
+ // opts out via `data-no-refresh` (its mutation arrives via an SSE event).
+ bindAsyncForms(() => refreshState());
// The live agent roster (`containersState`) + its snapshot-sync now
// live in `./state.js` — it's the one piece of cross-domain state, read