/** * 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) : '', }; }