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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
/* ── 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';
|
||||
|
||||
Reference in New Issue
Block a user