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:
+65
-4
@@ -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 |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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;">
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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" />`)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user