ui: amber pending-edit markers for unapplied config changes

Add hoover/dirty.js: line-matching helpers that flag UI rows/cards
edited (saved to config) but not yet applied, consuming the pending
state the daemon already streams — status.pending_diff for hash
subsystems, firewall pending zone+type for firewalld. Visual language
is amber (.config-dirty + PendingDot), distinct from the red
.pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard
peers) on their container table. Wired into the backends, dhcp,
interfaces, nat, proxy, rules, wireguard, and zones pages; Card and
Table gain cls/title props. Covered by 27 node tests
(tests/test-dirty.js).
This commit is contained in:
2026-09-01 20:17:15 +00:00
parent 75b86fd60d
commit 89b64960f3
14 changed files with 649 additions and 61 deletions
+65 -4
View File
@@ -947,9 +947,10 @@ StatusText({ status: iface.state })
Empty-state placeholder card.
#### `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 `<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
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<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
| Function | Description |