Files
vacuum-wall/webui/static/hoover/components/modal.js
T
mteehan ac52918df5 firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync
Post-DHCP-incident hardening per HARDEN.md.

- apply guard: refuse (ConflictError, `force` overrides) when a
  network-managed interface would end up in no zone; absent
  `interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
  + advisory in /api/status/pending; zones.js banner + interfaces-picker
  last-zone confirm
- target drift (Option A): absent or default-normalizing target is
  unmanaged: not diffed, never re-set by apply; create_zone runs
  --new-zone first and sets non-default targets only; importer omits
  the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
  deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
  ({timestamp, default_zone, zones, config}); drop the empty post-apply
  skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
  teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
  (get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
  867 passing
2026-08-28 23:38:21 +00:00

424 lines
18 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.
*
* Renders a scrollable, filtered checkbox list (not a native
* `<select multiple>`). Options are sorted; each row may carry optional
* description text. A live search box filters rows in place (typing does
* not re-render the modal, so input focus is preserved), a counter shows
* "N of M selected", and Select all / Clear act on the currently visible
* rows.
*
* When `props.common` is a non-empty array an advanced toggle appears:
* cleared (the default) the list shows only common options plus anything
* currently selected; checked it shows every option. Deselecting a
* non-common option while the advanced toggle is cleared hides its row
* again.
*
* Selection, the search query, and the advanced flag live in a closure per
* open call, so `refreshModals()` re-renders (e.g. the processing spinner)
* re-apply the current state instead of losing it.
*
* @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 {object} [props.descriptions] - Option value → description text
* @param {string[]} [props.common] - When set, enables the advanced toggle
* @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 allOpts = [...new Set(props.options || [])].sort();
const sel = new Set(props.selected || []);
const hasAdv = Array.isArray(props.common) && props.common.length > 0;
const descs = props.descriptions || {};
let query = '';
let advanced = false;
openModal((inner) => {
formModal(inner, props.title, [],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url: props.url,
body: () => ({ [props.fieldKey]: [...sel].sort() }),
successMsg: props.successMsg || 'Updated',
refresh: props.refresh,
confirm: props.confirm,
closeModal: () => closeModal(),
}),
],
);
const body = inner.querySelector('.modal-body');
const showSearch = allOpts.length > 8;
const rowsHtml = allOpts.map((o) => {
const d = descs[o];
return '<label class="ms-row" data-ms-value="' + att_esc(o) + '">'
+ '<input type="checkbox" class="ms-check"' + (sel.has(o) ? ' checked' : '') + '>'
+ '<span class="ms-name">' + esc(o) + '</span>'
+ (d ? '<span class="ms-desc">' + esc(d) + '</span>' : '')
+ '</label>';
}).join('');
body.innerHTML = '<div class="ms-picker">'
+ (showSearch
? '<div class="ms-toolbar">'
+ '<input type="search" class="ms-search" id="ms-search-' + att_esc(props.fieldKey) + '"'
+ ' placeholder="Filter…" value="' + att_esc(query) + '">'
+ '<span class="ms-count"></span>'
+ '</div>' : '<div class="ms-toolbar"><span class="ms-count"></span></div>')
+ '<div class="ms-subbar">'
+ '<button type="button" class="btn-link ms-selall">Select all</button>'
+ '<button type="button" class="btn-link ms-clear">Clear</button>'
+ (hasAdv
? '<label class="ms-advanced"><input type="checkbox" class="ms-adv-check"'
+ (advanced ? ' checked' : '') + '>Show all ' + allOpts.length + ' options</label>'
: '')
+ '</div>'
+ '<div class="ms-list">' + rowsHtml + '</div>'
+ '<div class="ms-empty" hidden></div>'
+ '</div>';
const rows = [...body.querySelectorAll('.ms-row')];
const countEl = body.querySelector('.ms-count');
const searchEl = body.querySelector('.ms-search');
const advEl = body.querySelector('.ms-adv-check');
const emptyEl = body.querySelector('.ms-empty');
const isVisible = (o) => {
if (query && !o.toLowerCase().includes(query)) return false;
if (!hasAdv || advanced) return true;
return sel.has(o) || props.common.includes(o);
};
const apply = () => {
let visibleCount = 0;
for (const row of rows) {
const show = isVisible(row.dataset.msValue);
row.hidden = !show;
if (show) visibleCount++;
}
if (emptyEl) {
emptyEl.hidden = visibleCount > 0;
emptyEl.textContent = allOpts.length === 0
? 'Nothing to select.'
: 'No matches for "' + (query || '') + '".';
}
countEl.textContent = sel.size + ' of ' + allOpts.length + ' selected';
};
const syncRowChecks = () => {
for (const row of rows) {
row.querySelector('.ms-check').checked = sel.has(row.dataset.msValue);
}
};
if (searchEl) {
searchEl.addEventListener('input', () => {
query = searchEl.value.trim().toLowerCase();
apply();
});
}
if (advEl) {
advEl.addEventListener('change', () => {
advanced = advEl.checked;
apply();
});
}
for (const row of rows) {
const cb = row.querySelector('.ms-check');
cb.addEventListener('change', () => {
const o = row.dataset.msValue;
if (cb.checked) sel.add(o);
else sel.delete(o);
apply();
});
}
body.querySelector('.ms-selall').addEventListener('click', () => {
for (const row of rows) if (!row.hidden) sel.add(row.dataset.msValue);
syncRowChecks();
apply();
});
body.querySelector('.ms-clear').addEventListener('click', () => {
for (const row of rows) if (!row.hidden) sel.delete(row.dataset.msValue);
syncRowChecks();
apply();
});
apply();
});
};
}
/**
* 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);
});
};
}