status: cancel-all reverts pending changes to last applied config

- lib.common.revert_to_applied(): restore a config file from its
  _last_applied_config snapshot (stamped hash); no baseline -> skip with
  reason, file untouched
- firewall config_apply now stamps the applied baseline like the other
  subsystems; GET /firewall/config and the state collector strip the
  internal _last_applied_* keys
- POST /status/cancel-all + /api/status/cancel-all: revert pending
  subsystems, {cancelled, skipped, errors}, partial-failure safe
- dashboard: "Cancel All Changes" button with confirm modal
  (CancelConfirm, reuses the pending-changes modal rows); the pending
  changes card is hidden entirely when nothing is pending
- tests: revert_to_applied, status_cancel_all, firewall stamping/meta
  stripping, /api/status/cancel-all route, node tests for CancelConfirm;
  firewall _config_apply tests no longer write the real repo config
- docs: api.md, state-model.md, config.md, hoover.md
This commit is contained in:
2026-08-21 02:10:05 +00:00
parent 30b51ad7d3
commit 55309cfd86
18 changed files with 791 additions and 19 deletions
+8 -3
View File
@@ -34,7 +34,7 @@ from daemon.iface import (
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
_normalize_target,
_parse_active_zones,
@@ -391,6 +391,11 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
"timestamp": "",
}
backup_path = _save_backup(full_state)
# Record the applied config snapshot + hash so pending-changes detection
# and cancel/revert work like the hash-based subsystems.
applied_cfg = _get_config()
stamp_applied(applied_cfg)
_save_config(applied_cfg)
logger.info("Firewall config applied to %d zones", len(applied))
return {
"applied_zones": applied,
@@ -506,9 +511,9 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
_body: The request body (unused).
Returns:
Full firewall config dict.
Full firewall config dict (apply bookkeeping keys stripped).
"""
return _get_config()
return strip_apply_meta(_get_config())
@registry.register(POST_FIREWALL_CONFIG)
+83 -3
View File
@@ -1,21 +1,33 @@
"""Aggregate status handler.
Exposes pending changes across all subsystems and a single apply-all
endpoint that invokes each subsystem's apply in the correct order.
Exposes pending changes across all subsystems, a single apply-all
endpoint that invokes each subsystem's apply in the correct order, and a
cancel-all endpoint that reverts pending edits to the last applied config.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from daemon.handlers import dnsmasq as _dnsmasq_h
from daemon.handlers import firewall as _firewall_h
from daemon.handlers import nginx as _nginx_h
from daemon.handlers.dnsmasq import apply_config as dnsmasq_apply_config
from daemon.handlers.firewall import config_apply as firewall_config_apply
from daemon.handlers.network import apply_all as network_apply_all
from daemon.handlers.nginx import apply as nginx_apply
from daemon.handlers.wireguard import apply as wireguard_apply
from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL
from daemon.iface import (
GET_STATUS_PENDING,
POST_STATUS_APPLY_ALL,
POST_STATUS_CANCEL_ALL,
)
from daemon.server import refresh_state, registry
from lib import network as _net
from lib import wireguard as _wg
from lib.common import revert_to_applied
from lib.firewall import fw_change_summary
from lib.state import state as state_store
@@ -36,6 +48,15 @@ SYS_APPLY = {
"dnsmasq": dnsmasq_apply_config,
"nginx": nginx_apply,
}
# (module, attribute) pairs for each subsystem's on-disk config path.
# Resolved at call time so tests can monkeypatch the module constants.
SYS_CONFIG_PATHS: dict[str, tuple[Any, str]] = {
"firewall": (_firewall_h, "CONFIG_FILE"),
"dnsmasq": (_dnsmasq_h, "CONFIG_PATH"),
"nginx": (_nginx_h, "CONFIG_FILE"),
"wireguard": (_wg, "CONFIG_PATH"),
"networkd": (_net, "CONFIG_FILE"),
}
@registry.register(GET_STATUS_PENDING)
@@ -126,6 +147,65 @@ def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": applied, "errors": errors}
def _config_path(name: str) -> Path:
"""Return the on-disk config path for subsystem *name*.
Resolved via the owning module at call time so tests can monkeypatch
the module constants (e.g. ``lib.wireguard.CONFIG_PATH``).
"""
module, attr = SYS_CONFIG_PATHS[name]
return getattr(module, attr)
@registry.register(POST_STATUS_CANCEL_ALL)
def status_cancel_all(_request: Any, _body: Any) -> dict[str, Any]:
"""Revert pending changes for all subsystems to the last applied config.
Restores each pending subsystem's config file from its recorded
``_last_applied_config`` snapshot, discarding unapplied edits.
Subsystems without a recorded baseline (never applied) are skipped
with a reason instead of being reset. No live-system commands run —
cancel only touches the declarative config files.
Returns:
Dict with ``cancelled`` (list of reverted subsystems),
``skipped`` (label -> reason), and ``errors`` (label -> message).
"""
pending_data = status_pending(None, None)
fw_pending = pending_data["firewall"]["needs_apply"]
hash_pending = {
"dnsmasq": pending_data["dnsmasq"]["pending_changes"],
"nginx": pending_data["nginx"]["pending_changes"],
"wireguard": pending_data["wireguard"]["pending_changes"],
"networkd": pending_data["networkd"]["pending_changes"],
}
cancelled: list[str] = []
skipped: dict[str, str] = {}
errors: dict[str, str] = {}
for name in SYS_ORDER:
pending = fw_pending if name == "firewall" else hash_pending.get(name, False)
if not pending:
continue
label = SYS_LABELS.get(name, name)
try:
ok, reason = revert_to_applied(_config_path(name))
if ok:
cancelled.append(name)
logger.info("Cancelled pending changes for %s", name)
else:
skipped[label] = reason
logger.warning("Cancel-all skipped %s: %s", label, reason)
except Exception as exc:
errors[label] = str(exc)
logger.error("Cancel-all failed for %s: %s", name, exc)
if cancelled:
refresh_state(SYS_ORDER)
return {"cancelled": cancelled, "skipped": skipped, "errors": errors}
def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]:
"""Build pending result for a hash-based subsystem."""
if state is None:
+1
View File
@@ -209,6 +209,7 @@ GET_WS: Endpoint = _ep("GET", "/ws")
POST_BATCH: Endpoint = _ep("POST", "/batch")
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all")
POST_STATUS_CANCEL_ALL: Endpoint = _ep("POST", "/status/cancel-all")
GET_SYSTEM_METRICS: Endpoint = _ep("GET", "/system/metrics")
# Collect all endpoint module-level constants for __all__ verification
+27 -1
View File
@@ -1962,7 +1962,8 @@ Aggregate pending changes across all subsystems. Useful for the dashboard to sho
| Field | Type | Description |
|-------|------|-------------|
| `subsystems` | `object` | Map of subsystem name to pending status |
| `firewall` | `object` | `{ needs_apply, change_count, changes: [{summary, detail}] }` |
| `dnsmasq` / `nginx` / `wireguard` / `networkd` | `object` | `{ pending_changes, summary, changes: [{summary, detail}] }` |
| `total_changes` | `number` | Total count of pending changes across all subsystems |
---
@@ -1984,6 +1985,31 @@ Apply pending changes for all subsystems in dependency order.
---
#### Cancel All Pending Changes
```
POST /api/status/cancel-all
```
Revert pending changes for all subsystems to the last applied
configuration. Restores each pending subsystem's `config.json` from its
recorded `_last_applied_config` snapshot, discarding unapplied edits.
Subsystems without a recorded baseline (config never applied) are
reported as skipped and left untouched. No live-system commands run —
only the declarative config files are written.
**Request Body:** none.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `cancelled` | `[string, ...]` | Subsystems reverted to their last applied config |
| `skipped` | `object` | Map of subsystem label → reason (e.g. "No baseline recorded (never applied)") |
| `errors` | `object` | Map of subsystem label → error message |
---
#### Refresh State
```
+2
View File
@@ -538,6 +538,8 @@ Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reco
**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel.
## Networkd (IP Configuration)
**File**: `config/network/config.json`
+33
View File
@@ -1172,6 +1172,39 @@ Table({
})
```
### Apply / Cancel
`components/applyconfirm.js` — cross-subsystem apply/cancel buttons with a
shared expandable-subsystems modal. Both fetch `/api/status/pending` to
populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall,
dnsmasq, nginx, wireguard, networkd).
#### `ApplyConfirm(props)`
Button that opens the confirmation modal listing pending subsystems, then
POSTs `/api/status/apply-all`. When `props.pending` is false it renders a
disabled "synced" button that toasts on click.
**Parameters:** `pending` (bool), `label`, `syncedLabel`, `cls`,
`successMsg`, `refresh` (legacy, ignored).
#### `CancelConfirm(props)`
Button that opens the confirmation modal listing the subsystems that
would be reverted ("Restores the listed subsystems to their last applied
configuration, discarding changes saved since the last apply"), then
POSTs `/api/status/cancel-all`. Success toast appends skipped-subsystem
details when the response has a non-empty `skipped` map; errors from the
response are toasted separately. State-store models update from the
daemon's WS delta — no explicit `modelFetch`.
**Parameters:** `label` (default `'Cancel All Changes'`), `cls`
(default `'btn btn-danger'`).
```javascript
CancelConfirm({ cls: 'btn btn-sm btn-danger' })
```
### Modal
#### `openModal(renderFn)`
+11
View File
@@ -17,6 +17,17 @@ return annotation references them.
- A subsystem whose collection failed holds `null`/`None` in the state
store — WS snapshots and deltas skip `null` payloads so a failed
collector never overwrites good client data.
- Config-backed subsystems record their applied baseline inside the config
file itself: `_last_applied_config` (the full merged config at last
apply) and `_last_applied_hash` (its SHA-256). A hash subsystem's
`status.pending_changes` is true when the current (merged) config hash
differs from the recorded hash; `status.pending_diff` lists the field
changes since that snapshot. All apply operations (including firewall
`config_apply`) re-stamp the baseline. These bookkeeping keys are
internal and stripped from every state/API config payload. Canceling
pending changes (`POST /api/status/cancel-all`) restores a pending
config file from its snapshot; a subsystem with no recorded baseline
(never applied) is reported as skipped, not reset.
## State shape summary
+24
View File
@@ -55,6 +55,29 @@ def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]:
return cfg
def revert_to_applied(path: Path) -> tuple[bool, str]:
"""Restore the config file at *path* to its last-applied snapshot.
Reads the raw file, and when it records a ``_last_applied_config``
snapshot, rewrites the file from that snapshot (stamped with a fresh
hash so the pending check reports the config as up to date).
Args:
path: Path to the config JSON file to revert.
Returns:
Tuple ``(True, "")`` when the file was restored, or
``(False, reason)`` when it could not be (missing file or no
recorded baseline i.e. the config was never applied).
"""
cfg = load_json(path)
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if not isinstance(snap, dict):
return False, "No baseline recorded (never applied)"
save_json(path, stamp_applied(deepcopy(snap)))
return True, ""
def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]:
"""Return a list of field-level changes between two configurations.
@@ -284,6 +307,7 @@ __all__ = [
"ensure_dirs",
"get_interface_ip",
"load_json",
"revert_to_applied",
"run",
"run_proc",
"save_json",
+2 -2
View File
@@ -517,12 +517,12 @@ def _collect_firewall() -> schema.FirewallState:
except Exception:
pass
# Load config
# Load config (strip apply bookkeeping keys, as the other collectors do)
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
config_data = {}
if fw_config_path.exists():
with contextlib.suppress(Exception):
config_data = load_json(fw_config_path)
config_data = strip_apply_meta(load_json(fw_config_path))
# Pending changes
full_state = {
+87
View File
@@ -0,0 +1,87 @@
/**
* Tests for hoover/components/applyconfirm.js CancelConfirm
*
* Component-level tests: VNode structure of the cancel button.
* Run with `node tests/test-cancelconfirm.js`.
*/
import { CancelConfirm, buildRows, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
}
function assertIncludes(str, substr, msg) {
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
}
console.log('Testing CancelConfirm component\n');
test('CancelConfirm renders a button vnode', () => {
const vnode = CancelConfirm();
assertEq(vnode.tag, 'button');
});
test('CancelConfirm default label', () => {
const vnode = CancelConfirm();
const text = vnode.ch.find(c => c.tag === '#text');
assert(text, 'should have text child');
assertEq(text.text, 'Cancel All Changes');
});
test('CancelConfirm default class is danger', () => {
const vnode = CancelConfirm();
assertIncludes(vnode.props.class, 'btn-danger');
assertIncludes(vnode.props.class, 'btn');
});
test('CancelConfirm accepts a custom class', () => {
const vnode = CancelConfirm({ cls: 'btn btn-sm btn-danger' });
assertEq(vnode.props.class, 'btn btn-sm btn-danger');
});
test('CancelConfirm accepts a custom label', () => {
const vnode = CancelConfirm({ label: 'Discard Changes' });
const text = vnode.ch.find(c => c.tag === '#text');
assertEq(text.text, 'Discard Changes');
});
test('CancelConfirm has a click handler', () => {
const vnode = CancelConfirm();
assert(typeof vnode.props['on:click'] === 'function', 'on:click should be a function');
});
// === shared modal row builder (used by the cancel modal) ===
test('buildRows still drives the cancel modal rows', () => {
const data = {
firewall: { needs_apply: true, changes: [{ summary: 'Zone internal: interfaces changed', detail: '' }] },
dnsmasq: { pending_changes: true, changes: [{ summary: 'x', detail: '' }] },
};
const rows = buildRows(data, {});
assertEq(rows.length, SUBSYSTEM_LIST.length);
const fwRow = rows[0];
assertIncludes(fwRow.props.class, 'pending');
const dmRow = rows[1];
assertIncludes(dmRow.props.class, 'pending');
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+20
View File
@@ -997,4 +997,24 @@ class TestStatusRefresh:
mock_post.side_effect = RuntimeError("no daemon")
resp = status_client.post("/api/status/refresh", json={})
assert resp.status_code == 500
class TestStatusCancelAll:
@_st("post")
def test_success(self, mock_post, status_client):
from daemon.iface import POST_STATUS_CANCEL_ALL
mock_post.return_value = {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
resp = status_client.post("/api/status/cancel-all")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"] == {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
mock_post.assert_called_once_with(POST_STATUS_CANCEL_ALL)
@_st("post")
def test_runtime_error(self, mock_post, status_client):
mock_post.side_effect = RuntimeError("no daemon")
resp = status_client.post("/api/status/cancel-all")
assert resp.status_code == 500
assert resp.get_json()["ok"] is False
+66
View File
@@ -7,6 +7,9 @@ from lib.common import (
_LAST_APPLIED_CONFIG_KEY,
config_hash,
deep_diff,
load_json,
revert_to_applied,
save_json,
stamp_applied,
strip_apply_meta,
)
@@ -82,3 +85,66 @@ class TestDashboardFallback:
status = {"pending_changes": True, "pending_diff": []}
assert status["pending_changes"] is True
assert status["pending_diff"] == []
class TestRevertToApplied:
def test_restores_snapshot_and_clears_pending(self, tmp_path):
path = tmp_path / "config.json"
applied = {"zones": {"lan": {"services": ["http"]}}}
stamped = dict(applied)
stamp_applied(stamped)
# Drift the file after apply (the "pending" state).
dirty = {"zones": {"lan": {"services": ["http", "ssh"]}}}
dirty[_LAST_APPLIED_CONFIG_KEY] = dict(applied)
dirty[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
save_json(path, dirty, indent=2)
# Sanity: pending check would report drift.
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty)
ok, reason = revert_to_applied(path)
assert ok and reason == ""
restored = load_json(path)
assert _APPLY_HASH_KEY in restored and restored[_APPLY_HASH_KEY] == config_hash(
restored
)
assert (
_LAST_APPLIED_CONFIG_KEY in restored
and restored[_LAST_APPLIED_CONFIG_KEY] == applied
)
assert (
deep_diff(
restored.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(restored)
)
== []
)
def test_no_baseline(self, tmp_path):
path = tmp_path / "config.json"
save_json(path, {"zones": {}}, indent=2)
ok, reason = revert_to_applied(path)
assert not ok
assert reason
# File untouched.
assert _LAST_APPLIED_CONFIG_KEY not in load_json(path)
def test_missing_file(self, tmp_path):
ok, reason = revert_to_applied(tmp_path / "nope.json")
assert not ok
assert reason
def test_stale_hash_but_snapshot_present(self, tmp_path):
# Baseline recorded, hash stale (drifted) → still revertable.
path = tmp_path / "config.json"
applied = {"a": 1}
stamped = dict(applied)
stamp_applied(stamped)
stamp = dict(stamped)
stamp["a"] = 99 # edited without re-stamping
save_json(path, stamp, indent=2)
assert stamp[_APPLY_HASH_KEY] != config_hash(stamp)
ok, _ = revert_to_applied(path)
assert ok
restored = load_json(path)
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
+89
View File
@@ -1,5 +1,6 @@
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
from copy import deepcopy
from unittest.mock import MagicMock, call, patch
import pytest
@@ -7,6 +8,12 @@ import pytest
from daemon.handlers import firewall as daemonfirewall
from daemon.server import ConflictError, NotFoundError
from lib import firewall
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
strip_apply_meta,
)
# ---------------------------------------------------------------------------
# lib/firewall.py — pure parsing (no sudo)
@@ -531,6 +538,11 @@ class TestDaemonConfigApply:
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
@@ -661,10 +673,87 @@ class TestDaemonMgmtLockoutGuard:
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply(force=True)
assert result["applied_zones"] == ["public"]
_STAMP_TEST_CFG = {
"zones": {
"public": {
"target": "DEFAULT",
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
class TestDaemonConfigApplyStamp:
"""Verify _config_apply records the applied baseline in the config file."""
ZONE_LIST_ALL_OUT = (
"target: default\ninterfaces: \nsources: \nservices: \nports: \n"
"protocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \n"
"icmp-blocks: \nmodule: \n"
)
@patch(
"lib.firewall.get_config",
return_value=_STAMP_TEST_CFG,
create=True,
)
@patch(
"daemon.handlers.firewall.run",
return_value=ZONE_LIST_ALL_OUT,
)
def test_stamps_applied_baseline(self, mock_run, mock_cfg):
with (
patch(
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
),
patch(
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(_STAMP_TEST_CFG),
),
patch("daemon.handlers.firewall._save_config") as mock_save,
):
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
saved = mock_save.call_args[0][0]
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
# The snapshot is the applied (meta-stripped) config.
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
class TestDaemonGetConfigEndpoint:
def test_strips_apply_meta(self):
with patch.object(
daemonfirewall,
"_get_config",
return_value={
"zones": {},
_APPLY_HASH_KEY: "h",
_LAST_APPLIED_CONFIG_KEY: {"zones": {}},
},
):
result = daemonfirewall.get_config(None, None)
assert result == {"zones": {}}
@patch(
"daemon.handlers.firewall._config_apply",
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
+221
View File
@@ -0,0 +1,221 @@
"""Tests for daemon/handlers/status.py — cancel-all (revert to applied)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, ClassVar
from unittest.mock import patch
from daemon.handlers import status
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
stamp_applied,
)
def _pending(firewall: bool = False, **flags: bool) -> dict[str, Any]:
"""Build status_pending() output with the given subsystems pending.
Mirrors the real handler shape: every subsystem key is always present.
"""
data: dict[str, Any] = {
"firewall": {
"needs_apply": firewall,
"change_count": 1 if firewall else 0,
"changes": [],
},
}
total = 1 if firewall else 0
for name in ("dnsmasq", "nginx", "wireguard", "networkd"):
flagged = bool(flags.get(name, False))
data[name] = {
"pending_changes": flagged,
"summary": "x" if flagged else "Up to date",
"changes": [],
}
total += 1 if flagged else 0
data["total_changes"] = total
return data
class TestConfigPathResolution:
"""SYS_CONFIG_PATHS points at the constants the handlers actually use."""
def test_resolves_to_module_constants(self):
import daemon.handlers.dnsmasq as dm_h
import daemon.handlers.firewall as fw_h
import daemon.handlers.nginx as ngx_h
import lib.network as net_lib
import lib.wireguard as wg_lib
assert status._config_path("firewall") == fw_h.CONFIG_FILE
assert status._config_path("dnsmasq") == dm_h.CONFIG_PATH
assert status._config_path("nginx") == ngx_h.CONFIG_FILE
assert status._config_path("wireguard") == wg_lib.CONFIG_PATH
assert status._config_path("networkd") == net_lib.CONFIG_FILE
def test_all_subsystems_mapped(self):
assert set(status.SYS_CONFIG_PATHS) == set(status.SYS_ORDER)
class TestStatusCancelAll:
"""Test the cancel-all endpoint.
Patches the ``_config_path`` seam directly since it resolves the real
module constants via getattr at call time.
"""
_fake_pending_all: ClassVar[dict[str, Any]] = _pending()
def _write_config(
self, path: Path, cfg: dict[str, Any], baseline: dict | None
) -> None:
"""Write *cfg* (with an attached applied baseline when *baseline* given)."""
path.parent.mkdir(parents=True, exist_ok=True)
stamped = dict(cfg)
if baseline is not None:
b = dict(baseline)
stamp_applied(b)
stamped[_LAST_APPLIED_CONFIG_KEY] = dict(baseline)
stamped[_APPLY_HASH_KEY] = b[_APPLY_HASH_KEY]
path.write_text(json.dumps(stamped, indent=2) + "\n")
def _read_config(self, path: Path) -> dict[str, Any]:
return json.loads(path.read_text())
@patch("daemon.handlers.status.status_pending")
@patch("daemon.handlers.status.refresh_state")
def test_nothing_pending(self, mock_refresh, mock_pending):
mock_pending.return_value = self._fake_pending_all
with patch("daemon.handlers.status._config_path", side_effect=AssertionError):
result = status.status_cancel_all(None, None)
assert result == {"cancelled": [], "skipped": {}, "errors": {}}
mock_refresh.assert_not_called()
def test_reverts_pending_subsystem(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
applied = {"dns": {"upstreams": ["8.8.8.8"]}}
# Baseline applied, then the user edited the config (drift).
self._write_config(
paths["dnsmasq"], {"dns": {"upstreams": ["1.1.1.1"]}}, applied
)
dirty = self._read_config(paths["dnsmasq"])
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty) # really pending
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(dnsmasq=True),
),
patch("daemon.handlers.status.refresh_state") as mock_refresh,
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
assert result == {
"cancelled": ["dnsmasq"],
"skipped": {},
"errors": {},
}
mock_refresh.assert_called_once_with(status.SYS_ORDER)
restored = self._read_config(paths["dnsmasq"])
assert restored.get(_LAST_APPLIED_CONFIG_KEY) == applied
# Cancel restored the baseline: the pending check now passes.
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
def test_skipped_when_no_baseline(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
cfg_no_baseline = {"peers": {"a": {}}}
self._write_config(paths["wireguard"], cfg_no_baseline, None)
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(wireguard=True),
),
patch("daemon.handlers.status.refresh_state") as mock_refresh,
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
assert result["cancelled"] == []
assert "WireGuard" in result["skipped"]
assert result["errors"] == {}
mock_refresh.assert_not_called()
# File left untouched.
assert self._read_config(paths["wireguard"]) == cfg_no_baseline
def test_error_in_one_subsystem_others_still_cancel(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
for name in ("firewall", "nginx"):
self._write_config(
paths[name],
{"zones": {}} if name == "firewall" else {"domains": {}},
{"x": 1},
)
def fake_revert(path):
if "nginx" in str(path):
raise RuntimeError("disk full")
return True, ""
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(firewall=True, nginx=True),
),
patch("daemon.handlers.status.refresh_state"),
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
# SYS_ORDER runs firewall before nginx.
assert result["cancelled"] == ["firewall"]
assert result["errors"] == {"Nginx": "disk full"}
assert result["skipped"] == {}
def test_order_matches_sys_order(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
call_order = []
def fake_revert(path):
call_order.append(next(n for n, p in paths.items() if p == path))
return True, ""
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(
firewall=True,
dnsmasq=True,
nginx=True,
wireguard=True,
networkd=True,
),
),
patch("daemon.handlers.status.refresh_state"),
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
assert result["cancelled"] == status.SYS_ORDER
assert call_order == status.SYS_ORDER
+19
View File
@@ -14,6 +14,7 @@ from daemon.iface import (
GET_STATUS_PENDING,
GET_SYSTEM_METRICS,
POST_STATUS_APPLY_ALL,
POST_STATUS_CANCEL_ALL,
POST_STATUS_REFRESH,
)
from webui.api.common import _error, _ok
@@ -56,6 +57,24 @@ def apply_all():
return _error(str(exc), 500)
@bp.route("/cancel-all", methods=["POST"])
def cancel_all():
"""Revert pending changes for all subsystems to the last applied config.
Endpoint:
POST /api/status/cancel-all
Returns:
JSON response with the reverted subsystems, skipped subsystems
(label -> reason), and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_CANCEL_ALL))
except RuntimeError as exc:
logger.error("Failed to cancel all pending changes: %s", exc)
return _error(str(exc), 500)
@bp.route("/refresh", methods=["POST"])
def refresh():
"""Re-collect state from the daemon, optionally filtered by subsystem.
@@ -140,3 +140,91 @@ export function ApplyConfirm(props = {}) {
},
}, props.pending ? label : syncedLabel);
}
/**
* POST cancel-all, toast result, close modal. State-store models update from
* the daemon's WS delta no explicit refresh.
*/
async function doCancelAll() {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/cancel-all', { method: 'POST' });
if (resp.ok) {
const data = resp.data || {};
let msg = 'Pending changes cancelled';
const nSkipped = Object.keys(data.skipped || {}).length;
if (nSkipped) {
msg += ` (${nSkipped} skipped: ` +
Object.entries(data.skipped).map(([k, v]) => `${k}${v}`).join('; ') + ')';
}
toast(msg, nSkipped ? 'warning' : 'success', nSkipped ? 8000 : undefined);
const errs = data.errors || {};
const nErrs = Object.keys(errs).length;
if (nErrs) {
toast('Cancel failed for: ' +
Object.entries(errs).map(([k, v]) => `${k}${v}`).join('; '),
'error', 8000);
}
closeModal();
// No modelFetch — WS delta updates all affected subsystems.
} else {
toast(resp.error || 'Cancel failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
}
/**
* Fetch pending state, then open the cancel confirmation modal.
*/
async function openCancelModal() {
const pendingResp = await apiFetch('/api/status/pending');
if (!pendingResp.ok) {
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
return;
}
const pendingData = pendingResp.data || {};
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
openModal((inner) => {
if (totalChanges === 0) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
<div class="apply-no-changes">No pending changes to cancel.</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Close</button></div>
</div>`);
return;
}
const rows = buildRows(pendingData, expanded);
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
<p>Restores the listed subsystems to their last applied configuration, discarding changes saved since the last apply.</p>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Keep Changes</button><button class="btn btn-danger" onClick="${() => doCancelAll()}">Cancel All Changes</button></div>
</div>`);
});
}
/**
* Cancel button with cross-subsystem confirmation modal.
*
* @param {object} props
* @param {string} [props.label] - Button text (default: 'Cancel All Changes')
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-danger')
*/
export function CancelConfirm(props = {}) {
const label = props.label || 'Cancel All Changes';
return h('button', {
class: props.cls !== undefined ? props.cls : 'btn btn-danger',
'on:click': () => openCancelModal(),
}, label);
}
+1 -1
View File
@@ -53,7 +53,7 @@ export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, cert
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js';
/* ── UI Components: Apply ────────────────────────────────────── */
export { ApplyConfirm } from './components/applyconfirm.js';
export { ApplyConfirm, CancelConfirm } from './components/applyconfirm.js';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js';
+9 -9
View File
@@ -1,4 +1,4 @@
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, fmtBytes } from '/static/hoover/index.js';
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton, CancelConfirm, fmtBytes } from '/static/hoover/index.js';
// Render a single firewall change as "current → new".
// `live` is the currently applied value; `config` is the target value it
@@ -144,7 +144,7 @@ export default definePage({
meta=${expiringCerts.length ? expiringCerts.length + ' expiring' : 'All valid'} />
</div>`;
// ── Pending changes ──
// ── Pending changes (hidden entirely when nothing is pending) ──
const pendingCard = pendingBlocks.length > 0
? html`<div class="card">
<div class="card-header">Pending Changes <span style="margin-left:8px"><${Badge} text=${String(totalChanges)} variant="warning" /></span></div>
@@ -157,15 +157,15 @@ export default definePage({
</ul>
</li>`)}
</ul>
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
successMsg="All changes applied"
cls="btn btn-sm btn-primary" />
<div style="display:flex;gap:8px;flex-wrap:wrap">
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
successMsg="All changes applied"
cls="btn btn-sm btn-primary" />
<${CancelConfirm} cls="btn btn-sm btn-danger" />
</div>
</div>
</div>`
: html`<div class="card">
<div class="card-header">Pending Changes</div>
<div class="card-body"><${Badge} text="All configured" variant="success" /></div>
</div>`;
: null;
// ── System resources ──
const memPct = sysMem.used_pct || 0;