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
+22 -11
View File
@@ -1,4 +1,4 @@
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm } from '/static/hoover/index.js';
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
function makeAddRange(activeZones, interfaces) {
const opts = [
@@ -118,6 +118,7 @@ export default definePage({
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
if (guard) return guard;
const set = dirtySet(state.dnsmasq.data?.status);
const cfg = state.dnsmasq.data?.config || {};
const dhcpCfg = cfg.dhcp || {};
const dnsCfg = cfg.dns || {};
@@ -126,8 +127,10 @@ export default definePage({
const dnsRecords = dnsCfg.custom_records || [];
const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
<td>${r.interface || '(global)'}</td>
const rangesRows = ranges.map((r, i) => {
const info = dirtyInfo(set, 'dhcp.ranges[' + i + ']');
return html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}${r.interface || '(global)'}</td>
<td>${esc(r.start)}</td>
<td>${esc(r.end)}</td>
<td>${esc(r.lease_time || '12h')}</td>
@@ -139,10 +142,13 @@ export default definePage({
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed" />
</td>
</tr>`);
</tr>`;
});
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
<td>${esc(l.mac)}</td>
const leaseRows = staticLeases.map((l, i) => {
const info = dirtyInfo(set, 'dhcp.static_leases[' + i + ']');
return html`<tr key=${l.mac} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}${esc(l.mac)}</td>
<td>${esc(l.ip)}</td>
<td>${l.hostname || '-'}</td>
<td>
@@ -152,10 +158,13 @@ export default definePage({
message=${'Remove lease ' + l.mac + '?'}
success="Lease removed" />
</td>
</tr>`);
</tr>`;
});
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
<td><strong>${esc(rec.name || 'unnamed')}</strong></td>
const dnsRows = dnsRecords.map((rec, i) => {
const info = dirtyInfo(set, 'dns.custom_records[' + i + ']');
return html`<tr key=${rec.name} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(rec.name || 'unnamed')}</strong></td>
<td class="text-sm">${esc(rec.address || '-')}</td>
<td>
<${ConfirmDelete}
@@ -164,7 +173,8 @@ export default definePage({
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed" />
</td>
</tr>`);
</tr>`;
});
const _setDomain = async (domain) => {
const res = await apiFetch('/api/dhcp/domain', {
@@ -180,7 +190,8 @@ export default definePage({
};
const currentDomain = dnsCfg.domain || null;
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
const domainInfo = dirtyInfo(set, 'dns.domain');
const domainSection = html`<div class="domain-config ${domainInfo.class}" title=${domainInfo.title || undefined} style="margin-bottom: 1rem;">
<label style="font-weight: 600;">Search Domain</label>
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">