ui: amber pending-edit markers for unapplied config changes

Add hoover/dirty.js: line-matching helpers that flag UI rows/cards
edited (saved to config) but not yet applied, consuming the pending
state the daemon already streams — status.pending_diff for hash
subsystems, firewall pending zone+type for firewalld. Visual language
is amber (.config-dirty + PendingDot), distinct from the red
.pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard
peers) on their container table. Wired into the backends, dhcp,
interfaces, nat, proxy, rules, wireguard, and zones pages; Card and
Table gain cls/title props. Covered by 27 node tests
(tests/test-dirty.js).
This commit is contained in:
2026-09-01 20:17:15 +00:00
parent 75b86fd60d
commit 89b64960f3
14 changed files with 649 additions and 61 deletions
+12 -6
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js';
import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal, PendingDot, fwDirty, fwInfo, fwTitle } from '/static/hoover/index.js';
// Services shown by default in the service picker. Everything else is only
// visible with the "Show all options" toggle (or while it is already
@@ -33,6 +33,8 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
// Live zone data (parsed `--list-all-zones`): carries interfaces,
// services, target, and masquerade for every defined zone.
const liveZones = state.firewall.data?.zones || {};
@@ -45,22 +47,26 @@ export default definePage({
const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : [];
return html`<div class="card" key=${name} style="position:relative">
const info = fwInfo(fw, name);
const ifTitle = fwTitle(fw, name, 'interfaces');
const svcTitle = fwTitle(fw, name, 'services');
const tgtTitle = fwTitle(fw, name, 'target');
return html`<div class="card ${info.class}" title=${info.title || undefined} key=${name} style="position:relative">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px">
<h3 style="font-size:16px;color:var(--accent)">${info.dirty ? PendingDot({}) : ''}${name}</h3>
<div class="text-muted text-sm" title=${tgtTitle || undefined} style="margin-bottom:10px">
${z.target ? 'Target: ' + esc(z.target) : ''}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-sm mb-4" title=${ifTitle || undefined}>
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
${ifacesArr.length
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
: html`<span class="text-muted">None</span>`}
</div>
<div class="text-sm mb-4">
<div class="text-sm mb-4" title=${svcTitle || undefined}>
<div class="text-muted" style="margin-bottom:4px">Services</div>
${svcsArr.length
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)