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.
|
||||
|
||||
#### `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 |
|
||||
|
||||
Reference in New Issue
Block a user