ac52918df5
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
424 lines
18 KiB
JavaScript
424 lines
18 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|
||
};
|
||
}
|