Files
vacuum-wall/webui/static/hoover/components/modal.js
T
mteehan 332d14e37d ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
2026-08-20 01:38:00 +00:00

312 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hoover — components/modal.js
*
* Modal overlay system: openModal, closeModal, closeAllModals, formModal.
* Renders directly into #modal-root using DOM manipulation (not vdom) to
* avoid fighting with the main render cycle.
*/
import { esc, att_esc } from '../helpers.js';
import { apiSubmit } from '../api.js';
import { createDom } from '../vdom.js';
/**
* Render Hoover VNodes into a modal content element.
* VDOM is not diffed across modal re-render — modals are transient and
* innerHTML is cleared/repainted each time (avoids lifecycle baggage).
*/
export function modalVNodes(inner, vnodes) {
inner.innerHTML = '';
const nodes = Array.isArray(vnodes) ? vnodes : [vnodes];
for (const vnode of nodes) {
if (vnode) inner.appendChild(createDom(vnode));
}
}
const _modalQueue = [];
function _renderModals() {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = '';
_modalQueue.forEach((m, idx) => {
const wrap = document.createElement('div');
wrap.className = 'modal-overlay active';
wrap.onclick = (e) => {
if (e.target === wrap) {
if (m._processing) return;
if (m._hasInputs && !confirm('Discard changes?')) return;
closeModal(idx);
}
};
const content = document.createElement('div');
content.className = 'modal';
content.onclick = (e) => e.stopPropagation();
if (m.renderFn) {
try { m.renderFn(content, idx); }
catch (err) { content.textContent = err.message; }
}
wrap.appendChild(content);
root.appendChild(wrap);
});
}
/**
* Open a modal dialog.
*
* @param {function|object} content Either:
* - renderFn(contentEl, idx) => void (legacy innerHTML path)
* - VNode / VNode[] (new VDOM path — uses modalVNodes)
*/
export function openModal(content) {
const entry = typeof content === 'function'
? { renderFn: content, id: _modalQueue.length, _processing: false, _hasInputs: false }
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content), _processing: false, _hasInputs: false };
_modalQueue.push(entry);
_renderModals();
}
/**
* Check if the topmost modal (or specified index) has an active async operation.
*
* @param {number} [idx] Modal index (defaults to topmost)
* @returns {boolean}
*/
export function isModalProcessing(idx) {
if (idx === undefined) idx = _modalQueue.length - 1;
if (idx < 0 || idx >= _modalQueue.length) return false;
return _modalQueue[idx]._processing;
}
/**
* Set the processing flag on the topmost modal (or specified index).
*
* @param {boolean} flag Whether the modal is currently processing
* @param {number} [idx] Modal index (defaults to topmost)
*/
export function setModalProcessing(flag, idx) {
if (idx === undefined) idx = _modalQueue.length - 1;
if (idx < 0 || idx >= _modalQueue.length) return;
_modalQueue[idx]._processing = flag;
}
/**
* Close a modal by index. Closes the topmost modal if index is omitted.
*
* @param {number} [idx]
*/
export function closeModal(idx) {
if (idx === undefined) idx = _modalQueue.length - 1;
if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1);
_renderModals();
}
/**
* Close all open modals.
*/
export function closeAllModals() {
_modalQueue.length = 0;
_renderModals();
}
/** Re-render all open modals. Used by long-lived modals that update in place. */
export function refreshModals() {
_renderModals();
}
/**
* Render a standard modal layout: title, form fields, action buttons.
*
* @param {HTMLElement} inner Modal content element to fill
* @param {string} title Modal title
* @param {object[]} fields Form field descriptors
* @param {object[]} actions Action button descriptors
*
* Field shape:
* { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] }
* - options can be string[], [value, selected][] tuples, or objects with { group, options }
* for <optgroup> grouping. Nested options follow the same string/tuple format.
*
* Action shape:
* { label, cls, action, handler }
*/
export function formModal(inner, title, fields, actions) {
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
+ fields.map(f => {
if (f.tag === 'select')
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '"'
+ (f.multiple ? ' multiple' : '') + '>'
+ (f.options || []).map(function(o) {
if (o === null || o === undefined) return '';
if (typeof o === 'string')
return '<option value="' + att_esc(o) + '">' + esc(o) + '</option>';
if (Array.isArray(o)) {
// Support [value, label] and [value, selectedBoolean]
if (o.length < 2)
return '<option value="' + att_esc(o[0]) + '">' + att_esc(o[0]) + '</option>';
if (typeof o[1] === 'boolean')
return '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + att_esc(o[0]) + '</option>';
return '<option value="' + att_esc(o[0]) + '">' + esc(o[1]) + '</option>';
}
if ('group' in o) {
return '<optgroup label="' + att_esc(o.group) + '">'
+ o.options.map(function(item) {
return typeof item === 'string'
? '<option value="' + att_esc(item) + '">' + esc(item) + '</option>'
: '<option value="' + att_esc(item[0]) + '">' + esc(item[1]) + '</option>';
}).join('') + '</optgroup>';
}
return '';
}).join('') + '</select></div>';
const tag = f.tag || 'input';
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
+ (f.checked ? ' checked' : '')
+ '></' + tag + '></div>';
}).join('') + '</div><div class="modal-actions"></div>';
// Mark modal as having editable inputs
const topEntry = _modalQueue[_modalQueue.length - 1];
if (topEntry) topEntry._hasInputs = true;
// Build action buttons with processing-aware rendering
const actionsBar = inner.querySelector('.modal-actions');
const actionBtns = [];
for (const a of actions) {
const actionId = 'am-' + a.action + '-' + (_modalQueue.length - 1);
const btn = document.createElement('button');
btn.className = 'btn ' + a.cls;
btn.id = actionId;
if (a.processing && isModalProcessing(_modalQueue.length - 1)) {
btn.disabled = true;
btn.innerHTML = '<span class="btn-spinner"></span>';
} else {
btn.appendChild(document.createTextNode(a.label));
}
// Store reference for later re-binding
actionBtns.push({ btn, action: a });
if (a.handler) {
const origHandler = a.handler;
btn.addEventListener('click', () => {
// Only inline-disable if the handler doesn't use processing
// state. apiSubmit/formAction already call
// setModalProcessing + refreshModals which re-creates the
// button in processing state. Without the guard the old
// button is discarded before the handler even starts.
if (!a.processing) {
btn.disabled = true;
btn.innerHTML = '<span class="btn-spinner"></span>';
}
origHandler();
});
}
actionsBar.appendChild(btn);
}
}
/**
* Factory that returns a function to open a multi-select modal.
*
* @param {object} props
* @param {string} props.title - Modal title
* @param {string} props.url - API POST URL
* @param {string[]} props.options - All selectable options
* @param {string[]} props.selected - Currently selected values
* @param {string} props.fieldKey - JSON key for the field
* @param {string} [props.successMsg] - Success toast message
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {function} [props.confirm] - (body) => string|null; confirm gate, see apiSubmit
* @returns {function} () => void, calls openModal
*/
export function MultiSelectModal(props = {}) {
return () => {
const selectId = 'ms-' + props.fieldKey;
openModal((inner) => {
formModal(inner, props.title,
[{
label: props.fieldKey,
id: selectId,
tag: 'select',
multiple: true,
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
}],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url: props.url,
body: () => ({
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
.map(o => o.value),
}),
successMsg: props.successMsg || 'Updated',
refresh: props.refresh,
confirm: props.confirm,
closeModal: () => closeModal(),
}),
],
);
});
};
}
/**
* Factory that returns a function to open a modal with form fields and apiSubmit.
* Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers.
*
* @param {object} props
* @param {string|function} props.title - Modal title or (data) => string
* @param {object[]|function} props.fields - Form field descriptors or (data) => object[]
* @param {object} props.submit - Submit configuration
* @param {string|function} props.submit.url - API URL or (data) => string
* @param {string} [props.submit.method] - HTTP method (default: 'POST')
* @param {function} [props.submit.body] - (data) => object
* @param {function} [props.submit.validate] - (body) => string|null
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
* @returns {function} (data) => void, calls openModal
*/
export function QuickModal(props = {}) {
return (data) => {
const title = typeof props.title === 'function' ? props.title(data) : props.title;
const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields;
const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url;
openModal((inner) => {
let actions;
if (props.handler) {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
label: props.submitLabel || 'Submit',
cls: 'btn-primary',
action: 's',
handler: () => props.handler(data, () => closeModal()),
},
];
} else {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url,
method: props.submit.method || 'POST',
body: props.submit.body ? () => props.submit.body(data) : undefined,
validate: props.submit.validate,
successMsg: typeof props.submit.successMsg === 'function'
? props.submit.successMsg(data)
: (props.submit.successMsg || 'Done'),
refresh: props.refresh || undefined,
closeModal: () => closeModal(),
}),
];
}
formModal(inner, title, fields, actions);
if (props.postRender) props.postRender(inner, data);
});
};
}