state: applied-config snapshots + per-field pending diffs
- lib/common: stamp_applied() now records a _last_applied_config snapshot alongside the hash; strip_apply_meta() centralizes bookkeeping-key stripping; deep_diff() reports field-level changes - state collectors (dnsmasq/nginx/wireguard/networkd) expose pending_diff so the dashboard can show exactly which fields changed since the last apply (wireguard diff excludes private_key paths) - dashboard pending-changes card renders per-change lines with a generic fallback when no snapshot is recorded - firewall: firewalld built-in zones no longer flagged as unmanaged; public-zone masquerade skipped in pending changes since apply drives it via nftables propagation - schema: PendingChange TypedDict; pending_diff on DnsmasqStatus / WgStatus; tests in test_common.py, test_firewall.py, test_state.py
This commit is contained in:
@@ -1,5 +1,45 @@
|
||||
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, fmtBytes } from '/static/hoover/index.js';
|
||||
|
||||
// Render a single firewall change as "current → new".
|
||||
// `live` is the currently applied value; `config` is the target value it
|
||||
// will become on apply. Mirrors lib.firewall.fw_change_summary.
|
||||
function fwChangeLine(c) {
|
||||
const zone = c.zone || 'unknown';
|
||||
const type = c.type || 'unknown';
|
||||
const join = (arr) => (Array.isArray(arr) && arr.length ? arr.join(', ') : '∅');
|
||||
switch (type) {
|
||||
case 'interfaces':
|
||||
return `Zone ${zone}: interfaces ${join(c.live)} → ${join(c.config)}`;
|
||||
case 'services':
|
||||
return `Zone ${zone}: services ${join(c.live)} → ${join(c.config)}`;
|
||||
case 'rich_rules':
|
||||
return `Zone ${zone}: rich rules ${c.live_count ?? 0} → ${c.config_count ?? 0}`;
|
||||
case 'forward_ports':
|
||||
return `Zone ${zone}: port forwards ${c.live_count ?? 0} → ${c.config_count ?? 0}`;
|
||||
case 'masquerade':
|
||||
return `Zone ${zone}: masquerade ${c.live} → ${c.config}`;
|
||||
case 'target':
|
||||
return `Zone ${zone}: target ${c.live ?? 'default'} → ${c.config ?? 'default'}`;
|
||||
default:
|
||||
return `Zone ${zone}: ${type} changed`;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtVal(v) {
|
||||
if (Array.isArray(v)) return v.length ? v.join(', ') : '∅';
|
||||
if (v === null || v === undefined) return '∅';
|
||||
if (typeof v === 'boolean') return v;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Render a single field-level pending change (applied → current).
|
||||
function diffLine(d) {
|
||||
const val = (x) => ` ${fmtVal(x)}`;
|
||||
if (d.action === 'added') return `${d.path}: added (now${val(d.new)})`;
|
||||
if (d.action === 'removed') return `${d.path}: removed (was${val(d.old)})`;
|
||||
return `${d.path}: ${fmtVal(d.old)} → ${fmtVal(d.new)}`;
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return {
|
||||
@@ -42,14 +82,34 @@ export default definePage({
|
||||
// Pending changes — derived from the config-backed subsystem models.
|
||||
// Firewall uses pending.needs_apply (config_pending() output); all
|
||||
// others use status.pending_changes. `system` is metrics-only.
|
||||
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k => {
|
||||
const model = getModel(k === 'networkd' ? 'network' : k);
|
||||
const d = model.data || {};
|
||||
if (k === 'firewall') return !!d.pending?.needs_apply;
|
||||
return !!d.status?.pending_changes;
|
||||
});
|
||||
const totalChanges = pendKeys.length;
|
||||
//
|
||||
// Each entry is { label, lines: [string] } so the card can render a
|
||||
// concise per-change summary. Firewall and the hash-based subsystems
|
||||
// both carry real per-field diffs (applied → current).
|
||||
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
|
||||
const pendingBlocks = [];
|
||||
|
||||
const fwPending = state.firewall.data?.pending || {};
|
||||
if (fwPending.needs_apply) {
|
||||
const lines = (fwPending.pending || []).map(fwChangeLine);
|
||||
for (const z of Object.keys(fwPending.unmanaged_zones || {})) {
|
||||
lines.push(`Zone ${z}: unmanaged (not in config)`);
|
||||
}
|
||||
pendingBlocks.push({ label: pendLabels.firewall, lines });
|
||||
}
|
||||
|
||||
for (const k of ['dnsmasq', 'nginx', 'wireguard', 'networkd']) {
|
||||
const d = getModel(k === 'networkd' ? 'network' : k).data || {};
|
||||
if (d.status?.pending_changes) {
|
||||
const diff = d.status.pending_diff;
|
||||
const lines = (Array.isArray(diff) && diff.length)
|
||||
? diff.map(diffLine)
|
||||
: ['configuration saved but not applied yet'];
|
||||
pendingBlocks.push({ label: pendLabels[k], lines });
|
||||
}
|
||||
}
|
||||
|
||||
const totalChanges = pendingBlocks.reduce((n, b) => n + b.lines.length, 0);
|
||||
|
||||
// Build merged interface list
|
||||
const allNames = [...new Set([...fwIfaces.map(f => f.name), ...Object.keys(netIfaces)])];
|
||||
@@ -85,11 +145,18 @@ export default definePage({
|
||||
</div>`;
|
||||
|
||||
// ── Pending changes ──
|
||||
const pendingCard = pendKeys.length > 0
|
||||
const pendingCard = pendingBlocks.length > 0
|
||||
? html`<div class="card">
|
||||
<div class="card-header">Pending Changes <span style="margin-left:8px"><${Badge} text=${String(totalChanges)} variant="warning" /></span></div>
|
||||
<div class="card-body">
|
||||
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
|
||||
<ul class="pending-list">
|
||||
${pendingBlocks.map(b => html`<li>
|
||||
<strong>${b.label}</strong>
|
||||
<ul>
|
||||
${b.lines.map(line => html`<li class="text-sm">${line}</li>`)}
|
||||
</ul>
|
||||
</li>`)}
|
||||
</ul>
|
||||
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
||||
successMsg="All changes applied"
|
||||
cls="btn btn-sm btn-primary" />
|
||||
|
||||
Reference in New Issue
Block a user