diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js
index 29b4a27..f06dbce 100644
--- a/webui/static/pages/interfaces.js
+++ b/webui/static/pages/interfaces.js
@@ -1,4 +1,4 @@
-import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
+import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
@@ -45,6 +45,7 @@ export default definePage({
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
if (guard) return guard;
+ const set = dirtySet(state.network.data?.status);
const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {};
const zones = Object.keys(fwZones);
@@ -72,9 +73,10 @@ export default definePage({
};
});
- const rows = ifaces.map(iface =>
- html`
- | ${iface.name} |
+ const rows = ifaces.map(iface => {
+ const info = dirtyInfo(set, 'interfaces.' + iface.name);
+ return html`
+ | ${info.dirty ? PendingDot({}) : ''}${iface.name} |
${String(iface.mac || 'N/A')} |
${(iface.ips || []).join(', ') || 'N/A'} |
<${StatusText} status=${iface.state} /> |
@@ -84,8 +86,8 @@ export default definePage({
-
`
- );
+ `;
+ });
return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js
index a8e1196..903e757 100644
--- a/webui/static/pages/nat.js
+++ b/webui/static/pages/nat.js
@@ -1,4 +1,4 @@
-import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js';
+import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -33,6 +33,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard;
+ const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
@@ -82,8 +83,9 @@ export default definePage({
${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'} |
`;
}
- return html`
- | ${zone} |
+ const info = fwInfo(fw, zone, 'masquerade');
+ return html`
+ | ${info.dirty ? PendingDot({}) : ''}${zone} |
<${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /> |
<${ActionButton}
@@ -102,8 +104,9 @@ export default definePage({
forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
- fwRows.push(html` |
- | ${zone} |
+ const info = fwInfo(fw, zone, 'forward_ports');
+ fwRows.push(html`
+ | ${info.dirty ? PendingDot({}) : ''}${zone} |
<${Badge} text=${proto || 'tcp'} variant="info" /> |
${port} |
${fwd['to-addr'] || fwd.toaddr || '-'} |
diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js
index e1c47ca..e8772ee 100644
--- a/webui/static/pages/proxy.js
+++ b/webui/static/pages/proxy.js
@@ -1,4 +1,4 @@
-import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js';
+import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
import { openBackendModal } from '/static/pages/backends.js';
function certLookup(acmeData) {
@@ -172,8 +172,9 @@ function editDomain(d, state) {
modal({});
}
-function domainRow(domainName, domainPaths, state) {
+function domainRow(domainName, domainPaths, state, set) {
const d = domainPaths[0];
+ const info = dirtyInfo(set, 'domains.' + domainName);
const certMap = certLookup(state.acme ? state.acme.data : null);
const cert = certMap[d.domain];
let certBadge, certTitle;
@@ -199,8 +200,8 @@ function domainRow(domainName, domainPaths, state) {
if (flags.length) parts.push(flags.join(', '));
return parts.join(' → ');
});
- return html`
- | ${esc(domainName)} |
+ return html`
+ | ${info.dirty ? PendingDot({}) : ''}${esc(domainName)} |
${pathSummaries} |
${certBadge} |
${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`} |
@@ -216,9 +217,10 @@ function domainRow(domainName, domainPaths, state) {
`;
}
-function backendSection(section, state) {
+function backendSection(section, state, set) {
const { backendName, backend, domains } = section;
- const rows = domains.map(d => domainRow(d.domain, d.paths, state));
+ const info = dirtyInfo(set, 'backends.' + backendName);
+ const rows = domains.map(d => domainRow(d.domain, d.paths, state, set));
const sectionActions = [];
if (!backend.builtin) {
sectionActions.push(html`
`);
@@ -233,7 +235,7 @@ function backendSection(section, state) {
}
}
sectionActions.push(html`
`);
- return html`
+ return html`
<${Badge} text=${esc(backendName)} variant="primary" />
@@ -258,10 +260,11 @@ export default definePage({
render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard;
+ const set = dirtySet(state.nginx.data?.status);
const domains = state.nginx.data.domains || [];
const backends = state.backends.data || {};
const sections = _groupByBackend(domains, backends);
- const sectionVNodes = sections.map(s => backendSection(s, state));
+ const sectionVNodes = sections.map(s => backendSection(s, state, set));
const actions = ActionGroup(
html``,
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
diff --git a/webui/static/pages/rules.js b/webui/static/pages/rules.js
index a3e93d6..9248ee7 100644
--- a/webui/static/pages/rules.js
+++ b/webui/static/pages/rules.js
@@ -1,4 +1,4 @@
-import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js';
+import { h, html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js';
const addRule = QuickModal({
title: 'Add Rich Rule',
@@ -24,6 +24,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
if (guard) return guard;
+ const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {};
const zones = Object.keys(state.firewall.data?.zones || {});
const zoneData = cfg.zones || {};
@@ -34,6 +35,7 @@ export default definePage({
});
const cards = Object.entries(zoneRules).map(([zone, rules]) => {
+ const info = fwInfo(fw, zone, 'rich_rules');
const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
@@ -50,8 +52,12 @@ export default definePage({
`;
});
return Card({
- header: 'Zone: ' + esc(zone),
+ header: info.dirty
+ ? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)])
+ : 'Zone: ' + esc(zone),
key: zone,
+ cls: info.class || undefined,
+ title: info.title || undefined,
children: [Table({
columns: ['#', 'Rule', 'Action'],
rows: ruleRows,
diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js
index 182b806..5bb9086 100644
--- a/webui/static/pages/wireguard.js
+++ b/webui/static/pages/wireguard.js
@@ -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`
- | ${esc(k)} |
+ const info = dirtyInfo(set, 'access_classes.' + k);
+ return html`
+ | ${info.dirty ? PendingDot({}) : ''}${esc(k)} |
${esc(v.name || k)} |
${esc(v.description || '-')} |
${esc(v.subnet || '-')} |
@@ -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.` 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`
+ const info = dirtyInfo(set, 'peers.' + p.name);
+ const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined;
+ return html`
+ ${info.dirty ? PendingDot({}) : ''}
<${StatusDot} status=${isConnected ? 'success' : 'danger'} />
${esc(p.name || 'unnamed')}
${p.description ? html` ${esc(p.description)}` : ''}
@@ -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`
+ const info = dirtyInfo(set, 'access_classes.' + k);
+ return html`
|