Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0407e7da62 | ||
|
|
03b10d22cd |
3 changed files with 202 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
115
frontend/packages/dashboard/src/modal.js
Normal file
115
frontend/packages/dashboard/src/modal.js
Normal file
|
|
@ -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 | { [name]: bool }>. `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;
|
||||
});
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
makePathLink, appendText, appendLinkified,
|
||||
openStream, renderServerWarnings, bindAsyncForms,
|
||||
} from './common.js';
|
||||
import { themedConfirm } from './modal.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
import {
|
||||
containersState, syncContainersFromSnapshot,
|
||||
|
|
@ -281,8 +282,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 +324,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 +365,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 +995,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 +1160,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 = '<span class="spinner">◐</span> ' + label;
|
||||
|
|
@ -1168,7 +1195,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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue