/** * 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`
${sub.label} ${hasPending ? changes.length + ' pending changes' : 'Up to date'} ${hasPending ? html`\u25B6` : ''}
`); if (hasPending && isExpanded) { vnodeList.push(html`
${changes.map(c => html`
${c.summary || c.detail || c}
`)}
`); } } 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`
No pending changes to apply.
`); return; } modalVNodes(inner, html`
${fwPending ? html`` : ''}
`); }); } /** * 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`
No pending changes to cancel.
`); return; } const rows = buildRows(pendingData, expanded); modalVNodes(inner, html`

Restores the listed subsystems to their last applied configuration, discarding changes saved since the last apply.

`); }); } /** * 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); }