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
+19 -7
View File
@@ -1,5 +1,5 @@
/** WireGuard page — tunnel & peer management. */
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js';
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr, PendingDot, dirtySet, dirtyInfo, orphanInfo } from '/static/hoover/index.js';
/* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() {
@@ -351,6 +351,7 @@ function renderAccessClasses(config, status) {
}
const classStatuses = status?.classes || {};
const set = dirtySet(status);
const rows = entries.map(([k, v]) => {
const pCount = peerCountMap[k] || 0;
@@ -358,8 +359,9 @@ function renderAccessClasses(config, status) {
const isUp = clsStatus.up;
const hasKeys = classHasKeys(v);
const color = classColor(k);
return html`<tr key=${k}>
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
const info = dirtyInfo(set, 'access_classes.' + k);
return html`<tr key=${k} class=${info.class || undefined} title=${info.title || undefined}>
<td style="border-left: 3px solid ${color}">${info.dirty ? PendingDot({}) : ''}<strong>${esc(k)}</strong></td>
<td>${esc(v.name || k)}</td>
<td class="text-sm">${esc(v.description || '-')}</td>
<td class="text-sm">${esc(v.subnet || '-')}</td>
@@ -418,12 +420,16 @@ export default definePage({
const wgData = state.wireguard.data;
const st = wgData?.status || {};
const config = wgData?.config || {};
const set = dirtySet(st);
const isUp = st.up || false;
const listenPort = config.interface?.listen_port || '-';
const serverEndpoint = config.interface?.server_endpoint || '';
// Build merged peer rows: configured peers + live status
const configuredPeers = wgData?.peers || [];
// A removed peer leaves a `peers.<name>` pending path with no live row
// to attach a per-row marker to; surface it on the table itself.
const peersOrphan = orphanInfo(set, 'peers', configuredPeers.map(p => 'peers.' + p.name));
const statusPeersMap = {};
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
for (const sp of (cSt.peers || [])) {
@@ -442,9 +448,11 @@ export default definePage({
const isConnected = sp && !!sp.latest_handshake;
const accessClass = p.access_class;
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
return html`<tr key=${p.name}${borderColor}>
const info = dirtyInfo(set, 'peers.' + p.name);
const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined;
return html`<tr key=${p.name} class=${info.class || undefined} title=${info.title || undefined} style=${style}>
<td>
${info.dirty ? PendingDot({}) : ''}
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
<strong>${esc(p.name || 'unnamed')}</strong>
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
@@ -480,7 +488,8 @@ export default definePage({
const isUp = cSt.up;
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
const color = classColor(k);
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
const info = dirtyInfo(set, 'access_classes.' + k);
return html`<div key=${k} class="card ${info.class}" title=${info.title || undefined} style="border-left: 3px solid ${color}">
<div class="card-header d-flex justify-content-between align-items-center">
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
@@ -502,9 +511,10 @@ export default definePage({
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
}
const ifaceInfo = dirtyInfo(set, 'interface');
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title=${'Interface Settings' + (ifaceInfo.dirty ? ' — ' + ifaceInfo.title : '')}>${ifaceInfo.dirty ? PendingDot({}) : ''}\u{1F527}</button>`,
ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
@@ -531,6 +541,8 @@ export default definePage({
? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows,
cls: peersOrphan.class || undefined,
title: peersOrphan.title || undefined,
})
: Empty({ text: 'No peers configured. Add a peer above.' }),
renderAccessClasses(config, st),