faa076370d
- move state collectors from lib/state.py to daemon/collectors/ (7 modules, registration side-effect; daemon/server.py imports the package before the first populate()) - webui/api: new daemon_route() decorator factory in common.py collapses the try/except daemon-proxy boilerplate in all 8 blueprints (rules/params/body/transform keep responses identical) - firewall: interface-coverage invariant — config is the source of truth for zone interfaces (absent key = empty, no hands-off zones); pure validate_coverage() enforced at save (400) and apply (409, force: true overrides), top-level `unmanaged` exemption - lib: get_config() reads are now pure (no dir creation or writes); new lib/bootstrap.py creates runtime dirs and persists the one-shot nginx legacy migration at daemon start, after system_import (lib.nginx.migrate_config_file) - lib/common: compute_pending() apply-bookkeeping helper - daemon: emit_and_refresh() handler helper; refresh_state(bump=) so /status/refresh no longer bumps versions (poll/mutation only) - acme: move --log last so acme.sh never treats a real arg as the log-file argument - docs: AGENTS.md, config.md, state-model.md, api.md updated; HARDEN.md dropped (plan implemented); apply-confirm force wording Tests: 917 passed; ruff check + format clean.
278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
/**
|
||
* Hoover — components/applyconfirm.js
|
||
*
|
||
* Apply button with cross-subsystem confirmation modal.
|
||
* Fetches pending changes from /api/status/pending, shows them in an
|
||
* expandable modal, then applies all via /api/status/apply-all.
|
||
*/
|
||
|
||
import { h } from '../vdom.js';
|
||
import { html } from '../html.js';
|
||
import { reactive } from '../reactivity.js';
|
||
import { apiFetch, toast } from '../api.js';
|
||
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js';
|
||
|
||
export const SUBSYSTEM_LIST = [
|
||
{ key: 'firewall', label: 'Firewall' },
|
||
{ key: 'dnsmasq', label: 'DHCP/DNS' },
|
||
{ key: 'nginx', label: 'Nginx' },
|
||
{ key: 'wireguard', label: 'WireGuard' },
|
||
{ key: 'networkd', label: 'Network' },
|
||
];
|
||
|
||
/**
|
||
* Extract pending state from a subsystem result.
|
||
* Handles firewall's `needs_apply` vs hash subsystems' `pending_changes`.
|
||
*/
|
||
export function isPending(ss) {
|
||
return (ss.needs_apply || ss.pending_changes || false);
|
||
}
|
||
|
||
/**
|
||
* Build the VNode array for modal rows given pending data and expanded state.
|
||
*/
|
||
export function buildRows(pendingData, expanded) {
|
||
const vnodeList = [];
|
||
|
||
for (const sub of SUBSYSTEM_LIST) {
|
||
const ss = pendingData[sub.key] || {};
|
||
const changes = ss.changes || [];
|
||
const hasPending = isPending(ss) && changes.length > 0;
|
||
const isExpanded = !!expanded[sub.key];
|
||
|
||
vnodeList.push(html`<div class="apply-subsystem-row${hasPending ? ' pending' : ''}">
|
||
<span class="apply-subsystem-name">${sub.label}</span>
|
||
<span class="apply-subsystem-status${hasPending ? ' pending' : ''}">${hasPending ? changes.length + ' pending changes' : 'Up to date'}</span>
|
||
${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : ''}">\u25B6</span>` : ''}
|
||
</div>`);
|
||
|
||
if (hasPending && isExpanded) {
|
||
vnodeList.push(html`<div class="apply-detail-section">${changes.map(c => html`<div class="apply-detail-item">${c.summary || c.detail || c}</div>`)}</div>`);
|
||
}
|
||
}
|
||
|
||
return vnodeList;
|
||
}
|
||
|
||
/**
|
||
* Decide the toasts for an apply-all response payload.
|
||
*
|
||
* The endpoint returns 200 with `{ applied, errors }` even when some
|
||
* subsystems failed (e.g. the firewall safety guards refused a change), so
|
||
* `resp.ok` alone is not a success signal. An error always wins: when any
|
||
* subsystem failed, report it and suppress the success toast.
|
||
*
|
||
* @param {object} data – Response payload `{ applied, errors }`
|
||
* @param {string} [successMsg] – Message for the success toast
|
||
* @returns {{error: string|null, success: string|null}}
|
||
*/
|
||
export function applyResultToasts(data, successMsg) {
|
||
const errs = (data && data.errors) || {};
|
||
const entries = Object.entries(errs);
|
||
if (entries.length) {
|
||
return {
|
||
error: 'Apply failed for: ' +
|
||
entries.map(([k, v]) => `${k} — ${v}`).join('; '),
|
||
success: null,
|
||
};
|
||
}
|
||
const applied = (data && data.applied) || [];
|
||
return {
|
||
error: null,
|
||
success: applied.length ? successMsg : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* POST apply-all, toast result, close modal. State-store models update from
|
||
* the daemon's WS delta — no explicit refresh.
|
||
*
|
||
* @param {string} successMsg – Success toast message
|
||
* @param {boolean} [force] – Forward `{"force": true}` to override the
|
||
* firewall safety guards
|
||
*/
|
||
async function doApply(successMsg, force) {
|
||
if (isModalProcessing()) return;
|
||
setModalProcessing(true);
|
||
try {
|
||
const opts = { method: 'POST' };
|
||
if (force) opts.body = { force: true };
|
||
const resp = await apiFetch('/api/status/apply-all', opts);
|
||
if (resp.ok) {
|
||
const t = applyResultToasts(resp.data, successMsg);
|
||
if (t.error) toast(t.error, 'error', 8000);
|
||
else if (t.success) toast(t.success, 'success');
|
||
closeModal();
|
||
// No modelFetch — WS delta updates all affected subsystems.
|
||
} else {
|
||
toast(resp.error || 'Apply failed', 'error');
|
||
}
|
||
} finally {
|
||
setModalProcessing(false);
|
||
refreshModals();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch pending state, then open the confirmation modal.
|
||
*/
|
||
async function openApplyModal(successMsg) {
|
||
const pendingResp = await apiFetch('/api/status/pending');
|
||
if (!pendingResp.ok) {
|
||
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
|
||
return;
|
||
}
|
||
|
||
const pendingData = pendingResp.data || {};
|
||
const totalChanges = pendingData.total_changes || 0;
|
||
|
||
const expanded = reactive({});
|
||
// Only meaningful when the firewall has pending changes (the only
|
||
// subsystem whose apply honours `force`); the checkbox tracks its own
|
||
// DOM state — no reactivity needed.
|
||
const fwPending = isPending(pendingData.firewall) &&
|
||
((pendingData.firewall.changes || []).length > 0);
|
||
let force = false;
|
||
|
||
openModal((inner) => {
|
||
const rows = buildRows(pendingData, expanded);
|
||
|
||
if (totalChanges === 0) {
|
||
modalVNodes(inner, html`<div>
|
||
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
||
<div class="apply-no-changes">No pending changes to apply.</div>
|
||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button></div>
|
||
</div>`);
|
||
return;
|
||
}
|
||
|
||
modalVNodes(inner, html`<div>
|
||
<h2 class="modal-title">Confirm: Apply All Changes</h2>
|
||
<div class="modal-body">${rows}</div>
|
||
${fwPending ? html`<label style="display:flex;gap:8px;align-items:center;margin-top:12px;cursor:pointer">
|
||
<input type="checkbox" checked=${force} onChange="${(e) => { force = e.target.checked; }}" />
|
||
<span class="text-sm">Force apply <span class="text-muted">— overrides firewall safety guards (e.g. removing an interface from all zones, or removing https/ssh from the default zone)</span></span>
|
||
</label>` : ''}
|
||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, force)}">Apply All</button></div>
|
||
</div>`);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply button with cross-subsystem confirmation modal.
|
||
*
|
||
* @param {object} props
|
||
* @param {boolean} props.pending - Whether any subsystem has pending changes
|
||
* @param {string} [props.label] - Apply button text (default: 'Apply')
|
||
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
|
||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced)
|
||
* @param {string} [props.successMsg] - Success toast message (default: 'All changes applied')
|
||
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
|
||
*/
|
||
export function ApplyConfirm(props = {}) {
|
||
const label = props.label || 'Apply';
|
||
const syncedLabel = props.syncedLabel || 'Synced';
|
||
const successMsg = props.successMsg || 'All changes applied';
|
||
|
||
return h('button', {
|
||
class: props.cls !== undefined
|
||
? props.cls
|
||
: (props.pending ? 'btn btn-primary' : 'btn btn-outline'),
|
||
'on:click': () => {
|
||
if (!props.pending) {
|
||
toast(successMsg || 'All synced', 'info');
|
||
return;
|
||
}
|
||
openApplyModal(successMsg);
|
||
},
|
||
}, props.pending ? label : syncedLabel);
|
||
}
|
||
|
||
/**
|
||
* POST cancel-all, toast result, close modal. State-store models update from
|
||
* the daemon's WS delta — no explicit refresh.
|
||
*/
|
||
async function doCancelAll() {
|
||
if (isModalProcessing()) return;
|
||
setModalProcessing(true);
|
||
try {
|
||
const resp = await apiFetch('/api/status/cancel-all', { method: 'POST' });
|
||
if (resp.ok) {
|
||
const data = resp.data || {};
|
||
let msg = 'Pending changes cancelled';
|
||
const nSkipped = Object.keys(data.skipped || {}).length;
|
||
if (nSkipped) {
|
||
msg += ` (${nSkipped} skipped: ` +
|
||
Object.entries(data.skipped).map(([k, v]) => `${k} — ${v}`).join('; ') + ')';
|
||
}
|
||
toast(msg, nSkipped ? 'warning' : 'success', nSkipped ? 8000 : undefined);
|
||
const errs = data.errors || {};
|
||
const nErrs = Object.keys(errs).length;
|
||
if (nErrs) {
|
||
toast('Cancel failed for: ' +
|
||
Object.entries(errs).map(([k, v]) => `${k} — ${v}`).join('; '),
|
||
'error', 8000);
|
||
}
|
||
closeModal();
|
||
// No modelFetch — WS delta updates all affected subsystems.
|
||
} else {
|
||
toast(resp.error || 'Cancel failed', 'error');
|
||
}
|
||
} finally {
|
||
setModalProcessing(false);
|
||
refreshModals();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch pending state, then open the cancel confirmation modal.
|
||
*/
|
||
async function openCancelModal() {
|
||
const pendingResp = await apiFetch('/api/status/pending');
|
||
if (!pendingResp.ok) {
|
||
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
|
||
return;
|
||
}
|
||
|
||
const pendingData = pendingResp.data || {};
|
||
const totalChanges = pendingData.total_changes || 0;
|
||
|
||
const expanded = reactive({});
|
||
|
||
openModal((inner) => {
|
||
if (totalChanges === 0) {
|
||
modalVNodes(inner, html`<div>
|
||
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
|
||
<div class="apply-no-changes">No pending changes to cancel.</div>
|
||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Close</button></div>
|
||
</div>`);
|
||
return;
|
||
}
|
||
|
||
const rows = buildRows(pendingData, expanded);
|
||
|
||
modalVNodes(inner, html`<div>
|
||
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
|
||
<p>Restores the listed subsystems to their last applied configuration, discarding changes saved since the last apply.</p>
|
||
<div class="modal-body">${rows}</div>
|
||
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Keep Changes</button><button class="btn btn-danger" onClick="${() => doCancelAll()}">Cancel All Changes</button></div>
|
||
</div>`);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Cancel button with cross-subsystem confirmation modal.
|
||
*
|
||
* @param {object} props
|
||
* @param {string} [props.label] - Button text (default: 'Cancel All Changes')
|
||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-danger')
|
||
*/
|
||
export function CancelConfirm(props = {}) {
|
||
const label = props.label || 'Cancel All Changes';
|
||
|
||
return h('button', {
|
||
class: props.cls !== undefined ? props.cls : 'btn btn-danger',
|
||
'on:click': () => openCancelModal(),
|
||
}, label);
|
||
}
|