diff --git a/docs/hoover.md b/docs/hoover.md index c1fdccd..d4cda93 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -947,9 +947,10 @@ StatusText({ status: iface.state }) 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)` @@ -1152,9 +1153,9 @@ ZoneSelect({ | `onChange` | `(zone) => void` callback | | `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 `` VNodes. +Table wrapper with header, body, and empty-state row. `rows` expects pre-built `` VNodes. `cls` appends a class to the wrapper (or `div.card`); `title` sets a tooltip on the wrapper. ```javascript 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. +## 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>` 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..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 | Function | Description | diff --git a/tests/test-dirty.js b/tests/test-dirty.js new file mode 100644 index 0000000..f8664c3 --- /dev/null +++ b/tests/test-dirty.js @@ -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; +})(); diff --git a/webui/static/hoover/components/data.js b/webui/static/hoover/components/data.js index 57b17fb..d81b4f0 100644 --- a/webui/static/hoover/components/data.js +++ b/webui/static/hoover/components/data.js @@ -37,6 +37,13 @@ export function StatusDot(props = {}) { 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. * @@ -55,16 +62,20 @@ export function Empty(props = {}) { * @param {object} props * @param {string} [props.header] * @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 = {}) { 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) { - return h('div', { class: 'card', ...key }, + return h('div', { class: cls, ...title, ...key }, h('div', { class: 'card-header' }, props.header), 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 {boolean} [props.wrapCard] - Wrap in div.card (default: true) * @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 = {}) { const cols = props.columns || []; @@ -225,10 +238,12 @@ export function Table(props = {}) { ), ); 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) { - 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); } /** diff --git a/webui/static/hoover/dirty.js b/webui/static/hoover/dirty.js new file mode 100644 index 0000000..99df8a6 --- /dev/null +++ b/webui/static/hoover/dirty.js @@ -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> 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) : '', + }; +} diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index 5db9bc6..fb24e8a 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -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'; /* ── 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 ────────────────────────────────────── */ 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 ──────────────────────────────────── */ 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'; diff --git a/webui/static/pages/backends.js b/webui/static/pages/backends.js index 542f3ea..cdc6c83 100644 --- a/webui/static/pages/backends.js +++ b/webui/static/pages/backends.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 { _deleting } from '/static/hoover/components/data.js'; @@ -260,18 +260,22 @@ export default definePage({ return { backends: getModel('backends'), dnsmasq: getModel('dnsmasq'), + nginx: getModel('nginx'), }; }, render(state) { const guard = renderGuardMulti('Backends', 'Reusable proxy backend templates', state.backends); if (guard) return guard; + const set = dirtySet(state.nginx.data?.status); const backends = state.backends.data || {}; const entries = Object.entries(backends); - const rows = entries.map(([name, b]) => - html` - ${esc(name)} + const rows = entries.map(([name, b]) => { + const info = dirtyInfo(set, 'backends.' + name); + const cls = (_deleting.has(name) ? 'pending-delete' : '') + (info.class ? ' ' + info.class : ''); + return html` + ${info.dirty ? PendingDot({}) : ''}${esc(name)} ${esc(b.label || name)} ${Object.keys(b.paths || {}).length} @@ -291,11 +295,11 @@ export default definePage({ message=${'Remove backend ' + enc(name) + '?'} success="Backend removed" onComplete=${() => modelFetch('backends')} - label="Delete" />` + label="Delete" />`} } - ` - ); + `; + }); const actions = ActionGroup( h('button', { class: 'btn btn-primary', 'on:click': () => openBackendModal(state) }, 'Add Backend'), diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 085d119..35a6eba 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -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) { const opts = [ @@ -118,6 +118,7 @@ export default definePage({ const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall); if (guard) return guard; + const set = dirtySet(state.dnsmasq.data?.status); const cfg = state.dnsmasq.data?.config || {}; const dhcpCfg = cfg.dhcp || {}; const dnsCfg = cfg.dns || {}; @@ -126,8 +127,10 @@ export default definePage({ const dnsRecords = dnsCfg.custom_records || []; const status = state.dnsmasq.data?.status || {}; - const rangesRows = ranges.map((r) => html` - ${r.interface || '(global)'} + const rangesRows = ranges.map((r, i) => { + const info = dirtyInfo(set, 'dhcp.ranges[' + i + ']'); + return html` + ${info.dirty ? PendingDot({}) : ''}${r.interface || '(global)'} ${esc(r.start)} ${esc(r.end)} ${esc(r.lease_time || '12h')} @@ -139,10 +142,13 @@ export default definePage({ body=${{ interface: r.interface || '', start: r.start, end: r.end }} success="Range removed" /> - `); + `; + }); - const leaseRows = staticLeases.map((l) => html` - ${esc(l.mac)} + const leaseRows = staticLeases.map((l, i) => { + const info = dirtyInfo(set, 'dhcp.static_leases[' + i + ']'); + return html` + ${info.dirty ? PendingDot({}) : ''}${esc(l.mac)} ${esc(l.ip)} ${l.hostname || '-'} @@ -152,10 +158,13 @@ export default definePage({ message=${'Remove lease ' + l.mac + '?'} success="Lease removed" /> - `); + `; + }); - const dnsRows = dnsRecords.map((rec) => html` - ${esc(rec.name || 'unnamed')} + const dnsRows = dnsRecords.map((rec, i) => { + const info = dirtyInfo(set, 'dns.custom_records[' + i + ']'); + return html` + ${info.dirty ? PendingDot({}) : ''}${esc(rec.name || 'unnamed')} ${esc(rec.address || '-')} <${ConfirmDelete} @@ -164,7 +173,8 @@ export default definePage({ message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'} success="Record removed" /> - `); + `; + }); const _setDomain = async (domain) => { const res = await apiFetch('/api/dhcp/domain', { @@ -180,7 +190,8 @@ export default definePage({ }; const currentDomain = dnsCfg.domain || null; - const domainSection = html`
+ const domainInfo = dirtyInfo(set, 'dns.domain'); + const domainSection = html`

${currentDomain ? esc(currentDomain) : '(not set)'}

diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js index 29b4a27..f06dbce 100644 --- a/webui/static/pages/interfaces.js +++ b/webui/static/pages/interfaces.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js'; +import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js'; async function changeZone(name, zone, state) { const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { @@ -45,6 +45,7 @@ export default definePage({ const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network); if (guard) return guard; + const set = dirtySet(state.network.data?.status); const fwZones = state.firewall.data?.zones || {}; const netData = state.network.data?.interfaces || {}; const zones = Object.keys(fwZones); @@ -72,9 +73,10 @@ export default definePage({ }; }); - const rows = ifaces.map(iface => - html` - ${iface.name} + const rows = ifaces.map(iface => { + const info = dirtyInfo(set, 'interfaces.' + iface.name); + return html` + ${info.dirty ? PendingDot({}) : ''}${iface.name} ${String(iface.mac || 'N/A')} ${(iface.ips || []).join(', ') || 'N/A'} <${StatusText} status=${iface.state} /> @@ -84,8 +86,8 @@ export default definePage({ - ` - ); + `; + }); return [ PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }), diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js index a8e1196..903e757 100644 --- a/webui/static/pages/nat.js +++ b/webui/static/pages/nat.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js'; +import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js'; const addFwd = QuickModal({ title: 'Add Port Forward', @@ -33,6 +33,7 @@ export default definePage({ const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config); if (guard) return guard; + const fw = fwDirty(state.firewall.data?.pending); const cfg = state.firewall.data?.config || {}; const zoneData = cfg.zones || {}; @@ -82,8 +83,9 @@ export default definePage({ ${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'} `; } - return html` - ${zone} + const info = fwInfo(fw, zone, 'masquerade'); + return html` + ${info.dirty ? PendingDot({}) : ''}${zone} <${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /> <${ActionButton} @@ -102,8 +104,9 @@ export default definePage({ forwards.forEach((fwd, i) => { const port = fwd.port; const proto = fwd['proxy-protocol'] || fwd.proto; - fwRows.push(html` - ${zone} + const info = fwInfo(fw, zone, 'forward_ports'); + fwRows.push(html` + ${info.dirty ? PendingDot({}) : ''}${zone} <${Badge} text=${proto || 'tcp'} variant="info" /> ${port} ${fwd['to-addr'] || fwd.toaddr || '-'} diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index e1c47ca..e8772ee 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js'; +import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js'; import { openBackendModal } from '/static/pages/backends.js'; function certLookup(acmeData) { @@ -172,8 +172,9 @@ function editDomain(d, state) { modal({}); } -function domainRow(domainName, domainPaths, state) { +function domainRow(domainName, domainPaths, state, set) { const d = domainPaths[0]; + const info = dirtyInfo(set, 'domains.' + domainName); const certMap = certLookup(state.acme ? state.acme.data : null); const cert = certMap[d.domain]; let certBadge, certTitle; @@ -199,8 +200,8 @@ function domainRow(domainName, domainPaths, state) { if (flags.length) parts.push(flags.join(', ')); return parts.join(' → '); }); - return html` - ${esc(domainName)} + return html` + ${info.dirty ? PendingDot({}) : ''}${esc(domainName)} ${pathSummaries} ${certBadge} ${d.force_ssl ? html`<${Badge} text="on" variant="success" />` : html`<${Badge} text="off" variant="secondary" />`} @@ -216,9 +217,10 @@ function domainRow(domainName, domainPaths, state) { `; } -function backendSection(section, state) { +function backendSection(section, state, set) { const { backendName, backend, domains } = section; - const rows = domains.map(d => domainRow(d.domain, d.paths, state)); + const info = dirtyInfo(set, 'backends.' + backendName); + const rows = domains.map(d => domainRow(d.domain, d.paths, state, set)); const sectionActions = []; if (!backend.builtin) { sectionActions.push(html``); @@ -233,7 +235,7 @@ function backendSection(section, state) { } } sectionActions.push(html``); - return html`
+ return html`

<${Badge} text=${esc(backendName)} variant="primary" /> @@ -258,10 +260,11 @@ export default definePage({ render(state) { const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.backends, state.acme); if (guard) return guard; + const set = dirtySet(state.nginx.data?.status); const domains = state.nginx.data.domains || []; const backends = state.backends.data || {}; const sections = _groupByBackend(domains, backends); - const sectionVNodes = sections.map(s => backendSection(s, state)); + const sectionVNodes = sections.map(s => backendSection(s, state, set)); const actions = ActionGroup( html``, ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', label: 'Apply' }), diff --git a/webui/static/pages/rules.js b/webui/static/pages/rules.js index a3e93d6..9248ee7 100644 --- a/webui/static/pages/rules.js +++ b/webui/static/pages/rules.js @@ -1,4 +1,4 @@ -import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal } from '/static/hoover/index.js'; +import { h, html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, MonoText, QuickModal, PendingDot, fwDirty, fwInfo } from '/static/hoover/index.js'; const addRule = QuickModal({ title: 'Add Rich Rule', @@ -24,6 +24,7 @@ export default definePage({ const guard = renderGuard(state.firewall, 'Rules', 'Firewall rich rules', state.firewall.data?.config); if (guard) return guard; + const fw = fwDirty(state.firewall.data?.pending); const cfg = state.firewall.data?.config || {}; const zones = Object.keys(state.firewall.data?.zones || {}); const zoneData = cfg.zones || {}; @@ -34,6 +35,7 @@ export default definePage({ }); const cards = Object.entries(zoneRules).map(([zone, rules]) => { + const info = fwInfo(fw, zone, 'rich_rules'); const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => { const ruleId = typeof entry === 'object' ? entry.id : null; const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry); @@ -50,8 +52,12 @@ export default definePage({ `; }); return Card({ - header: 'Zone: ' + esc(zone), + header: info.dirty + ? h('span', {}, [PendingDot({}), 'Zone: ' + esc(zone)]) + : 'Zone: ' + esc(zone), key: zone, + cls: info.class || undefined, + title: info.title || undefined, children: [Table({ columns: ['#', 'Rule', 'Action'], rows: ruleRows, diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js index 182b806..5bb9086 100644 --- a/webui/static/pages/wireguard.js +++ b/webui/static/pages/wireguard.js @@ -1,5 +1,5 @@ /** WireGuard page — tunnel & peer management. */ -import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr } from '/static/hoover/index.js'; +import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, ActionButton, ActionCell, ConfirmDelete, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG, csvToArr, PendingDot, dirtySet, dirtyInfo, orphanInfo } from '/static/hoover/index.js'; /* ── LAN detection helper ────────────────────────────────────── */ function getLanSubnets() { @@ -351,6 +351,7 @@ function renderAccessClasses(config, status) { } const classStatuses = status?.classes || {}; + const set = dirtySet(status); const rows = entries.map(([k, v]) => { const pCount = peerCountMap[k] || 0; @@ -358,8 +359,9 @@ function renderAccessClasses(config, status) { const isUp = clsStatus.up; const hasKeys = classHasKeys(v); const color = classColor(k); - return html` - ${esc(k)} + const info = dirtyInfo(set, 'access_classes.' + k); + return html` + ${info.dirty ? PendingDot({}) : ''}${esc(k)} ${esc(v.name || k)} ${esc(v.description || '-')} ${esc(v.subnet || '-')} @@ -418,12 +420,16 @@ export default definePage({ const wgData = state.wireguard.data; const st = wgData?.status || {}; const config = wgData?.config || {}; + const set = dirtySet(st); const isUp = st.up || false; const listenPort = config.interface?.listen_port || '-'; const serverEndpoint = config.interface?.server_endpoint || ''; // Build merged peer rows: configured peers + live status const configuredPeers = wgData?.peers || []; + // A removed peer leaves a `peers.` pending path with no live row + // to attach a per-row marker to; surface it on the table itself. + const peersOrphan = orphanInfo(set, 'peers', configuredPeers.map(p => 'peers.' + p.name)); const statusPeersMap = {}; for (const [cKey, cSt] of Object.entries(st.classes || {})) { for (const sp of (cSt.peers || [])) { @@ -442,9 +448,11 @@ export default definePage({ const isConnected = sp && !!sp.latest_handshake; const accessClass = p.access_class; const classInfo = accessClass ? (peersByClass[accessClass] || null) : null; - const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : ''; - return html` + const info = dirtyInfo(set, 'peers.' + p.name); + const style = classInfo ? 'border-left: 3px solid ' + classColor(accessClass) : undefined; + return html` + ${info.dirty ? PendingDot({}) : ''} <${StatusDot} status=${isConnected ? 'success' : 'danger'} /> ${esc(p.name || 'unnamed')} ${p.description ? html`
${esc(p.description)}` : ''} @@ -480,7 +488,8 @@ export default definePage({ const isUp = cSt.up; const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length; const color = classColor(k); - return html`
+ const info = dirtyInfo(set, 'access_classes.' + k); + return html`
<${StatusDot} status=${isUp ? 'success' : 'danger'} /> ${esc(v.name || k)} (${esc(k)}) ${pCount} peer(s), port ${v.listen_port || '-'} @@ -502,9 +511,10 @@ export default definePage({ classSummaryCards = html`
${cards}
`; } + const ifaceInfo = dirtyInfo(set, 'interface'); const actions = ActionGroup( html``, - html``, + html``, ActionButton({ url: '/api/wireguard/' + (isUp ? 'down' : 'up'), labelOn: 'Stop All', labelOff: 'Start All', condition: isUp, @@ -531,6 +541,8 @@ export default definePage({ ? Table({ columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'], rows: peerRows, + cls: peersOrphan.class || undefined, + title: peersOrphan.title || undefined, }) : Empty({ text: 'No peers configured. Add a peer above.' }), renderAccessClasses(config, st), diff --git a/webui/static/pages/zones.js b/webui/static/pages/zones.js index 76f7cc7..1a96dfd 100644 --- a/webui/static/pages/zones.js +++ b/webui/static/pages/zones.js @@ -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 // 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); if (guard) return guard; + const fw = fwDirty(state.firewall.data?.pending); + // Live zone data (parsed `--list-all-zones`): carries interfaces, // services, target, and masquerade for every defined zone. const liveZones = state.firewall.data?.zones || {}; @@ -45,22 +47,26 @@ export default definePage({ const z = typeof zdata === 'object' ? zdata : {}; const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : []; const svcsArr = Array.isArray(z.services) ? z.services : []; - return html`
+ 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`
-

${name}

-
+

${info.dirty ? PendingDot({}) : ''}${name}

+
${z.target ? 'Target: ' + esc(z.target) : ''}
-
+
Interfaces
${ifacesArr.length ? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`) : html`None`}
-
+
Services
${svcsArr.length ? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`) diff --git a/webui/static/style.css b/webui/static/style.css index b20aafe..34f1897 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -1062,6 +1062,37 @@ body { 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 */ @media (max-width: 768px) { .sidebar {