/**
* 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;
}
/**
* POST apply-all, toast result, close modal. State-store models update from
* the daemon's WS delta — no explicit refresh.
*/
async function doApply(successMsg) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
if (resp.ok) {
toast(successMsg, '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({});
openModal((inner) => {
const rows = buildRows(pendingData, expanded);
if (totalChanges === 0) {
modalVNodes(inner, html`
Confirm: Apply All Changes
No pending changes to apply.
`);
return;
}
modalVNodes(inner, html`
Confirm: Apply All Changes
${rows}
`);
});
}
/**
* 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`
Confirm: Cancel All Changes
No pending changes to cancel.
`);
return;
}
const rows = buildRows(pendingData, expanded);
modalVNodes(inner, html`
Confirm: Cancel All Changes
Restores the listed subsystems to their last applied configuration, discarding changes saved since the last apply.
${rows}
`);
});
}
/**
* 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);
}