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
+65 -4
View File
@@ -947,9 +947,10 @@ StatusText({ status: iface.state })
Empty-state placeholder card. Empty-state placeholder card.
#### `Card({ header, children })` #### `Card({ header, children, cls, title })`
Card container with optional header. Card container with optional header. `cls` appends a class to the outer
`div.card`; `title` sets a tooltip on the outer div.
#### `ConfirmDelete(props)` #### `ConfirmDelete(props)`
@@ -1152,9 +1153,9 @@ ZoneSelect({
| `onChange` | `(zone) => void` callback | | `onChange` | `(zone) => void` callback |
| `placeholder` | Placeholder option text (optional) | | `placeholder` | Placeholder option text (optional) |
#### `Table({ columns, rows, emptyText, wrapCard, key })` #### `Table({ columns, rows, emptyText, wrapCard, key, cls, title })`
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes. Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes. `cls` appends a class to the wrapper (or `div.card`); `title` sets a tooltip on the wrapper.
```javascript ```javascript
Table({ Table({
@@ -1337,6 +1338,66 @@ re-apply the current state instead of losing it.
Render the toast notification container. Include in the main render root. See API section above. Render the toast notification container. Include in the main render root. See API section above.
## Dirty / pending-edit markers
`dirty.js` marks UI elements that have been edited (saved to config) but not yet
applied to the live system. It consumes the pending state the daemon already
streams — no extra API calls. Visual language: amber accent (`.config-dirty`) +
`PendingDot` + tooltip, distinct from the red `.pending-delete` (deletion) style.
#### `PendingDot()`
Small amber dot marking a pending (edited, not yet applied) element. Drop it into
the first cell of a dirty row, or next to a card/section heading.
### Hash subsystems (field-level)
Pending source: `status.pending_diff` — `[{path, action, old, new}]` where `path`
is a dotted config path (e.g. `dhcp.ranges[0].start`, `interface.listen_port`,
`domains.example.local.cert`).
| Function | Description |
|---|---|
| `dirtySet(status)` | `Set` of pending config paths from a subsystem `status` object (reads `status.pending_diff`; empty set when absent). When `status.pending_changes` is true but `pending_diff` is empty (config saved but never applied — no baseline to diff), the set is a *sentinel* that marks every element dirty |
| `isDirty(set, path)` | `true` when element path `path` is on a pending line (under / above / equal to a pending path); always `true` for the never-applied sentinel |
| `dirtyTitle(set, path)` | Tooltip text listing the concrete pending field(s) that affect `path` (empty string when clean); the sentinel reads "Configuration saved but not applied yet" |
| `dirtyInfo(set, path)` | `{dirty, class, title}` — `class` is `'config-dirty'` or `''`, `title` the tooltip or `''`. One object per element; apply `class`/`title` on the element |
| `orphanInfo(set, root, children)` | `{dirty, class, title}` for a container element: dirty when a pending path under `root` has **no** live child element to mark — e.g. a removed dict key (`peers.p1`) whose row no longer exists. `children` is the list of element paths for the container's live children (e.g. `'peers.' + name`). Clean when the set is the never-applied sentinel or when `root` itself is pending (every row is marked instead) |
**Line-matching rule**: an element path is dirty when it shares a root-to-leaf
line with a pending path — equal, an ancestor, or a descendant. A plain key is a
prefix of its indexed form (`ranges` prefixes `ranges[0]`), so a whole-list
change (e.g. `dhcp.ranges`) marks every row of that list, while a leaf change
(`interface.listen_port`) marks only that field/row. Matching is segment-based,
so dotted names (e.g. a domain `a.com.b`) can conservatively over-highlight a
parent-like row — never a false negative.
### Firewall (zone + type)
Pending source: `pending` — `{needs_apply, pending: [{zone, type, ...}]}` where
`type` ∈ `interfaces|services|target|masquerade|rich_rules|forward_ports`
(zone-level, not field-level).
| Function | Description |
|---|---|
| `fwDirty(pending)` | `Map<zone, Set<type>>` from a firewall `pending` object (empty map when absent) |
| `fwIsDirty(map, zone, type?)` | `true` when `zone` (and optionally `type`) has a pending change |
| `fwTitle(map, zone, type?)` | Tooltip listing the pending type(s) for the zone (empty string when clean) |
| `fwInfo(map, zone, type?)` | `{dirty, class, title}` — one object for a firewall element (zone, optional type) |
### Wiring conventions
- Compute the set **once** per `render()`, after the guard:
`const set = dirtySet(state.<subsystem>.data?.status)` or
`const fw = fwDirty(state.firewall.data?.pending)`.
- `h()` rows/cards: merge `{ class: info.class, title: info.title }` into the props object.
- `htm` rows/cards: `class="row ${info.class}"` + `title=${info.title || undefined}`;
drop `PendingDot({})` into the first cell when `info.dirty`.
- Container elements (tables/sections) whose children are dict keys: pass
`orphanInfo(set, root, childPaths)` as `cls`/`title` so removed entries —
which leave no row to mark — still surface on the container (WireGuard peers table).
- An empty `class`/`title` is harmless; prefer `|| undefined` for htm attrs.
## Helpers ## Helpers
| Function | Description | | Function | Description |
+270
View File
@@ -0,0 +1,270 @@
/**
* Tests for hoover/dirty.js — pending-edit marker matching.
*
* dirty.js has no imports — DOM-free at import, so the tests run under
* plain node (same pattern as test-model-set.js).
*
* Run with `node tests/test-dirty.js`.
*/
import { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from '../webui/static/hoover/dirty.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── dirtySet ────────────────────────────────────────────────── */
test('dirtySet collects pending paths from pending_diff', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[0].start', action: 'changed' },
{ path: 'dns.domain', action: 'added' },
]});
assert(set.has('dhcp.ranges[0].start'), 'first path collected');
assert(set.has('dns.domain'), 'second path collected');
assertEq(set.size, 2, 'exactly two paths');
});
test('dirtySet skips diff entries without a path', () => {
const set = dirtySet({ pending_diff: [null, {}, { action: 'changed' }, { path: 'a.b' }] });
assertEq(set.size, 1, 'only well-formed entries');
assert(set.has('a.b'), 'valid path collected');
});
test('dirtySet is empty when pending_diff is absent', () => {
assertEq(dirtySet(null).size, 0, 'null status');
assertEq(dirtySet({}).size, 0, 'empty status');
assertEq(dirtySet({ pending_diff: 'nope' }).size, 0, 'non-array pending_diff');
});
/* ── never-applied sentinel ──────────────────────────────────── */
test('dirtySet marks everything dirty when saved but never applied', () => {
const set = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(set.size, 1, 'sentinel only');
assert(isDirty(set, 'dhcp.ranges[0].start'), 'any path is dirty');
assert(isDirty(set, 'interface.listen_port'), 'any other path is dirty');
assertEq(dirtyTitle(set, 'dhcp.ranges[0].start'), 'Configuration saved but not applied yet', 'sentinel tooltip');
});
test('dirtySet has no sentinel when there is no pending state', () => {
const set = dirtySet({ pending_changes: false, pending_diff: [] });
assert(!isDirty(set, 'dhcp.ranges'), 'clean when nothing is pending');
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip when clean');
});
test('dirtySet has no sentinel when a real diff exists', () => {
const set = dirtySet({
pending_changes: true,
pending_diff: [{ path: 'dns.domain', action: 'changed' }],
});
assert(isDirty(set, 'dns.domain'), 'matching path is dirty');
assert(!isDirty(set, 'dhcp.ranges'), 'unrelated path stays clean');
assertEq(dirtyTitle(set, 'dns.domain'), 'Unapplied changes: dns.domain', 'normal tooltip, not the sentinel');
});
/* ── line matching ───────────────────────────────────────────── */
test('isDirty matches an exact pending leaf', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(isDirty(set, 'interface.listen_port'), 'equal path is dirty');
});
test('a pending list marks every indexed row (ancestor of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges', action: 'changed' }] });
for (const i of [0, 1, 12]) {
assert(isDirty(set, `dhcp.ranges[${i}]`), `row ${i} is dirty`);
assert(isDirty(set, `dhcp.ranges[${i}].start`), `row ${i} field is dirty`);
}
});
test('a pending row field marks the list (descendant of element)', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[0].start', action: 'changed' }] });
assert(isDirty(set, 'dhcp.ranges'), 'the list container is dirty');
assert(isDirty(set, 'dhcp'), 'the top-level container is dirty');
});
test('unrelated paths do not match', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges'), 'different root');
});
test('index brackets do not prefix-match across digits', () => {
const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[1]', action: 'changed' }] });
assert(!isDirty(set, 'dhcp.ranges[12]'), 'ranges[1] must not mark row 12');
assert(!isDirty(set, 'dhcp.ranges[10]'), 'ranges[1] must not mark row 10');
assert(isDirty(set, 'dhcp.ranges[1]'), 'the exact row is dirty');
});
test('plain keys do not prefix-match similar names', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assert(!isDirty(set, 'interfaces.eth0'), 'interface must not mark interfaces.eth0');
assert(!isDirty(set, 'interface2.port'), 'interface must not mark interface2');
});
test('isDirty is false for an empty or missing set', () => {
assert(!isDirty(new Set(), 'a.b'), 'empty set');
assert(!isDirty(null, 'a.b'), 'null set');
assert(!isDirty(dirtySet({}), 'a.b'), 'status with no pending');
});
test('isDirty tolerates an empty path', () => {
const set = dirtySet({ pending_diff: [{ path: 'a.b', action: 'changed' }] });
assert(!isDirty(set, ''), 'empty element path is not dirty');
assert(!isDirty(set, null), 'null element path is not dirty');
});
/* ── dirtyTitle / dirtyInfo ──────────────────────────────────── */
test('dirtyTitle lists all matching pending paths sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'dhcp.ranges[1].start', action: 'changed' },
{ path: 'dhcp.ranges[0].end', action: 'changed' },
{ path: 'dns.domain', action: 'changed' },
]});
assertEq(
dirtyTitle(set, 'dhcp.ranges'),
'Unapplied changes: dhcp.ranges[0].end, dhcp.ranges[1].start',
'both rows listed, sorted, unrelated path excluded',
);
});
test('dirtyTitle is empty when the element is clean', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip for unrelated element');
});
test('dirtyInfo returns the full marker object', () => {
const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] });
const hit = dirtyInfo(set, 'dns.domain');
assertEq(hit.dirty, true, 'dirty flag');
assertEq(hit.class, 'config-dirty', 'class');
assertEq(hit.title, 'Unapplied changes: dns.domain', 'tooltip');
const miss = dirtyInfo(set, 'dhcp.ranges');
assertEq(miss.dirty, false, 'clean flag');
assertEq(miss.class, '', 'clean class');
assertEq(miss.title, '', 'clean title');
});
/* ── orphanInfo (removed dict keys) ──────────────────────────── */
test('orphanInfo flags a removed peer with no live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
const info = orphanInfo(set, 'peers', ['peers.p2', 'peers.p3']);
assertEq(info.dirty, true, 'orphan is dirty');
assertEq(info.class, 'config-dirty', 'orphan class');
assertEq(info.title, 'Unapplied changes: peers.p1', 'orphan tooltip');
});
test('orphanInfo is clean when the pending path still has a live row', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1.endpoint', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1', 'peers.p2']).dirty, false, 'matched child is not an orphan');
});
test('orphanInfo flags a removed peer when no peers remain', () => {
const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] });
assertEq(orphanInfo(set, 'peers', []).dirty, true, 'no children means the orphan stands');
});
test('orphanInfo ignores pending paths outside the root', () => {
const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'unrelated root');
});
test('orphanInfo is clean when the root itself is pending', () => {
// A whole-dict `peers` change marks every child row instead; the
// container-level marker would be redundant.
const set = dirtySet({ pending_diff: [{ path: 'peers', action: 'changed' }] });
assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'root-pending is not an orphan');
assert(isDirty(set, 'peers.p1'), 'but the rows are still marked');
});
test('orphanInfo is clean for an empty set or the never-applied sentinel', () => {
assertEq(orphanInfo(new Set(), 'peers', []).dirty, false, 'empty set');
const sentinel = dirtySet({ pending_changes: true, pending_diff: [] });
assertEq(orphanInfo(sentinel, 'peers', []).dirty, false, 'sentinel: element markers already cover it');
});
test('orphanInfo lists multiple orphans sorted', () => {
const set = dirtySet({ pending_diff: [
{ path: 'peers.b', action: 'removed' },
{ path: 'peers.a', action: 'removed' },
{ path: 'peers.c.field', action: 'changed' },
]});
const info = orphanInfo(set, 'peers', ['peers.c']);
assertEq(info.title, 'Unapplied changes: peers.a, peers.b', 'only the orphans, sorted');
});
/* ── firewall zone + type granularity ────────────────────────── */
test('fwDirty builds a zone-to-types map', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'services' },
{ zone: 'public', type: 'rich_rules' },
{ zone: 'dmz', type: 'interfaces' },
{ zone: null },
{ zone: 'lan' },
],
});
assertEq(m.size, 3, 'three zones (null-zone entry skipped, typeless zone kept)');
assert(m.get('public').has('services'), 'public services');
assert(m.get('public').has('rich_rules'), 'public rich_rules');
assert(m.get('dmz').has('interfaces'), 'dmz interfaces');
assert(m.get('lan').size === 0, 'typeless zone has an empty type set');
});
test('fwIsDirty by zone and by zone+type', () => {
const m = fwDirty({ pending: [{ zone: 'public', type: 'services' }] });
assert(fwIsDirty(m, 'public'), 'zone-only match');
assert(fwIsDirty(m, 'public', 'services'), 'zone+type match');
assert(!fwIsDirty(m, 'public', 'rich_rules'), 'wrong type');
assert(!fwIsDirty(m, 'dmz'), 'unknown zone');
assert(!fwIsDirty(new Map(), 'public'), 'empty map');
});
test('fwInfo and fwTitle carry the pending types', () => {
const m = fwDirty({
pending: [
{ zone: 'public', type: 'rich_rules' },
{ zone: 'public', type: 'services' },
],
});
const zone = fwInfo(m, 'public');
assertEq(zone.dirty, true, 'zone dirty');
assertEq(zone.class, 'config-dirty', 'zone class');
assertEq(zone.title, 'Unapplied changes: rich_rules, services', 'zone tooltip lists all types');
const typed = fwInfo(m, 'public', 'services');
assertEq(typed.title, 'Unapplied changes: services', 'typed tooltip lists only that type');
assertEq(fwInfo(m, 'dmz').dirty, false, 'unknown zone clean');
assertEq(fwTitle(m, 'nope'), '', 'no tooltip for unknown zone');
});
/* ── Runner ──────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+19 -4
View File
@@ -37,6 +37,13 @@ export function StatusDot(props = {}) {
return h('span', { class: `status-dot status-${v}` }); return h('span', { class: `status-dot status-${v}` });
} }
/**
* Small amber dot marking a pending (edited, not yet applied) element.
*/
export function PendingDot() {
return h('span', { class: 'pending-dot' });
}
/** /**
* Empty-state placeholder card. * Empty-state placeholder card.
* *
@@ -55,16 +62,20 @@ export function Empty(props = {}) {
* @param {object} props * @param {object} props
* @param {string} [props.header] * @param {string} [props.header]
* @param {VNode[]} [props.children] * @param {VNode[]} [props.children]
* @param {string} [props.cls] - Extra class appended to the outer `div.card`
* @param {string} [props.title] - Tooltip on the outer `div.card`
*/ */
export function Card(props = {}) { export function Card(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {}; const key = props.key !== undefined ? { key: props.key } : {};
const cls = props.cls ? `card ${props.cls}` : 'card';
const title = props.title ? { title: props.title } : {};
if (props.header) { if (props.header) {
return h('div', { class: 'card', ...key }, return h('div', { class: cls, ...title, ...key },
h('div', { class: 'card-header' }, props.header), h('div', { class: 'card-header' }, props.header),
h('div', { class: 'card-body' }, props.children || []), h('div', { class: 'card-body' }, props.children || []),
); );
} }
return h('div', { class: 'card', ...key }, props.children || []); return h('div', { class: cls, ...title, ...key }, props.children || []);
} }
/** /**
@@ -209,6 +220,8 @@ export function ActionButton(props = {}) {
* @param {string} [props.emptyText] - Empty-state message * @param {string} [props.emptyText] - Empty-state message
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true) * @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
* @param {string} [props.key] - VNode key * @param {string} [props.key] - VNode key
* @param {string} [props.cls] - Extra class appended to the wrapper (or div.card)
* @param {string} [props.title] - Tooltip on the wrapper element
*/ */
export function Table(props = {}) { export function Table(props = {}) {
const cols = props.columns || []; const cols = props.columns || [];
@@ -225,10 +238,12 @@ export function Table(props = {}) {
), ),
); );
const key = props.key !== undefined ? { key: props.key } : {}; const key = props.key !== undefined ? { key: props.key } : {};
const title = props.title ? { title: props.title } : {};
const cls = props.cls ? `card ${props.cls}` : 'card';
if (props.wrapCard !== false) { if (props.wrapCard !== false) {
return h('div', { class: 'card', ...key }, table); return h('div', { class: cls, ...title, ...key }, table);
} }
return h('div', key, table); return h('div', { ...title, ...key }, table);
} }
/** /**
+161
View File
@@ -0,0 +1,161 @@
/**
* Hoover — dirty.js
*
* Marks UI elements that have been edited (saved to config) but not yet
* applied to the live system. Consumes the daemon-provided pending state:
* - hash subsystems: status.pending_diff -> [{path, action, old, new}]
* - firewall: pending -> {needs_apply, pending:[{zone,type,...}]}
*
* "Line" matching: an element path is dirty when it shares a root-to-leaf line
* with a pending path — equal, an ancestor, or a descendant. A plain key is a
* prefix of its indexed form, so a whole-list change (e.g. `dhcp.ranges`)
* marks every row, while a leaf change (`interface.listen_port`) marks only
* that field/row.
*/
function segs(p) {
return p ? String(p).split('.').filter(Boolean) : [];
}
// Is segment `a` a prefix of segment `b`? "ranges" prefixes "ranges[0]"
// (the trailing bracket keeps "ranges[1" from prefixing "ranges[12]").
function segPrefix(a, b) {
return a === b || b.indexOf(a + '[') === 0;
}
// Are paths p and q on the same root-to-leaf line? Segment matching is
// bidirectional so both directions of containment hold: a pending leaf under
// the element (`dhcp.ranges` vs `dhcp.ranges[0].start`) and a pending
// container over the element (`dhcp.ranges[3]` vs `dhcp.ranges`).
function isLine(p, q) {
const A = segs(p);
const B = segs(q);
if (!A.length || !B.length) return false;
const n = Math.min(A.length, B.length);
for (let i = 0; i < n; i++) {
if (!segPrefix(A[i], B[i]) && !segPrefix(B[i], A[i])) return false;
}
return true;
}
/**
* Sentinel path marking "pending but no diff baseline": the config was
* saved but never applied, so the daemon has no applied snapshot to diff
* against and `pending_diff` is empty while `pending_changes` is true.
* Every element is dirty in this case.
*/
const ANY_PATH = Symbol('dirty: any');
/** Set of pending config paths from a hash-subsystem status object. */
export function dirtySet(status) {
const diff = status && Array.isArray(status.pending_diff) ? status.pending_diff : [];
const s = new Set();
for (const d of diff) {
if (d && d.path) s.add(String(d.path));
}
if (!s.size && status && status.pending_changes) s.add(ANY_PATH);
return s;
}
/** True when element path `path` is (under / above / equal to) a pending change. */
export function isDirty(set, path) {
if (!set || !set.size) return false;
if (set.has(ANY_PATH)) return true;
const p = String(path || '');
for (const q of set) {
if (isLine(p, q)) return true;
}
return false;
}
/** Tooltip listing the concrete pending field(s) that affect `path`. */
export function dirtyTitle(set, path) {
if (!set || !set.size) return '';
if (set.has(ANY_PATH)) return 'Configuration saved but not applied yet';
const p = String(path || '');
const hits = [...set].filter((q) => isLine(p, q)).sort();
if (!hits.length) return '';
return 'Unapplied changes: ' + hits.join(', ');
}
/** One object for a hash-subsystem element. Use class/title on the element. */
export function dirtyInfo(set, path) {
const dirty = isDirty(set, path);
return {
dirty,
class: dirty ? 'config-dirty' : '',
title: dirty ? dirtyTitle(set, path) : '',
};
}
const CLEAN_INFO = { dirty: false, class: '', title: '' };
/**
* One object for a container element, covering pending changes under `root`
* that no longer have a live child element to mark: a removed dict key
* (e.g. `peers.p1`) leaves its pending path with no row/section for a
* per-element marker to attach to. `children` is the list of element paths
* for the container's live children (e.g. `'peers.' + name` per configured
* peer). Clean when there are no such orphaned paths, when the set is the
* never-applied sentinel (every element is already marked), or when the root
* itself is pending (every child row is marked instead).
*/
export function orphanInfo(set, root, children) {
if (!set || !set.size || set.has(ANY_PATH)) return CLEAN_INFO;
const r = String(root || '');
const childList = (children || []).map(String);
const hits = [];
for (const q of set) {
if (q === r || !isLine(r, q)) continue;
if (!childList.some((cp) => isLine(q, cp))) hits.push(q);
}
if (!hits.length) return CLEAN_INFO;
return {
dirty: true,
class: 'config-dirty',
title: 'Unapplied changes: ' + hits.sort().join(', '),
};
}
// ── Firewall (zone + type granularity) ─────────────────────────
/** Map<zone, Set<type>> from a firewall pending object. */
export function fwDirty(pending) {
const m = new Map();
const list = pending && Array.isArray(pending.pending) ? pending.pending : [];
for (const c of list) {
if (!c || !c.zone) continue;
if (!m.has(c.zone)) m.set(c.zone, new Set());
if (c.type) m.get(c.zone).add(c.type);
}
return m;
}
/** True when `zone` (and optionally `type`) has a pending firewall change. */
export function fwIsDirty(map, zone, type) {
if (!map || !map.size) return false;
const types = map.get(zone);
if (!types) return false;
if (type) return types.has(type);
return true;
}
/** Tooltip for a firewall zone (and optional type). */
export function fwTitle(map, zone, type) {
const types = map.get(zone);
if (!types) return '';
const t = [...types].sort();
const shown = type ? t.filter((x) => x === type) : t;
if (!shown.length) return '';
return 'Unapplied changes: ' + shown.join(', ');
}
/** One object for a firewall element (zone, optional type). */
export function fwInfo(map, zone, type) {
const dirty = fwIsDirty(map, zone, type);
return {
dirty,
class: dirty ? 'config-dirty' : '',
title: dirty ? fwTitle(map, zone, type) : '',
};
}
+4 -1
View File
@@ -47,7 +47,7 @@ export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob }
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js'; export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js';
/* ── UI Components: Data ─────────────────────────────────────── */ /* ── UI Components: Data ─────────────────────────────────────── */
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js'; export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect, PendingDot } from './components/data.js';
/* ── UI Components: Modal ────────────────────────────────────── */ /* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js'; export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js';
@@ -60,3 +60,6 @@ export { ToastContainer } from './components/toast.js';
/* ── UI Components: QR Code ──────────────────────────────────── */ /* ── UI Components: QR Code ──────────────────────────────────── */
export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js'; export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js';
/* ── Dirty / pending-edit markers ─────────────────────────────── */
export { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from './dirty.js';
+11 -7
View File
@@ -1,4 +1,4 @@
import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js'; import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js'; import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js';
import { _deleting } from '/static/hoover/components/data.js'; import { _deleting } from '/static/hoover/components/data.js';
@@ -260,18 +260,22 @@ export default definePage({
return { return {
backends: getModel('backends'), backends: getModel('backends'),
dnsmasq: getModel('dnsmasq'), dnsmasq: getModel('dnsmasq'),
nginx: getModel('nginx'),
}; };
}, },
render(state) { render(state) {
const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends); const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends);
if (guard) return guard; if (guard) return guard;
const set = dirtySet(state.nginx.data?.status);
const backends = state.backends.data || {}; const backends = state.backends.data || {};
const entries = Object.entries(backends); const entries = Object.entries(backends);
const rows = entries.map(([name, b]) => const rows = entries.map(([name, b]) => {
html`<tr key=${name} class=${_deleting.has(name) ? 'pending-delete' : ''}> const info = dirtyInfo(set, 'backends.' + name);
<td><strong>${esc(name)}</strong></td> const cls = (_deleting.has(name) ? 'pending-delete' : '') + (info.class ? ' ' + info.class : '');
return html`<tr key=${name} class=${cls || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(name)}</strong></td>
<td>${esc(b.label || name)}</td> <td>${esc(b.label || name)}</td>
<td>${Object.keys(b.paths || {}).length}</td> <td>${Object.keys(b.paths || {}).length}</td>
<td> <td>
@@ -291,11 +295,11 @@ export default definePage({
message=${'Remove backend ' + enc(name) + '?'} message=${'Remove backend ' + enc(name) + '?'}
success="Backend removed" success="Backend removed"
onComplete=${() => modelFetch('backends')} onComplete=${() => modelFetch('backends')}
label="Delete" />` label="Delete" />`}
} }
</td> </td>
</tr>` </tr>`;
); });
const actions = ActionGroup( const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'), h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'),
+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) { function makeAddRange(activeZones, interfaces) {
const opts = [ const opts = [
@@ -118,6 +118,7 @@ export default definePage({
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall); const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
if (guard) return guard; if (guard) return guard;
const set = dirtySet(state.dnsmasq.data?.status);
const cfg = state.dnsmasq.data?.config || {}; const cfg = state.dnsmasq.data?.config || {};
const dhcpCfg = cfg.dhcp || {}; const dhcpCfg = cfg.dhcp || {};
const dnsCfg = cfg.dns || {}; const dnsCfg = cfg.dns || {};
@@ -126,8 +127,10 @@ export default definePage({
const dnsRecords = dnsCfg.custom_records || []; const dnsRecords = dnsCfg.custom_records || [];
const status = state.dnsmasq.data?.status || {}; const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}> const rangesRows = ranges.map((r, i) => {
<td>${r.interface || '(global)'}</td> 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.start)}</td>
<td>${esc(r.end)}</td> <td>${esc(r.end)}</td>
<td>${esc(r.lease_time || '12h')}</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 }} body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success="Range removed" /> success="Range removed" />
</td> </td>
</tr>`); </tr>`;
});
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}> const leaseRows = staticLeases.map((l, i) => {
<td>${esc(l.mac)}</td> 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>${esc(l.ip)}</td>
<td>${l.hostname || '-'}</td> <td>${l.hostname || '-'}</td>
<td> <td>
@@ -152,10 +158,13 @@ export default definePage({
message=${'Remove lease ' + l.mac + '?'} message=${'Remove lease ' + l.mac + '?'}
success="Lease removed" /> success="Lease removed" />
</td> </td>
</tr>`); </tr>`;
});
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}> const dnsRows = dnsRecords.map((rec, i) => {
<td><strong>${esc(rec.name || 'unnamed')}</strong></td> 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 class="text-sm">${esc(rec.address || '-')}</td>
<td> <td>
<${ConfirmDelete} <${ConfirmDelete}
@@ -164,7 +173,8 @@ export default definePage({
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'} message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success="Record removed" /> success="Record removed" />
</td> </td>
</tr>`); </tr>`;
});
const _setDomain = async (domain) => { const _setDomain = async (domain) => {
const res = await apiFetch('/api/dhcp/domain', { const res = await apiFetch('/api/dhcp/domain', {
@@ -180,7 +190,8 @@ export default definePage({
}; };
const currentDomain = dnsCfg.domain || null; 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> <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> <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;"> <div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
+8 -6
View File
@@ -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) { async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { 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); const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
if (guard) return guard; if (guard) return guard;
const set = dirtySet(state.network.data?.status);
const fwZones = state.firewall.data?.zones || {}; const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {}; const netData = state.network.data?.interfaces || {};
const zones = Object.keys(fwZones); const zones = Object.keys(fwZones);
@@ -72,9 +73,10 @@ export default definePage({
}; };
}); });
const rows = ifaces.map(iface => const rows = ifaces.map(iface => {
html`<tr key=${iface.name}> const info = dirtyInfo(set, 'interfaces.' + iface.name);
<td><strong>${iface.name}</strong></td> return html`<tr key=${iface.name} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${iface.name}</strong></td>
<td class="text-muted">${String(iface.mac || 'N/A')}</td> <td class="text-muted">${String(iface.mac || 'N/A')}</td>
<td>${(iface.ips || []).join(', ') || 'N/A'}</td> <td>${(iface.ips || []).join(', ') || 'N/A'}</td>
<td><${StatusText} status=${iface.state} /></td> <td><${StatusText} status=${iface.state} /></td>
@@ -84,8 +86,8 @@ export default definePage({
<button class="btn btn-sm btn-outline" style="margin-left:8px" <button class="btn btn-sm btn-outline" style="margin-left:8px"
onClick=${() => cfgModalFn(iface)}>Config</button> onClick=${() => cfgModalFn(iface)}>Config</button>
</td> </td>
</tr>` </tr>`;
); });
return [ return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }), PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
+8 -5
View File
@@ -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({ const addFwd = QuickModal({
title: 'Add Port Forward', title: 'Add Port Forward',
@@ -33,6 +33,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config); const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard; if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {}; const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {}; const zoneData = cfg.zones || {};
@@ -82,8 +83,9 @@ export default definePage({
<td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td> <td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
</tr>`; </tr>`;
} }
return html`<tr key=${'m-' + zone}> const info = fwInfo(fw, zone, 'masquerade');
<td><strong>${zone}</strong></td> return html`<tr key=${'m-' + zone} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td> <td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td> <td>
<${ActionButton} <${ActionButton}
@@ -102,8 +104,9 @@ export default definePage({
forwards.forEach((fwd, i) => { forwards.forEach((fwd, i) => {
const port = fwd.port; const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto; const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}> const info = fwInfo(fw, zone, 'forward_ports');
<td><strong>${zone}</strong></td> fwRows.push(html`<tr key=${'f-' + zone + '-' + i} class=${info.class || undefined} title=${info.title || undefined}>
<td>${info.dirty ? PendingDot({}) : ''}<strong>${zone}</strong></td>
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td> <td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
<td>${port}</td> <td>${port}</td>
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td> <td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
+11 -8
View File
@@ -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'; import { openBackendModal } from '/static/pages/backends.js';
function certLookup(acmeData) { function certLookup(acmeData) {
@@ -172,8 +172,9 @@ function editDomain(d, state) {
modal({}); modal({});
} }
function domainRow(domainName, domainPaths, state) { function domainRow(domainName, domainPaths, state, set) {
const d = domainPaths[0]; const d = domainPaths[0];
const info = dirtyInfo(set, 'domains.' + domainName);
const certMap = certLookup(state.acme ? state.acme.data : null); const certMap = certLookup(state.acme ? state.acme.data : null);
const cert = certMap[d.domain]; const cert = certMap[d.domain];
let certBadge, certTitle; let certBadge, certTitle;
@@ -199,8 +200,8 @@ function domainRow(domainName, domainPaths, state) {
if (flags.length) parts.push(flags.join(', ')); if (flags.length) parts.push(flags.join(', '));
return parts.join(' → '); return parts.join(' → ');
}); });
return html`<tr key=${domainName} class="domain-row"> return html`<tr key=${domainName} class="domain-row ${info.class}" title=${info.title || undefined}>
<td><strong>${esc(domainName)}</strong></td> <td>${info.dirty ? PendingDot({}) : ''}<strong>${esc(domainName)}</strong></td>
<td>${pathSummaries}</td> <td>${pathSummaries}</td>
<td title=${certTitle}>${certBadge}</td> <td title=${certTitle}>${certBadge}</td>
<td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td> <td>${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`}</td>
@@ -216,9 +217,10 @@ function domainRow(domainName, domainPaths, state) {
</tr>`; </tr>`;
} }
function backendSection(section, state) { function backendSection(section, state, set) {
const { backendName, backend, domains } = section; 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 = []; const sectionActions = [];
if (!backend.builtin) { if (!backend.builtin) {
sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`); sectionActions.push(html`<button class="btn btn-sm btn-outline" onClick=${() => openBackendModal(state, { name: backendName, data: backend })}>Edit Backend</button>`);
@@ -233,7 +235,7 @@ function backendSection(section, state) {
} }
} }
sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`); sectionActions.push(html`<button class="btn btn-sm btn-primary" onClick=${() => addDomain(state, backendName)}>+ Add Domain → ${esc(backendName)}</button>`);
return html`<div class="backend-section" key=${backendName} style="margin-bottom:24px;"> return html`<div class="backend-section ${info.class}" title=${info.title || undefined} key=${backendName} style="margin-bottom:24px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #dee2e6;">
<h3 style="margin:0;display:flex;align-items:center;gap:8px;"> <h3 style="margin:0;display:flex;align-items:center;gap:8px;">
<${Badge} text=${esc(backendName)} variant="primary" /> <${Badge} text=${esc(backendName)} variant="primary" />
@@ -258,10 +260,11 @@ export default definePage({
render(state) { render(state) {
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme); const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme);
if (guard) return guard; if (guard) return guard;
const set = dirtySet(state.nginx.data?.status);
const domains = state.nginx.data.domains || []; const domains = state.nginx.data.domains || [];
const backends = state.backends.data || {}; const backends = state.backends.data || {};
const sections = _groupByBackend(domains, backends); 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( const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`, html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }), ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }),
+8 -2
View File
@@ -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({ const addRule = QuickModal({
title: 'Add Rich Rule', title: 'Add Rich Rule',
@@ -24,6 +24,7 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config); const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config);
if (guard) return guard; if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
const cfg = state.firewall.data?.config || {}; const cfg = state.firewall.data?.config || {};
const zones = Object.keys(state.firewall.data?.zones || {}); const zones = Object.keys(state.firewall.data?.zones || {});
const zoneData = cfg.zones || {}; const zoneData = cfg.zones || {};
@@ -34,6 +35,7 @@ export default definePage({
}); });
const cards = Object.entries(zoneRules).map(([zone, rules]) => { 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 ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => {
const ruleId = typeof entry === 'object' ? entry.id : null; const ruleId = typeof entry === 'object' ? entry.id : null;
const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry); const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry);
@@ -50,8 +52,12 @@ export default definePage({
</tr>`; </tr>`;
}); });
return Card({ return Card({
header: 'Zone: ' + esc(zone), header: info.dirty
? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)])
: 'Zone: ' + esc(zone),
key: zone, key: zone,
cls: info.class || undefined,
title: info.title || undefined,
children: [Table({ children: [Table({
columns: ['#', 'Rule', 'Action'], columns: ['#', 'Rule', 'Action'],
rows: ruleRows, rows: ruleRows,
+19 -7
View File
@@ -1,5 +1,5 @@
/** WireGuard page — tunnel & peer management. */ /** 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 ────────────────────────────────────── */ /* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() { function getLanSubnets() {
@@ -351,6 +351,7 @@ function renderAccessClasses(config, status) {
} }
const classStatuses = status?.classes || {}; const classStatuses = status?.classes || {};
const set = dirtySet(status);
const rows = entries.map(([k, v]) => { const rows = entries.map(([k, v]) => {
const pCount = peerCountMap[k] || 0; const pCount = peerCountMap[k] || 0;
@@ -358,8 +359,9 @@ function renderAccessClasses(config, status) {
const isUp = clsStatus.up; const isUp = clsStatus.up;
const hasKeys = classHasKeys(v); const hasKeys = classHasKeys(v);
const color = classColor(k); const color = classColor(k);
return html`<tr key=${k}> const info = dirtyInfo(set, 'access_classes.' + k);
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td> 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>${esc(v.name || k)}</td>
<td class="text-sm">${esc(v.description || '-')}</td> <td class="text-sm">${esc(v.description || '-')}</td>
<td class="text-sm">${esc(v.subnet || '-')}</td> <td class="text-sm">${esc(v.subnet || '-')}</td>
@@ -418,12 +420,16 @@ export default definePage({
const wgData = state.wireguard.data; const wgData = state.wireguard.data;
const st = wgData?.status || {}; const st = wgData?.status || {};
const config = wgData?.config || {}; const config = wgData?.config || {};
const set = dirtySet(st);
const isUp = st.up || false; const isUp = st.up || false;
const listenPort = config.interface?.listen_port || '-'; const listenPort = config.interface?.listen_port || '-';
const serverEndpoint = config.interface?.server_endpoint || ''; const serverEndpoint = config.interface?.server_endpoint || '';
// Build merged peer rows: configured peers + live status // Build merged peer rows: configured peers + live status
const configuredPeers = wgData?.peers || []; 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 = {}; const statusPeersMap = {};
for (const [cKey, cSt] of Object.entries(st.classes || {})) { for (const [cKey, cSt] of Object.entries(st.classes || {})) {
for (const sp of (cSt.peers || [])) { for (const sp of (cSt.peers || [])) {
@@ -442,9 +448,11 @@ export default definePage({
const isConnected = sp && !!sp.latest_handshake; const isConnected = sp && !!sp.latest_handshake;
const accessClass = p.access_class; const accessClass = p.access_class;
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null; const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : ''; const info = dirtyInfo(set, 'peers.' + p.name);
return html`<tr key=${p.name}${borderColor}> 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> <td>
${info.dirty ? PendingDot({}) : ''}
<${StatusDot} status=${isConnected ? 'success' : 'danger'} /> <${StatusDot} status=${isConnected ? 'success' : 'danger'} />
<strong>${esc(p.name || 'unnamed')}</strong> <strong>${esc(p.name || 'unnamed')}</strong>
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''} ${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 isUp = cSt.up;
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length; const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
const color = classColor(k); 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"> <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><${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> <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>`; classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
} }
const ifaceInfo = dirtyInfo(set, 'interface');
const actions = ActionGroup( const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`, 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({ ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'), url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop All', labelOff: 'Start All', condition: isUp, labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
@@ -531,6 +541,8 @@ export default definePage({
? Table({ ? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'], columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows, rows: peerRows,
cls: peersOrphan.class || undefined,
title: peersOrphan.title || undefined,
}) })
: Empty({ text: 'No peers configured. Add a peer above.' }), : Empty({ text: 'No peers configured. Add a peer above.' }),
renderAccessClasses(config, st), renderAccessClasses(config, st),
+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 // Services shown by default in the service picker. Everything else is only
// visible with the "Show all options" toggle (or while it is already // 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); const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard; if (guard) return guard;
const fw = fwDirty(state.firewall.data?.pending);
// Live zone data (parsed `--list-all-zones`): carries interfaces, // Live zone data (parsed `--list-all-zones`): carries interfaces,
// services, target, and masquerade for every defined zone. // services, target, and masquerade for every defined zone.
const liveZones = state.firewall.data?.zones || {}; const liveZones = state.firewall.data?.zones || {};
@@ -45,22 +47,26 @@ export default definePage({
const z = typeof zdata === 'object' ? zdata : {}; const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : []; const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : []; 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 style="display:flex;justify-content:space-between;align-items:flex-start">
<div> <div>
<h3 style="font-size:16px;color:var(--accent)">${name}</h3> <h3 style="font-size:16px;color:var(--accent)">${info.dirty ? PendingDot({}) : ''}${name}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px"> <div class="text-muted text-sm" title=${tgtTitle || undefined} style="margin-bottom:10px">
${z.target ? 'Target: ' + esc(z.target) : ''} ${z.target ? 'Target: ' + esc(z.target) : ''}
</div> </div>
</div> </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> <div class="text-muted" style="margin-bottom:4px">Interfaces</div>
${ifacesArr.length ${ifacesArr.length
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`) ? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
: html`<span class="text-muted">None</span>`} : html`<span class="text-muted">None</span>`}
</div> </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> <div class="text-muted" style="margin-bottom:4px">Services</div>
${svcsArr.length ${svcsArr.length
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`) ? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
+31
View File
@@ -1062,6 +1062,37 @@ body {
color: var(--text-muted); color: var(--text-muted);
} }
/* Pending (edited, not yet applied) marker — amber, distinct from the red
.pending-delete. Applied to rows/cards/sections whose config is dirty. */
.config-dirty {
background: rgba(243, 156, 18, 0.07);
}
tr.config-dirty > td:first-child {
box-shadow: inset 3px 0 0 var(--warning);
}
/* A row queued for deletion (red) takes precedence over the dirty marker. */
tr.pending-delete.config-dirty > td:first-child {
box-shadow: none;
}
.card.config-dirty,
.backend-section.config-dirty,
.domain-config.config-dirty {
box-shadow: inset 3px 0 0 var(--warning);
}
.pending-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning);
margin-right: 6px;
vertical-align: middle;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {