Files
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- 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.
2026-09-03 00:40:56 +00:00

278 lines
10 KiB
JavaScript
Raw Permalink 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/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);
}