55309cfd86
- lib.common.revert_to_applied(): restore a config file from its
_last_applied_config snapshot (stamped hash); no baseline -> skip with
reason, file untouched
- firewall config_apply now stamps the applied baseline like the other
subsystems; GET /firewall/config and the state collector strip the
internal _last_applied_* keys
- POST /status/cancel-all + /api/status/cancel-all: revert pending
subsystems, {cancelled, skipped, errors}, partial-failure safe
- dashboard: "Cancel All Changes" button with confirm modal
(CancelConfirm, reuses the pending-changes modal rows); the pending
changes card is hidden entirely when nothing is pending
- tests: revert_to_applied, status_cancel_all, firewall stamping/meta
stripping, /api/status/cancel-all route, node tests for CancelConfirm;
firewall _config_apply tests no longer write the real repo config
- docs: api.md, state-model.md, config.md, hoover.md
231 lines
8.4 KiB
JavaScript
231 lines
8.4 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;
|
|
}
|
|
|
|
/**
|
|
* 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`<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>
|
|
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">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);
|
|
}
|