/** * 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?v=8'; import { html } from '../html.js?v=8'; import { reactive } from '../reactivity.js?v=8'; import { apiFetch, toast } from '../api.js?v=8'; import { modelFetch } from '../model.js?v=8'; import { openModal, closeModal, modalVNodes } from './modal.js?v=8'; 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; } /** * POST apply-all, toast result, close modal, refresh models. */ async function doApply(successMsg, refreshTargets) { const resp = await apiFetch('/api/status/apply-all', { method: 'POST' }); if (resp.ok) { toast(successMsg, 'success'); closeModal(); if (refreshTargets) { const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets]; names.forEach(n => modelFetch(n)); } } else { toast(resp.error || 'Apply failed', 'error'); } } /** * Fetch pending state, then open the confirmation modal. */ async function openApplyModal(successMsg, refreshTargets) { 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) => { const rows = buildRows(pendingData, expanded); if (totalChanges === 0) { modalVNodes(inner, html`
No pending changes to apply.
`); return; } modalVNodes(inner, 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] - Model name(s) to refresh after apply */ 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.refresh); }, }, props.pending ? label : syncedLabel); }