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/<name>?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.
This commit is contained in:
parent
9a6306540b
commit
03b10d22cd
3 changed files with 153 additions and 8 deletions
|
|
@ -46,6 +46,72 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
||||||
return f;
|
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
|
// Page-level submit interceptor for `data-async` forms — the pattern every
|
||||||
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
|
// dashboard action button uses (meta-update, spawn, cancel, purge, …).
|
||||||
// Without this a `data-async` form POSTs natively and the browser navigates
|
// Without this a `data-async` form POSTs natively and the browser navigates
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,59 @@ body.dashboard-shell {
|
||||||
}
|
}
|
||||||
.container-row .actions form.inline { display: inline-block; margin: 0; }
|
.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 {
|
.agent-status {
|
||||||
font-size: 0.82em;
|
font-size: 0.82em;
|
||||||
color: var(--subtext0);
|
color: var(--subtext0);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { marked } from 'marked';
|
||||||
import {
|
import {
|
||||||
$, el, esc, form,
|
$, el, esc, form,
|
||||||
fmtAgeSecs,
|
fmtAgeSecs,
|
||||||
Panel, NOTIF,
|
Panel, NOTIF, themedConfirm,
|
||||||
makePathLink, appendText, appendLinkified,
|
makePathLink, appendText, appendLinkified,
|
||||||
openStream, renderServerWarnings, bindAsyncForms,
|
openStream, renderServerWarnings, bindAsyncForms,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
|
|
@ -281,8 +281,8 @@ window.marked = marked;
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
// Single-agent POST helper shared by all menu items.
|
// Single-agent POST helper shared by all menu items.
|
||||||
async function agentMenuPost(actionPath, name, body) {
|
async function agentMenuPost(actionPath, name, body, graceful) {
|
||||||
const url = actionPath + encodeURIComponent(name);
|
const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=1' : '');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -323,8 +323,20 @@ window.marked = marked;
|
||||||
}, label);
|
}, label);
|
||||||
item.addEventListener('click', async () => {
|
item.addEventListener('click', async () => {
|
||||||
closeAllAgentMenus();
|
closeAllAgentMenus();
|
||||||
if (opts.confirm && !confirm(opts.confirm)) return;
|
let graceful = false;
|
||||||
await agentMenuPost(opts.action, c.name, opts.body || null);
|
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);
|
li.append(item);
|
||||||
return li;
|
return li;
|
||||||
|
|
@ -352,7 +364,7 @@ window.marked = marked;
|
||||||
if (c.running) {
|
if (c.running) {
|
||||||
dropdown.append(
|
dropdown.append(
|
||||||
menuItem('↺ R3ST4RT', { action: '/restart/', confirm: `restart ${c.name}?` }),
|
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 {
|
} else {
|
||||||
dropdown.append(
|
dropdown.append(
|
||||||
|
|
@ -982,6 +994,8 @@ window.marked = marked;
|
||||||
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
|
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
|
||||||
action: '/kill/',
|
action: '/kill/',
|
||||||
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
|
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`)),
|
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
|
||||||
});
|
});
|
||||||
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
|
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
|
||||||
|
|
@ -1145,7 +1159,19 @@ window.marked = marked;
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (btn.disabled) return;
|
if (btn.disabled) return;
|
||||||
const msg = opts.confirm(names);
|
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;
|
btn.disabled = true;
|
||||||
const original = btn.innerHTML;
|
const original = btn.innerHTML;
|
||||||
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
|
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
|
||||||
|
|
@ -1168,7 +1194,7 @@ window.marked = marked;
|
||||||
: new URLSearchParams(opts.body || {});
|
: new URLSearchParams(opts.body || {});
|
||||||
const url = opts.perAgentBodyFor
|
const url = opts.perAgentBodyFor
|
||||||
? opts.action
|
? opts.action
|
||||||
: opts.action + encodeURIComponent(name);
|
: opts.action + encodeURIComponent(name) + (graceful ? '?graceful=1' : '');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue