From 878f95205b4d4eb412666b7b781a361320e8d54e Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 10 Jun 2026 22:17:40 +0200 Subject: [PATCH] fix(dashboard): wire data-async form submit handler on the C0R3 page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "update & rebuild" (and cancel / respawn / purge) on /core.html navigated to the bare `ok` response page instead of submitting async. The C0R3 page (split out of the dashboard) carries `data-async` forms but its bundle never had the global submit interceptor — that handler lived inline in tabs.js, so only the dashboard bundle had it. The forms POSTed natively and the browser followed the response. Fix: lift the `data-async` submit handler out of tabs.js into a shared `bindAsyncForms(onSuccess)` in common.js (which already owns the `form` helper that builds these forms), and call it from both pages: - tabs.js: `bindAsyncForms(() => refreshState())` — behaviour-preserving (same handler, now imported). - core.js: add a `refreshState()` (re-fetch /api/state + re-render) used for the cold load and as the post-submit refresh, and call `bindAsyncForms(() => refreshState())` at boot. Forms with `data-no-refresh` (e.g. meta-update, which gets its update via the meta SSE events) skip the refresh, same as before. --- frontend/packages/dashboard/src/common.js | 61 +++++++++++++++++++++ frontend/packages/dashboard/src/core.js | 31 +++++++---- frontend/packages/dashboard/src/tabs.js | 64 ++--------------------- 3 files changed, 87 insertions(+), 69 deletions(-) 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