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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user