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:
@@ -34,7 +34,7 @@ from daemon.iface import (
|
|||||||
POST_FIREWALL_ZONES_SERVICES,
|
POST_FIREWALL_ZONES_SERVICES,
|
||||||
)
|
)
|
||||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
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 (
|
from lib.firewall import (
|
||||||
_normalize_target,
|
_normalize_target,
|
||||||
_parse_active_zones,
|
_parse_active_zones,
|
||||||
@@ -391,6 +391,11 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
|||||||
"timestamp": "",
|
"timestamp": "",
|
||||||
}
|
}
|
||||||
backup_path = _save_backup(full_state)
|
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))
|
logger.info("Firewall config applied to %d zones", len(applied))
|
||||||
return {
|
return {
|
||||||
"applied_zones": applied,
|
"applied_zones": applied,
|
||||||
@@ -506,9 +511,9 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
_body: The request body (unused).
|
_body: The request body (unused).
|
||||||
|
|
||||||
Returns:
|
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)
|
@registry.register(POST_FIREWALL_CONFIG)
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
"""Aggregate status handler.
|
"""Aggregate status handler.
|
||||||
|
|
||||||
Exposes pending changes across all subsystems and a single apply-all
|
Exposes pending changes across all subsystems, a single apply-all
|
||||||
endpoint that invokes each subsystem's apply in the correct order.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
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.dnsmasq import apply_config as dnsmasq_apply_config
|
||||||
from daemon.handlers.firewall import config_apply as firewall_config_apply
|
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.network import apply_all as network_apply_all
|
||||||
from daemon.handlers.nginx import apply as nginx_apply
|
from daemon.handlers.nginx import apply as nginx_apply
|
||||||
from daemon.handlers.wireguard import apply as wireguard_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 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.firewall import fw_change_summary
|
||||||
from lib.state import state as state_store
|
from lib.state import state as state_store
|
||||||
|
|
||||||
@@ -36,6 +48,15 @@ SYS_APPLY = {
|
|||||||
"dnsmasq": dnsmasq_apply_config,
|
"dnsmasq": dnsmasq_apply_config,
|
||||||
"nginx": nginx_apply,
|
"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)
|
@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}
|
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]:
|
def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Build pending result for a hash-based subsystem."""
|
"""Build pending result for a hash-based subsystem."""
|
||||||
if state is None:
|
if state is None:
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ GET_WS: Endpoint = _ep("GET", "/ws")
|
|||||||
POST_BATCH: Endpoint = _ep("POST", "/batch")
|
POST_BATCH: Endpoint = _ep("POST", "/batch")
|
||||||
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
|
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
|
||||||
POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all")
|
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")
|
GET_SYSTEM_METRICS: Endpoint = _ep("GET", "/system/metrics")
|
||||||
|
|
||||||
# Collect all endpoint module-level constants for __all__ verification
|
# Collect all endpoint module-level constants for __all__ verification
|
||||||
|
|||||||
+27
-1
@@ -1962,7 +1962,8 @@ Aggregate pending changes across all subsystems. Useful for the dashboard to sho
|
|||||||
|
|
||||||
| Field | Type | Description |
|
| 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 |
|
| `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
|
#### Refresh State
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
**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)
|
## Networkd (IP Configuration)
|
||||||
|
|
||||||
**File**: `config/network/config.json`
|
**File**: `config/network/config.json`
|
||||||
|
|||||||
@@ -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
|
### Modal
|
||||||
|
|
||||||
#### `openModal(renderFn)`
|
#### `openModal(renderFn)`
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ return annotation references them.
|
|||||||
- A subsystem whose collection failed holds `null`/`None` in the state
|
- A subsystem whose collection failed holds `null`/`None` in the state
|
||||||
store — WS snapshots and deltas skip `null` payloads so a failed
|
store — WS snapshots and deltas skip `null` payloads so a failed
|
||||||
collector never overwrites good client data.
|
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
|
## State shape summary
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,29 @@ def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return cfg
|
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]]:
|
def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]:
|
||||||
"""Return a list of field-level changes between two configurations.
|
"""Return a list of field-level changes between two configurations.
|
||||||
|
|
||||||
@@ -284,6 +307,7 @@ __all__ = [
|
|||||||
"ensure_dirs",
|
"ensure_dirs",
|
||||||
"get_interface_ip",
|
"get_interface_ip",
|
||||||
"load_json",
|
"load_json",
|
||||||
|
"revert_to_applied",
|
||||||
"run",
|
"run",
|
||||||
"run_proc",
|
"run_proc",
|
||||||
"save_json",
|
"save_json",
|
||||||
|
|||||||
+2
-2
@@ -517,12 +517,12 @@ def _collect_firewall() -> schema.FirewallState:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Load config
|
# Load config (strip apply bookkeeping keys, as the other collectors do)
|
||||||
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
|
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
|
||||||
config_data = {}
|
config_data = {}
|
||||||
if fw_config_path.exists():
|
if fw_config_path.exists():
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
config_data = load_json(fw_config_path)
|
config_data = strip_apply_meta(load_json(fw_config_path))
|
||||||
|
|
||||||
# Pending changes
|
# Pending changes
|
||||||
full_state = {
|
full_state = {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -997,4 +997,24 @@ class TestStatusRefresh:
|
|||||||
mock_post.side_effect = RuntimeError("no daemon")
|
mock_post.side_effect = RuntimeError("no daemon")
|
||||||
resp = status_client.post("/api/status/refresh", json={})
|
resp = status_client.post("/api/status/refresh", json={})
|
||||||
assert resp.status_code == 500
|
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
|
assert resp.get_json()["ok"] is False
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ from lib.common import (
|
|||||||
_LAST_APPLIED_CONFIG_KEY,
|
_LAST_APPLIED_CONFIG_KEY,
|
||||||
config_hash,
|
config_hash,
|
||||||
deep_diff,
|
deep_diff,
|
||||||
|
load_json,
|
||||||
|
revert_to_applied,
|
||||||
|
save_json,
|
||||||
stamp_applied,
|
stamp_applied,
|
||||||
strip_apply_meta,
|
strip_apply_meta,
|
||||||
)
|
)
|
||||||
@@ -82,3 +85,66 @@ class TestDashboardFallback:
|
|||||||
status = {"pending_changes": True, "pending_diff": []}
|
status = {"pending_changes": True, "pending_diff": []}
|
||||||
assert status["pending_changes"] is True
|
assert status["pending_changes"] is True
|
||||||
assert status["pending_diff"] == []
|
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)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
|
"""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
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -7,6 +8,12 @@ import pytest
|
|||||||
from daemon.handlers import firewall as daemonfirewall
|
from daemon.handlers import firewall as daemonfirewall
|
||||||
from daemon.server import ConflictError, NotFoundError
|
from daemon.server import ConflictError, NotFoundError
|
||||||
from lib import firewall
|
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)
|
# lib/firewall.py — pure parsing (no sudo)
|
||||||
@@ -531,6 +538,11 @@ class TestDaemonConfigApply:
|
|||||||
return_value={"zones": {"public": {}}},
|
return_value={"zones": {"public": {}}},
|
||||||
),
|
),
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
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()
|
result = daemonfirewall._config_apply()
|
||||||
assert result["applied_zones"] == ["public"]
|
assert result["applied_zones"] == ["public"]
|
||||||
@@ -661,10 +673,87 @@ class TestDaemonMgmtLockoutGuard:
|
|||||||
return_value={"zones": {"public": {}}},
|
return_value={"zones": {"public": {}}},
|
||||||
),
|
),
|
||||||
patch("daemon.handlers.firewall.refresh_state"),
|
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)
|
result = daemonfirewall._config_apply(force=True)
|
||||||
assert result["applied_zones"] == ["public"]
|
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(
|
@patch(
|
||||||
"daemon.handlers.firewall._config_apply",
|
"daemon.handlers.firewall._config_apply",
|
||||||
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,6 +14,7 @@ from daemon.iface import (
|
|||||||
GET_STATUS_PENDING,
|
GET_STATUS_PENDING,
|
||||||
GET_SYSTEM_METRICS,
|
GET_SYSTEM_METRICS,
|
||||||
POST_STATUS_APPLY_ALL,
|
POST_STATUS_APPLY_ALL,
|
||||||
|
POST_STATUS_CANCEL_ALL,
|
||||||
POST_STATUS_REFRESH,
|
POST_STATUS_REFRESH,
|
||||||
)
|
)
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import _error, _ok
|
||||||
@@ -56,6 +57,24 @@ def apply_all():
|
|||||||
return _error(str(exc), 500)
|
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"])
|
@bp.route("/refresh", methods=["POST"])
|
||||||
def refresh():
|
def refresh():
|
||||||
"""Re-collect state from the daemon, optionally filtered by subsystem.
|
"""Re-collect state from the daemon, optionally filtered by subsystem.
|
||||||
|
|||||||
@@ -140,3 +140,91 @@ export function ApplyConfirm(props = {}) {
|
|||||||
},
|
},
|
||||||
}, props.pending ? label : syncedLabel);
|
}, 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js';
|
||||||
|
|
||||||
/* ── UI Components: Apply ────────────────────────────────────── */
|
/* ── UI Components: Apply ────────────────────────────────────── */
|
||||||
export { ApplyConfirm } from './components/applyconfirm.js';
|
export { ApplyConfirm, CancelConfirm } from './components/applyconfirm.js';
|
||||||
|
|
||||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||||
export { ToastContainer } from './components/toast.js';
|
export { ToastContainer } from './components/toast.js';
|
||||||
|
|||||||
@@ -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".
|
// Render a single firewall change as "current → new".
|
||||||
// `live` is the currently applied value; `config` is the target value it
|
// `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'} />
|
meta=${expiringCerts.length ? expiringCerts.length + ' expiring' : 'All valid'} />
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// ── Pending changes ──
|
// ── Pending changes (hidden entirely when nothing is pending) ──
|
||||||
const pendingCard = pendingBlocks.length > 0
|
const pendingCard = pendingBlocks.length > 0
|
||||||
? html`<div class="card">
|
? html`<div class="card">
|
||||||
<div class="card-header">Pending Changes <span style="margin-left:8px"><${Badge} text=${String(totalChanges)} variant="warning" /></span></div>
|
<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>
|
</ul>
|
||||||
</li>`)}
|
</li>`)}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
||||||
successMsg="All changes applied"
|
successMsg="All changes applied"
|
||||||
cls="btn btn-sm btn-primary" />
|
cls="btn btn-sm btn-primary" />
|
||||||
|
<${CancelConfirm} cls="btn btn-sm btn-danger" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
: html`<div class="card">
|
: null;
|
||||||
<div class="card-header">Pending Changes</div>
|
|
||||||
<div class="card-body"><${Badge} text="All configured" variant="success" /></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// ── System resources ──
|
// ── System resources ──
|
||||||
const memPct = sysMem.used_pct || 0;
|
const memPct = sysMem.used_pct || 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user