From 03b10d22cd98a98945725413a16bb736b46b9d28 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 00:20:05 +0200 Subject: [PATCH 1/2] dashboard(swarm): themed stop confirm modal with graceful checkbox Replace the browser-native confirm() on the agent ST0P actions (per-agent menu + bulk) with an in-theme modal (themedConfirm in common.js), carrying a 'stop gracefully' checkbox. Checked sends POST /kill/?graceful=1 (the quiesce path); unchecked is today's immediate hard stop, unchanged. The modal also covers the other destructive menu actions (restart / rebuild / destroy / purge) so they no longer fall back to the OS dialog. --- frontend/packages/dashboard/src/common.js | 66 +++++++++++++++++++ frontend/packages/dashboard/src/dashboard.css | 53 +++++++++++++++ frontend/packages/dashboard/src/tabs.js | 42 +++++++++--- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index ba351a83..4f9ef2e5 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -46,6 +46,72 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = return f; }; +// ─── themed confirm modal ─────────────────────────────────────────────── +// In-theme replacement for the browser's native `confirm()` so destructive +// actions match the dashboard chrome instead of a jarring OS dialog. Returns +// a Promise resolving to `null` when cancelled, or an object of the checkbox +// states (keyed by each checkbox's `name`) when confirmed — `{}` when there +// are no checkboxes. Escape, a backdrop click, or Cancel all reject; for a +// `danger` action the Cancel button takes initial focus (so a stray Enter +// doesn't fire the destructive path). Example: +// const r = await themedConfirm({ message: `stop ${n}?`, danger: true, +// confirmLabel: '■ stop', checkboxes: [{ name: 'graceful', label: '…' }] }); +// if (!r) return; // cancelled +// doStop(r.graceful); +export function themedConfirm(opts = {}) { + return new Promise((resolve) => { + const { + title = '', message = '', danger = false, + confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [], + } = opts; + + const boxes = checkboxes.map((cb) => { + const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name }); + if (cb.checked) input.checked = true; + const row = el('label', { class: 'tc-checkrow' }, + input, el('span', { class: 'tc-check-label' }, cb.label || cb.name)); + return { name: cb.name, input, row }; + }); + + let settled = false; + function done(result) { + if (settled) return; + settled = true; + document.removeEventListener('keydown', onKey, true); + backdrop.remove(); + resolve(result); + } + const cancel = () => done(null); + const confirmAction = () => { + const out = {}; + for (const b of boxes) out[b.name] = b.input.checked; + done(out); + }; + function onKey(e) { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel(); } + } + + const cancelBtn = el('button', { type: 'button', class: 'btn tc-btn tc-cancel' }, cancelLabel); + const okBtn = el('button', { + type: 'button', class: 'btn tc-btn tc-confirm' + (danger ? ' tc-danger' : ''), + }, confirmLabel); + cancelBtn.addEventListener('click', cancel); + okBtn.addEventListener('click', confirmAction); + + const box = el('div', { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' }, + title ? el('div', { class: 'tc-title' }, title) : null, + el('div', { class: 'tc-message' }, message), + boxes.length ? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row)) : null, + el('div', { class: 'tc-actions' }, cancelBtn, okBtn)); + + const backdrop = el('div', { class: 'tc-backdrop' }, box); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) cancel(); }); + document.addEventListener('keydown', onKey, true); + document.body.append(backdrop); + (danger ? cancelBtn : okBtn).focus(); + }); +} + // 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 diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index a837d12a..08f637f7 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -414,6 +414,59 @@ body.dashboard-shell { } .container-row .actions form.inline { display: inline-block; margin: 0; } +/* Themed confirm modal — `themedConfirm()` in common.js. In-theme replacement + for the native confirm() on destructive actions (stop / restart / destroy), + carrying the optional "stop gracefully" checkbox. */ +.tc-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1em; + background: color-mix(in srgb, var(--crust) 60%, transparent); + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); +} +.tc-box { + background: var(--bg-elev); + border: 1px solid var(--purple-dim); + box-shadow: 0 8px 40px -8px var(--crust); + padding: 1.2em 1.4em; + max-width: min(32em, 92vw); + display: flex; + flex-direction: column; + gap: 0.9em; +} +.tc-title { + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--subtext0); +} +.tc-message { color: var(--fg); line-height: 1.45; } +.tc-checks { display: flex; flex-direction: column; gap: 0.4em; } +.tc-checkrow { + display: flex; + align-items: flex-start; + gap: 0.5em; + cursor: pointer; + color: var(--subtext0); + font-size: 0.92em; + line-height: 1.35; +} +.tc-checkrow .tc-check { margin-top: 0.2em; flex: 0 0 auto; } +.tc-actions { + display: flex; + justify-content: flex-end; + gap: 0.6em; + margin-top: 0.2em; +} +.tc-cancel { color: var(--subtext0); } +.tc-confirm { color: var(--green); } +.tc-confirm.tc-danger { color: var(--red); } + .agent-status { font-size: 0.82em; color: var(--subtext0); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index ee69e53a..5831a0e8 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -14,7 +14,7 @@ import { marked } from 'marked'; import { $, el, esc, form, fmtAgeSecs, - Panel, NOTIF, + Panel, NOTIF, themedConfirm, makePathLink, appendText, appendLinkified, openStream, renderServerWarnings, bindAsyncForms, } from './common.js'; @@ -281,8 +281,8 @@ window.marked = marked; }, true); // Single-agent POST helper shared by all menu items. - async function agentMenuPost(actionPath, name, body) { - const url = actionPath + encodeURIComponent(name); + async function agentMenuPost(actionPath, name, body, graceful) { + const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=1' : ''); try { const resp = await fetch(url, { method: 'POST', @@ -323,8 +323,20 @@ window.marked = marked; }, label); item.addEventListener('click', async () => { closeAllAgentMenus(); - if (opts.confirm && !confirm(opts.confirm)) return; - await agentMenuPost(opts.action, c.name, opts.body || null); + let graceful = false; + if (opts.confirm) { + const r = await themedConfirm({ + message: opts.confirm, + danger: true, + confirmLabel: opts.confirmLabel || 'confirm', + checkboxes: opts.graceful + ? [{ name: 'graceful', label: 'stop gracefully — let the agent finish its turn and flush state before the container stops' }] + : [], + }); + if (!r) return; + graceful = !!r.graceful; + } + await agentMenuPost(opts.action, c.name, opts.body || null, graceful); }); li.append(item); return li; @@ -352,7 +364,7 @@ window.marked = marked; if (c.running) { dropdown.append( menuItem('↺ R3ST4RT', { action: '/restart/', confirm: `restart ${c.name}?` }), - menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?` }), + menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }), ); } else { dropdown.append( @@ -982,6 +994,8 @@ window.marked = marked; addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, { action: '/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, { @@ -1145,7 +1159,19 @@ window.marked = marked; btn.addEventListener('click', async () => { if (btn.disabled) return; const msg = opts.confirm(names); - if (msg && !confirm(msg)) return; + let graceful = false; + if (msg) { + const r = await themedConfirm({ + message: msg, + danger: true, + confirmLabel: opts.confirmLabel || 'confirm', + checkboxes: opts.graceful + ? [{ name: 'graceful', label: 'stop gracefully — let each agent finish its turn and flush state before the container stops' }] + : [], + }); + if (!r) return; + graceful = !!r.graceful; + } btn.disabled = true; const original = btn.innerHTML; btn.innerHTML = ' ' + label; @@ -1168,7 +1194,7 @@ window.marked = marked; : new URLSearchParams(opts.body || {}); const url = opts.perAgentBodyFor ? opts.action - : opts.action + encodeURIComponent(name); + : opts.action + encodeURIComponent(name) + (graceful ? '?graceful=1' : ''); try { const resp = await fetch(url, { method: 'POST', From 0407e7da62e5040931a0e8e18378fab3d30731f7 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 00:25:22 +0200 Subject: [PATCH 2/2] dashboard: extract themed dialog into a reusable modal.js component Move the themed dialog out of common.js into its own modal.js module: a general openDialog(title/message/content/buttons) primitive with themedConfirm as a thin cancel/confirm wrapper on top. tabs.js imports it from there. No behaviour change to the stop-confirm flow; the dialog is now a standalone reusable component other surfaces can open. --- frontend/packages/dashboard/src/common.js | 66 ------------- frontend/packages/dashboard/src/modal.js | 115 ++++++++++++++++++++++ frontend/packages/dashboard/src/tabs.js | 3 +- 3 files changed, 117 insertions(+), 67 deletions(-) create mode 100644 frontend/packages/dashboard/src/modal.js diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 4f9ef2e5..ba351a83 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -46,72 +46,6 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = return f; }; -// ─── themed confirm modal ─────────────────────────────────────────────── -// In-theme replacement for the browser's native `confirm()` so destructive -// actions match the dashboard chrome instead of a jarring OS dialog. Returns -// a Promise resolving to `null` when cancelled, or an object of the checkbox -// states (keyed by each checkbox's `name`) when confirmed — `{}` when there -// are no checkboxes. Escape, a backdrop click, or Cancel all reject; for a -// `danger` action the Cancel button takes initial focus (so a stray Enter -// doesn't fire the destructive path). Example: -// const r = await themedConfirm({ message: `stop ${n}?`, danger: true, -// confirmLabel: '■ stop', checkboxes: [{ name: 'graceful', label: '…' }] }); -// if (!r) return; // cancelled -// doStop(r.graceful); -export function themedConfirm(opts = {}) { - return new Promise((resolve) => { - const { - title = '', message = '', danger = false, - confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [], - } = opts; - - const boxes = checkboxes.map((cb) => { - const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name }); - if (cb.checked) input.checked = true; - const row = el('label', { class: 'tc-checkrow' }, - input, el('span', { class: 'tc-check-label' }, cb.label || cb.name)); - return { name: cb.name, input, row }; - }); - - let settled = false; - function done(result) { - if (settled) return; - settled = true; - document.removeEventListener('keydown', onKey, true); - backdrop.remove(); - resolve(result); - } - const cancel = () => done(null); - const confirmAction = () => { - const out = {}; - for (const b of boxes) out[b.name] = b.input.checked; - done(out); - }; - function onKey(e) { - if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel(); } - } - - const cancelBtn = el('button', { type: 'button', class: 'btn tc-btn tc-cancel' }, cancelLabel); - const okBtn = el('button', { - type: 'button', class: 'btn tc-btn tc-confirm' + (danger ? ' tc-danger' : ''), - }, confirmLabel); - cancelBtn.addEventListener('click', cancel); - okBtn.addEventListener('click', confirmAction); - - const box = el('div', { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' }, - title ? el('div', { class: 'tc-title' }, title) : null, - el('div', { class: 'tc-message' }, message), - boxes.length ? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row)) : null, - el('div', { class: 'tc-actions' }, cancelBtn, okBtn)); - - const backdrop = el('div', { class: 'tc-backdrop' }, box); - backdrop.addEventListener('click', (e) => { if (e.target === backdrop) cancel(); }); - document.addEventListener('keydown', onKey, true); - document.body.append(backdrop); - (danger ? cancelBtn : okBtn).focus(); - }); -} - // 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 diff --git a/frontend/packages/dashboard/src/modal.js b/frontend/packages/dashboard/src/modal.js new file mode 100644 index 00000000..5e6fd406 --- /dev/null +++ b/frontend/packages/dashboard/src/modal.js @@ -0,0 +1,115 @@ +// modal.js — reusable themed modal/dialog component for the operator +// dashboard. An in-theme replacement for the browser's native +// `confirm()` / `alert()` overlays so destructive actions and prompts match +// the dashboard chrome instead of a jarring OS dialog. +// +// `openDialog` is the general primitive (any title/message/content + a row of +// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional +// checkboxes built on top of it. Styling lives in `dashboard.css` under the +// `.tc-*` classes. + +import { el } from './common.js'; + +// openDialog({ title, message, content, buttons, danger, dismissable }) +// → Promise resolving to the clicked button's `value`, or `null` when the +// dialog is dismissed (Escape, backdrop click, or a button whose value is +// null). `content` is an optional DOM node rendered between the message +// and the buttons (checkboxes, custom fields, …). `buttons` is +// `[{ label, value, danger?, class?, autofocus? }]`, rendered +// right-aligned. Initial focus: the `autofocus` button if any, else — for +// a `danger` dialog — the first non-destructive button (so a stray Enter +// can't fire the destructive path), else the last button. +export function openDialog(opts = {}) { + const { + title = '', message = '', content = null, + buttons = [{ label: 'ok', value: true }], + danger = false, dismissable = true, + } = opts; + + return new Promise((resolve) => { + let settled = false; + function done(value) { + if (settled) return; + settled = true; + document.removeEventListener('keydown', onKey, true); + backdrop.remove(); + resolve(value); + } + function onKey(e) { + if (dismissable && e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + done(null); + } + } + + const btnEls = buttons.map((b) => { + const btn = el('button', { + type: 'button', + class: 'btn tc-btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' tc-danger' : ''), + }, b.label); + btn.addEventListener('click', () => done(b.value)); + return { spec: b, btn }; + }); + + const box = el('div', { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' }, + title ? el('div', { class: 'tc-title' }, title) : null, + message ? el('div', { class: 'tc-message' }, message) : null, + content || null, + el('div', { class: 'tc-actions' }, ...btnEls.map((b) => b.btn))); + + const backdrop = el('div', { class: 'tc-backdrop' }, box); + backdrop.addEventListener('click', (e) => { + if (dismissable && e.target === backdrop) done(null); + }); + document.addEventListener('keydown', onKey, true); + document.body.append(backdrop); + + const focusTarget = btnEls.find((b) => b.spec.autofocus) + || (danger ? btnEls.find((b) => !b.spec.danger) : null) + || btnEls[btnEls.length - 1]; + if (focusTarget) focusTarget.btn.focus(); + }); +} + +// themedConfirm({ title, message, danger, confirmLabel, cancelLabel, checkboxes }) +// → Promise. `null` = cancelled; otherwise an +// object of the checkbox states keyed by `name` (`{}` when there are none). +// Example: +// const r = await themedConfirm({ message: `stop ${n}?`, danger: true, +// confirmLabel: '■ stop', checkboxes: [{ name: 'graceful', label: '…' }] }); +// if (!r) return; // cancelled +// doStop(r.graceful); +export function themedConfirm(opts = {}) { + const { + title = '', message = '', danger = false, + confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [], + } = opts; + + const boxes = checkboxes.map((cb) => { + const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name }); + if (cb.checked) input.checked = true; + const row = el('label', { class: 'tc-checkrow' }, + input, el('span', { class: 'tc-check-label' }, cb.label || cb.name)); + return { input, row }; + }); + const content = boxes.length + ? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row)) + : null; + + return openDialog({ + title, + message, + content, + danger, + buttons: [ + { label: cancelLabel, value: null, class: 'tc-cancel', autofocus: danger }, + { label: confirmLabel, value: 'confirm', danger, class: 'tc-confirm', autofocus: !danger }, + ], + }).then((v) => { + if (v !== 'confirm') return null; + const out = {}; + for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked; + return out; + }); +} diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 5831a0e8..4765815e 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -14,10 +14,11 @@ import { marked } from 'marked'; import { $, el, esc, form, fmtAgeSecs, - Panel, NOTIF, themedConfirm, + Panel, NOTIF, makePathLink, appendText, appendLinkified, openStream, renderServerWarnings, bindAsyncForms, } from './common.js'; +import { themedConfirm } from './modal.js'; import { createTabStrip } from '@hive/shared/tabs.js'; import { containersState, syncContainersFromSnapshot,