state: applied-config snapshots + per-field pending diffs
- lib/common: stamp_applied() now records a _last_applied_config snapshot alongside the hash; strip_apply_meta() centralizes bookkeeping-key stripping; deep_diff() reports field-level changes - state collectors (dnsmasq/nginx/wireguard/networkd) expose pending_diff so the dashboard can show exactly which fields changed since the last apply (wireguard diff excludes private_key paths) - dashboard pending-changes card renders per-change lines with a generic fallback when no snapshot is recorded - firewall: firewalld built-in zones no longer flagged as unmanaged; public-zone masquerade skipped in pending changes since apply drives it via nftables propagation - schema: PendingChange TypedDict; pending_diff on DnsmasqStatus / WgStatus; tests in test_common.py, test_firewall.py, test_state.py
This commit is contained in:
@@ -26,14 +26,14 @@ from daemon.iface import (
|
|||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import NotFoundError, refresh_state, registry
|
||||||
from lib.common import (
|
from lib.common import (
|
||||||
_APPLY_HASH_KEY,
|
|
||||||
config_hash,
|
|
||||||
deep_merge,
|
deep_merge,
|
||||||
ensure_dirs,
|
ensure_dirs,
|
||||||
get_interface_ip,
|
get_interface_ip,
|
||||||
load_json,
|
load_json,
|
||||||
run,
|
run,
|
||||||
save_json,
|
save_json,
|
||||||
|
stamp_applied,
|
||||||
|
strip_apply_meta,
|
||||||
)
|
)
|
||||||
from lib.sync import SyncEvent, bus
|
from lib.sync import SyncEvent, bus
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
if dm:
|
if dm:
|
||||||
return dm.get("config", {})
|
return dm.get("config", {})
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
return {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
return strip_apply_meta(cfg)
|
||||||
|
|
||||||
|
|
||||||
@registry.register(POST_DNSMASQ_CONFIG)
|
@registry.register(POST_DNSMASQ_CONFIG)
|
||||||
@@ -196,9 +196,10 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
run(["systemctl", "restart", "dnsmasq"], sudo=True)
|
run(["systemctl", "restart", "dnsmasq"], sudo=True)
|
||||||
logger.info("dnsmasq config written and restarted")
|
logger.info("dnsmasq config written and restarted")
|
||||||
# Store the config hash so state collector can detect pending changes
|
# Store the applied config snapshot + hash so the state collector can
|
||||||
|
# detect pending changes and report what specifically changed.
|
||||||
cfg_after = _get_config()
|
cfg_after = _get_config()
|
||||||
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
_save_config(cfg_after)
|
_save_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
sync_result = bus.emit(
|
||||||
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
|
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from daemon.iface import (
|
|||||||
POST_NETWORK_SYSCTL_SET,
|
POST_NETWORK_SYSCTL_SET,
|
||||||
)
|
)
|
||||||
from daemon.server import NotFoundError, refresh_state, registry
|
from daemon.server import NotFoundError, refresh_state, registry
|
||||||
from lib.common import _APPLY_HASH_KEY, config_hash, run, validate_interface_name
|
from lib.common import run, stamp_applied, validate_interface_name
|
||||||
from lib.dnsmasq import get_config as _get_dm_cfg
|
from lib.dnsmasq import get_config as _get_dm_cfg
|
||||||
from lib.dnsmasq import save_config as _save_dm_cfg
|
from lib.dnsmasq import save_config as _save_dm_cfg
|
||||||
from lib.dnsmasq import set_upstreams
|
from lib.dnsmasq import set_upstreams
|
||||||
@@ -218,7 +218,7 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
|||||||
# even when deployment fails (e.g. in containerized environments).
|
# even when deployment fails (e.g. in containerized environments).
|
||||||
# The hash represents the JSON config state, not the system state.
|
# The hash represents the JSON config state, not the system state.
|
||||||
cfg_after = get_config()
|
cfg_after = get_config()
|
||||||
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
save_config(cfg_after)
|
save_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
sync_result = bus.emit(
|
||||||
SyncEvent(
|
SyncEvent(
|
||||||
@@ -286,16 +286,17 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
upstreams = collect_upstream_dns(cfg)
|
upstreams = collect_upstream_dns(cfg)
|
||||||
if upstreams:
|
if upstreams:
|
||||||
set_upstreams(upstreams)
|
set_upstreams(upstreams)
|
||||||
# Update dnsmasq apply hash so pending-changes detection stays correct
|
# Update dnsmasq applied snapshot + hash so pending-changes
|
||||||
|
# detection stays correct
|
||||||
dm_cfg = _get_dm_cfg()
|
dm_cfg = _get_dm_cfg()
|
||||||
dm_cfg[_APPLY_HASH_KEY] = config_hash(dm_cfg)
|
stamp_applied(dm_cfg)
|
||||||
_save_dm_cfg(dm_cfg)
|
_save_dm_cfg(dm_cfg)
|
||||||
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
|
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
|
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
|
||||||
|
|
||||||
cfg_after = get_config()
|
cfg_after = get_config()
|
||||||
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
save_config(cfg_after)
|
save_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
sync_result = bus.emit(
|
||||||
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
|
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ from daemon.iface import (
|
|||||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||||
from lib.acme import find_cert_dir
|
from lib.acme import find_cert_dir
|
||||||
from lib.common import (
|
from lib.common import (
|
||||||
_APPLY_HASH_KEY,
|
|
||||||
config_hash,
|
|
||||||
deep_merge,
|
deep_merge,
|
||||||
ensure_dirs,
|
ensure_dirs,
|
||||||
load_json,
|
load_json,
|
||||||
run,
|
run,
|
||||||
run_proc,
|
run_proc,
|
||||||
save_json,
|
save_json,
|
||||||
|
stamp_applied,
|
||||||
|
strip_apply_meta,
|
||||||
)
|
)
|
||||||
from lib.nginx import DEFAULT_SSL, WEBUI_BACKEND
|
from lib.nginx import DEFAULT_SSL, WEBUI_BACKEND
|
||||||
from lib.nginx import _resolve_auth as _ngx_resolve_auth
|
from lib.nginx import _resolve_auth as _ngx_resolve_auth
|
||||||
@@ -494,7 +494,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
if ng:
|
if ng:
|
||||||
return ng.get("config", {})
|
return ng.get("config", {})
|
||||||
cfg = _get_config()
|
cfg = _get_config()
|
||||||
return {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
return strip_apply_meta(cfg)
|
||||||
|
|
||||||
|
|
||||||
@registry.register(POST_NGINX_CONFIG)
|
@registry.register(POST_NGINX_CONFIG)
|
||||||
@@ -741,7 +741,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
raise RuntimeError(f"nginx config test failed: {msg}")
|
raise RuntimeError(f"nginx config test failed: {msg}")
|
||||||
_reload_nginx()
|
_reload_nginx()
|
||||||
cfg_after = _get_config()
|
cfg_after = _get_config()
|
||||||
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
_save_config(cfg_after)
|
_save_config(cfg_after)
|
||||||
refresh_state(["nginx"])
|
refresh_state(["nginx"])
|
||||||
return {"applied": True}
|
return {"applied": True}
|
||||||
|
|||||||
@@ -28,12 +28,7 @@ from daemon.iface import (
|
|||||||
POST_WIREGUARD_PEERS_ADD,
|
POST_WIREGUARD_PEERS_ADD,
|
||||||
)
|
)
|
||||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||||
from lib.common import (
|
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
|
||||||
_APPLY_HASH_KEY,
|
|
||||||
config_hash,
|
|
||||||
deep_merge,
|
|
||||||
run,
|
|
||||||
)
|
|
||||||
from lib.sync import SyncEvent, bus
|
from lib.sync import SyncEvent, bus
|
||||||
from lib.wireguard import (
|
from lib.wireguard import (
|
||||||
_class_interface_name,
|
_class_interface_name,
|
||||||
@@ -82,7 +77,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
if wg:
|
if wg:
|
||||||
return wg.get("config", {})
|
return wg.get("config", {})
|
||||||
cfg = _get_wireguard_config()
|
cfg = _get_wireguard_config()
|
||||||
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
safe = strip_apply_meta(cfg)
|
||||||
if "interface" in safe:
|
if "interface" in safe:
|
||||||
safe["interface"] = dict(safe["interface"])
|
safe["interface"] = dict(safe["interface"])
|
||||||
safe["interface"].pop("private_key", None)
|
safe["interface"].pop("private_key", None)
|
||||||
@@ -220,7 +215,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
|||||||
logger.info("WireGuard tunnel '%s' brought up", ifname)
|
logger.info("WireGuard tunnel '%s' brought up", ifname)
|
||||||
|
|
||||||
cfg_after = _get_wireguard_config()
|
cfg_after = _get_wireguard_config()
|
||||||
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
|
stamp_applied(cfg_after)
|
||||||
_save_wireguard_config(cfg_after)
|
_save_wireguard_config(cfg_after)
|
||||||
sync_result = bus.emit(
|
sync_result = bus.emit(
|
||||||
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
|
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
|
||||||
|
|||||||
+73
-3
@@ -16,21 +16,87 @@ from typing import Any
|
|||||||
from passlib.hash import sha256_crypt
|
from passlib.hash import sha256_crypt
|
||||||
|
|
||||||
_APPLY_HASH_KEY = "_last_applied_hash"
|
_APPLY_HASH_KEY = "_last_applied_hash"
|
||||||
|
_LAST_APPLIED_CONFIG_KEY = "_last_applied_config"
|
||||||
|
_APPLY_META_KEYS = (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_apply_meta(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return *cfg* without any apply bookkeeping keys.
|
||||||
|
|
||||||
|
Strips both the last-applied hash and the last-applied config snapshot
|
||||||
|
so the returned dict reflects only real configuration.
|
||||||
|
"""
|
||||||
|
return {k: v for k, v in cfg.items() if k not in _APPLY_META_KEYS}
|
||||||
|
|
||||||
|
|
||||||
def config_hash(cfg: dict[str, Any]) -> str:
|
def config_hash(cfg: dict[str, Any]) -> str:
|
||||||
"""Compute a SHA-256 hash of *cfg* excluding the ``_last_applied_hash`` key.
|
"""Compute a SHA-256 hash of *cfg*, ignoring apply bookkeeping keys.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cfg: Config dict, possibly containing ``_last_applied_hash``.
|
cfg: Config dict, possibly containing ``_last_applied_hash`` and
|
||||||
|
``_last_applied_config``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Hex digest of the stripped config JSON.
|
Hex digest of the stripped config JSON.
|
||||||
"""
|
"""
|
||||||
clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
clean = strip_apply_meta(cfg)
|
||||||
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
|
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Record that *cfg* is the last applied configuration.
|
||||||
|
|
||||||
|
Writes both the content snapshot (``_last_applied_config``) and its hash
|
||||||
|
(``_last_applied_hash``) so a later pending check can detect drift and a
|
||||||
|
diff can report exactly which fields changed.
|
||||||
|
"""
|
||||||
|
cfg[_APPLY_HASH_KEY] = config_hash(cfg)
|
||||||
|
cfg[_LAST_APPLIED_CONFIG_KEY] = strip_apply_meta(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]:
|
||||||
|
"""Return a list of field-level changes between two configurations.
|
||||||
|
|
||||||
|
Each entry is ``{"path", "action", "old", "new"}`` where *action* is one
|
||||||
|
of ``"added"``, ``"removed"`` or ``"changed"``. Dicts are recursed with
|
||||||
|
dotted paths; lists of equal length are compared element-by-element,
|
||||||
|
while any other value that differs is reported as a single change.
|
||||||
|
Apply bookkeeping keys are ignored.
|
||||||
|
"""
|
||||||
|
if isinstance(old, dict):
|
||||||
|
old = strip_apply_meta(old)
|
||||||
|
if isinstance(new, dict):
|
||||||
|
new = strip_apply_meta(new)
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
_diff_nodes(old, new, prefix, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> None:
|
||||||
|
"""Recursively collect field-level changes from *old* into *new*."""
|
||||||
|
if isinstance(old, dict) and isinstance(new, dict):
|
||||||
|
for key in sorted(set(old) | set(new)):
|
||||||
|
child = f"{path}.{key}" if path else str(key)
|
||||||
|
if key in old and key in new:
|
||||||
|
_diff_nodes(old[key], new[key], child, out)
|
||||||
|
elif key in old:
|
||||||
|
out.append(
|
||||||
|
{"path": child, "action": "removed", "old": old[key], "new": None}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
out.append(
|
||||||
|
{"path": child, "action": "added", "old": None, "new": new[key]}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if isinstance(old, list) and isinstance(new, list) and len(old) == len(new):
|
||||||
|
for i, (o, n) in enumerate(zip(old, new, strict=True)):
|
||||||
|
_diff_nodes(o, n, f"{path}[{i}]", out)
|
||||||
|
return
|
||||||
|
if old != new:
|
||||||
|
out.append({"path": path, "action": "changed", "old": old, "new": new})
|
||||||
|
|
||||||
|
|
||||||
def validate_interface_name(name: str) -> str:
|
def validate_interface_name(name: str) -> str:
|
||||||
"""Validate a Linux network interface name.
|
"""Validate a Linux network interface name.
|
||||||
|
|
||||||
@@ -210,8 +276,10 @@ def get_interface_ip(iface: str) -> str | None:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"_APPLY_HASH_KEY",
|
"_APPLY_HASH_KEY",
|
||||||
|
"_LAST_APPLIED_CONFIG_KEY",
|
||||||
"_hash_password",
|
"_hash_password",
|
||||||
"config_hash",
|
"config_hash",
|
||||||
|
"deep_diff",
|
||||||
"deep_merge",
|
"deep_merge",
|
||||||
"ensure_dirs",
|
"ensure_dirs",
|
||||||
"get_interface_ip",
|
"get_interface_ip",
|
||||||
@@ -219,5 +287,7 @@ __all__ = [
|
|||||||
"run",
|
"run",
|
||||||
"run_proc",
|
"run_proc",
|
||||||
"save_json",
|
"save_json",
|
||||||
|
"stamp_applied",
|
||||||
|
"strip_apply_meta",
|
||||||
"validate_interface_name",
|
"validate_interface_name",
|
||||||
]
|
]
|
||||||
|
|||||||
+23
-1
@@ -22,6 +22,22 @@ CONFIG_FILE: Path = CONFIG_DIR / "config.json"
|
|||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
|
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
|
||||||
|
|
||||||
|
# Zones firewalld ships by default. They are always present live and are
|
||||||
|
# never meaningful to flag as "unmanaged (not in config)".
|
||||||
|
FIREWALLD_BUILTIN_ZONES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"block",
|
||||||
|
"dmz",
|
||||||
|
"drop",
|
||||||
|
"external",
|
||||||
|
"home",
|
||||||
|
"host",
|
||||||
|
"internal",
|
||||||
|
"public",
|
||||||
|
"trusted",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
@@ -313,6 +329,11 @@ def _compute_pending_changes(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# public zone masquerade is not reconciled by apply (it is driven by
|
||||||
|
# the nftables propagation step in daemon/handlers/firewall.py), so
|
||||||
|
# reporting it as pending here would advertise a change that never
|
||||||
|
# happens. Skip it to keep the diff consistent with apply.
|
||||||
|
if zone_name != "public":
|
||||||
cfg_mq = zone_cfg.get("masquerade", False)
|
cfg_mq = zone_cfg.get("masquerade", False)
|
||||||
live_mq = live_zone.get("masquerade", False)
|
live_mq = live_zone.get("masquerade", False)
|
||||||
if cfg_mq != live_mq:
|
if cfg_mq != live_mq:
|
||||||
@@ -356,7 +377,7 @@ def _compute_pending_changes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for zone_name in live_zones:
|
for zone_name in live_zones:
|
||||||
if zone_name not in cfg_zones:
|
if zone_name not in cfg_zones and zone_name not in FIREWALLD_BUILTIN_ZONES:
|
||||||
unknown_live[zone_name] = {
|
unknown_live[zone_name] = {
|
||||||
"interfaces": live_zones[zone_name].get("interfaces", []),
|
"interfaces": live_zones[zone_name].get("interfaces", []),
|
||||||
}
|
}
|
||||||
@@ -415,6 +436,7 @@ __all__ = [
|
|||||||
"CONFIG_FILE",
|
"CONFIG_FILE",
|
||||||
"DATA_DIR",
|
"DATA_DIR",
|
||||||
"DEFAULT_CONFIG",
|
"DEFAULT_CONFIG",
|
||||||
|
"FIREWALLD_BUILTIN_ZONES",
|
||||||
"RULES_FILE",
|
"RULES_FILE",
|
||||||
"_compute_pending_changes",
|
"_compute_pending_changes",
|
||||||
"_ensure_config_file",
|
"_ensure_config_file",
|
||||||
|
|||||||
@@ -134,6 +134,23 @@ class DnsmasqDhcpLease(TypedDict):
|
|||||||
interface: str
|
interface: str
|
||||||
|
|
||||||
|
|
||||||
|
class PendingChange(TypedDict):
|
||||||
|
"""One field-level difference between the applied config and the current
|
||||||
|
saved config (see `lib.common.deep_diff`).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
path: Dotted (or indexed) path to the changed field.
|
||||||
|
action: "added", "removed", or "changed".
|
||||||
|
old: Value in the last applied config (None when added).
|
||||||
|
new: Value in the current config (None when removed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
action: str
|
||||||
|
old: Any
|
||||||
|
new: Any
|
||||||
|
|
||||||
|
|
||||||
class DnsmasqStatus(TypedDict):
|
class DnsmasqStatus(TypedDict):
|
||||||
"""Dnsmasq service status snapshot.
|
"""Dnsmasq service status snapshot.
|
||||||
|
|
||||||
@@ -142,12 +159,15 @@ class DnsmasqStatus(TypedDict):
|
|||||||
config_file_exists: Whether the rendered .conf is on disk.
|
config_file_exists: Whether the rendered .conf is on disk.
|
||||||
active_leases: Count of currently active leases.
|
active_leases: Count of currently active leases.
|
||||||
pending_changes: Whether the config is dirty vs the applied state.
|
pending_changes: Whether the config is dirty vs the applied state.
|
||||||
|
pending_diff: Field-level changes since the last apply (empty when
|
||||||
|
up to date or when no applied snapshot is recorded).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
service_active: bool
|
service_active: bool
|
||||||
config_file_exists: bool
|
config_file_exists: bool
|
||||||
active_leases: int
|
active_leases: int
|
||||||
pending_changes: bool
|
pending_changes: bool
|
||||||
|
pending_diff: list[PendingChange]
|
||||||
|
|
||||||
|
|
||||||
class DnsmasqState(TypedDict):
|
class DnsmasqState(TypedDict):
|
||||||
@@ -316,6 +336,8 @@ class WgStatus(TypedDict):
|
|||||||
peers: Legacy single-interface runtime peers.
|
peers: Legacy single-interface runtime peers.
|
||||||
classes: Per-access-class runtime status (keyed by class name).
|
classes: Per-access-class runtime status (keyed by class name).
|
||||||
pending_changes: Whether the config is dirty vs the applied state.
|
pending_changes: Whether the config is dirty vs the applied state.
|
||||||
|
pending_diff: Field-level changes since the last apply (empty when
|
||||||
|
up to date or when no applied snapshot is recorded).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
up: bool
|
up: bool
|
||||||
@@ -323,6 +345,7 @@ class WgStatus(TypedDict):
|
|||||||
peers: list[WgStatusPeer]
|
peers: list[WgStatusPeer]
|
||||||
classes: dict[str, WgClassStatus]
|
classes: dict[str, WgClassStatus]
|
||||||
pending_changes: bool
|
pending_changes: bool
|
||||||
|
pending_diff: list[PendingChange]
|
||||||
|
|
||||||
|
|
||||||
class WgPeer(TypedDict, total=False):
|
class WgPeer(TypedDict, total=False):
|
||||||
|
|||||||
+51
-9
@@ -13,7 +13,16 @@ from pathlib import Path
|
|||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from lib import schema
|
from lib import schema
|
||||||
from lib.common import _APPLY_HASH_KEY, config_hash, load_json, run, run_proc
|
from lib.common import (
|
||||||
|
_APPLY_HASH_KEY,
|
||||||
|
_LAST_APPLIED_CONFIG_KEY,
|
||||||
|
config_hash,
|
||||||
|
deep_diff,
|
||||||
|
load_json,
|
||||||
|
run,
|
||||||
|
run_proc,
|
||||||
|
strip_apply_meta,
|
||||||
|
)
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
_parse_active_zones,
|
_parse_active_zones,
|
||||||
_parse_all_zones_output,
|
_parse_all_zones_output,
|
||||||
@@ -637,7 +646,13 @@ def _collect_dnsmasq() -> schema.DnsmasqState:
|
|||||||
cfg
|
cfg
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
safe_cfg = strip_apply_meta(cfg)
|
||||||
|
pending_diff: list[dict[str, Any]] = []
|
||||||
|
if pending_changes:
|
||||||
|
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||||
|
if isinstance(snap, dict):
|
||||||
|
pending_diff = deep_diff(snap, safe_cfg)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"config": safe_cfg,
|
"config": safe_cfg,
|
||||||
"status": {
|
"status": {
|
||||||
@@ -645,6 +660,7 @@ def _collect_dnsmasq() -> schema.DnsmasqState:
|
|||||||
"config_file_exists": conf_exists,
|
"config_file_exists": conf_exists,
|
||||||
"active_leases": len(leases),
|
"active_leases": len(leases),
|
||||||
"pending_changes": pending_changes,
|
"pending_changes": pending_changes,
|
||||||
|
"pending_diff": pending_diff,
|
||||||
},
|
},
|
||||||
"leases": leases,
|
"leases": leases,
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
@@ -732,11 +748,20 @@ def _collect_nginx() -> schema.NginxState:
|
|||||||
cfg
|
cfg
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
safe_cfg = strip_apply_meta(cfg)
|
||||||
|
nginx_pending_diff: list[dict[str, Any]] = []
|
||||||
|
if pending_changes:
|
||||||
|
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||||
|
if isinstance(snap, dict):
|
||||||
|
nginx_pending_diff = deep_diff(snap, safe_cfg)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"config": safe_cfg,
|
"config": safe_cfg,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"status": {"pending_changes": pending_changes},
|
"status": {
|
||||||
|
"pending_changes": pending_changes,
|
||||||
|
"pending_diff": nginx_pending_diff,
|
||||||
|
},
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,7 +964,7 @@ def _collect_wireguard() -> schema.WgState:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Safe config (strip private keys from interface and access classes)
|
# Safe config (strip private keys from interface and access classes)
|
||||||
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
safe = strip_apply_meta(cfg)
|
||||||
if "interface" in safe:
|
if "interface" in safe:
|
||||||
safe["interface"] = dict(safe["interface"])
|
safe["interface"] = dict(safe["interface"])
|
||||||
safe["interface"].pop("private_key", None)
|
safe["interface"].pop("private_key", None)
|
||||||
@@ -1116,6 +1141,16 @@ def _collect_wireguard() -> schema.WgState:
|
|||||||
status["up"] = True
|
status["up"] = True
|
||||||
|
|
||||||
status["pending_changes"] = pending_changes
|
status["pending_changes"] = pending_changes
|
||||||
|
pending_diff: list[dict[str, Any]] = []
|
||||||
|
if pending_changes:
|
||||||
|
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||||
|
if isinstance(snap, dict):
|
||||||
|
# `safe` has private keys stripped; drop any private-key paths so
|
||||||
|
# the pending summary never exposes key material.
|
||||||
|
pending_diff = [
|
||||||
|
d for d in deep_diff(snap, safe) if "private_key" not in d["path"]
|
||||||
|
]
|
||||||
|
status["pending_diff"] = pending_diff
|
||||||
return {
|
return {
|
||||||
"config": safe,
|
"config": safe,
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -1164,7 +1199,14 @@ def _collect_networkd() -> schema.NetworkdState:
|
|||||||
] != config_hash(net_cfg)
|
] != config_hash(net_cfg)
|
||||||
|
|
||||||
result: dict[str, dict[str, Any]] = {}
|
result: dict[str, dict[str, Any]] = {}
|
||||||
safe_net_cfg = {k: v for k, v in net_cfg.items() if k != _APPLY_HASH_KEY}
|
safe_net_cfg = strip_apply_meta(net_cfg)
|
||||||
|
net_status: dict[str, Any] = {"pending_changes": pending_changes}
|
||||||
|
net_pending_diff: list[dict[str, Any]] = []
|
||||||
|
if pending_changes:
|
||||||
|
snap = net_cfg.get(_LAST_APPLIED_CONFIG_KEY)
|
||||||
|
if isinstance(snap, dict):
|
||||||
|
net_pending_diff = deep_diff(snap, safe_net_cfg)
|
||||||
|
net_status["pending_diff"] = net_pending_diff
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
|
||||||
@@ -1173,21 +1215,21 @@ def _collect_networkd() -> schema.NetworkdState:
|
|||||||
return {
|
return {
|
||||||
"interfaces": {},
|
"interfaces": {},
|
||||||
"config": safe_net_cfg,
|
"config": safe_net_cfg,
|
||||||
"status": {"pending_changes": pending_changes},
|
"status": net_status,
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {
|
return {
|
||||||
"interfaces": {},
|
"interfaces": {},
|
||||||
"config": safe_net_cfg,
|
"config": safe_net_cfg,
|
||||||
"status": {"pending_changes": pending_changes},
|
"status": net_status,
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"interfaces": result,
|
"interfaces": result,
|
||||||
"config": safe_net_cfg,
|
"config": safe_net_cfg,
|
||||||
"status": {"pending_changes": pending_changes},
|
"status": net_status,
|
||||||
"timestamp": _now_iso(),
|
"timestamp": _now_iso(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -949,7 +949,7 @@ def import_firewall() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _cfgs_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
def _cfgs_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||||
"""Compare two configs ignoring _last_applied_hash."""
|
"""Compare two configs ignoring apply bookkeeping keys."""
|
||||||
a_clean = {k: v for k, v in a.items() if k != "_last_applied_hash"}
|
from lib.common import strip_apply_meta
|
||||||
b_clean = {k: v for k, v in b.items() if k != "_last_applied_hash"}
|
|
||||||
return a_clean == b_clean
|
return strip_apply_meta(a) == strip_apply_meta(b)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Tests for lib.common apply-metadata and diff helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from lib.common import (
|
||||||
|
_APPLY_HASH_KEY,
|
||||||
|
_LAST_APPLIED_CONFIG_KEY,
|
||||||
|
config_hash,
|
||||||
|
deep_diff,
|
||||||
|
stamp_applied,
|
||||||
|
strip_apply_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripApplyMeta:
|
||||||
|
def test_strips_both_keys(self):
|
||||||
|
cfg = {"a": 1, _APPLY_HASH_KEY: "h", _LAST_APPLIED_CONFIG_KEY: {}}
|
||||||
|
assert strip_apply_meta(cfg) == {"a": 1}
|
||||||
|
|
||||||
|
def test_missing_keys(self):
|
||||||
|
assert strip_apply_meta({"a": 1}) == {"a": 1}
|
||||||
|
|
||||||
|
def test_does_not_mutate_input(self):
|
||||||
|
cfg = {"a": 1, _APPLY_HASH_KEY: "h"}
|
||||||
|
strip_apply_meta(cfg)
|
||||||
|
assert _APPLY_HASH_KEY in cfg
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigHashIgnoresMeta:
|
||||||
|
def test_hash_unaffected_by_metadata(self):
|
||||||
|
cfg = {"a": 1}
|
||||||
|
stamped = {"a": 1, _APPLY_HASH_KEY: "x", _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
|
||||||
|
assert config_hash(cfg) == config_hash(stamped)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStampApplied:
|
||||||
|
def test_records_snapshot_and_hash(self):
|
||||||
|
cfg = {"a": 1}
|
||||||
|
stamp_applied(cfg)
|
||||||
|
assert cfg[_LAST_APPLIED_CONFIG_KEY] == {"a": 1}
|
||||||
|
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||||
|
|
||||||
|
def test_stable(self):
|
||||||
|
cfg = {"a": 1}
|
||||||
|
stamp_applied(cfg)
|
||||||
|
# A pending-style check: hash matches the current (stripped) config.
|
||||||
|
assert _APPLY_HASH_KEY in cfg and cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||||
|
# No drift → no diff.
|
||||||
|
assert (
|
||||||
|
deep_diff(cfg.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(cfg))
|
||||||
|
== []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeepDiff:
|
||||||
|
def test_identical_empty(self):
|
||||||
|
assert deep_diff({"a": 1, _APPLY_HASH_KEY: "h"}, {"a": 1}) == []
|
||||||
|
|
||||||
|
def test_changed_scalar(self):
|
||||||
|
diff = deep_diff({"a": 1}, {"a": 2})
|
||||||
|
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
|
||||||
|
|
||||||
|
def test_added_removed(self):
|
||||||
|
added = deep_diff({}, {"a": 1})
|
||||||
|
assert added[0]["action"] == "added" and added[0]["new"] == 1
|
||||||
|
removed = deep_diff({"a": 1}, {})
|
||||||
|
assert removed[0]["action"] == "removed" and removed[0]["old"] == 1
|
||||||
|
|
||||||
|
def test_nested_and_list_index(self):
|
||||||
|
old = {"z": {"svc": ["http"], "ranges": [{"ip": "10.0.0.1", "n": 1}]}}
|
||||||
|
new = {"z": {"svc": ["http", "ssh"], "ranges": [{"ip": "10.0.0.2", "n": 1}]}}
|
||||||
|
paths = {d["path"] for d in deep_diff(old, new)}
|
||||||
|
assert "z.svc" in paths
|
||||||
|
assert "z.ranges[0].ip" in paths
|
||||||
|
assert not any(p.startswith("z.ranges[0].n") for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardFallback:
|
||||||
|
def test_hash_subsystem_unchanged_generic(self):
|
||||||
|
# Guards that a pending status without a snapshot still yields a
|
||||||
|
# renderable pending flag (frontend falls back to a generic line).
|
||||||
|
status = {"pending_changes": True, "pending_diff": []}
|
||||||
|
assert status["pending_changes"] is True
|
||||||
|
assert status["pending_diff"] == []
|
||||||
+72
-3
@@ -244,18 +244,87 @@ class TestConfigPending:
|
|||||||
|
|
||||||
@patch("lib.firewall.get_config")
|
@patch("lib.firewall.get_config")
|
||||||
def test_detects_unmanaged_zones(self, mock_cfg):
|
def test_detects_unmanaged_zones(self, mock_cfg):
|
||||||
|
# A custom live zone not in config is flagged as unmanaged.
|
||||||
mock_cfg.return_value = {"zones": {}}
|
mock_cfg.return_value = {"zones": {}}
|
||||||
state = {
|
state = {
|
||||||
"zones": {
|
"zones": {
|
||||||
"public": {
|
"guest": {
|
||||||
"interfaces": ["eth0"],
|
"interfaces": ["eth5"],
|
||||||
"services": [],
|
"services": [],
|
||||||
"masquerade": False,
|
"masquerade": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result = firewall.config_pending(state)
|
result = firewall.config_pending(state)
|
||||||
assert "public" in result["unmanaged_zones"]
|
assert "guest" in result["unmanaged_zones"]
|
||||||
|
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
def test_built_in_zones_not_unmanaged(self, mock_cfg):
|
||||||
|
# firewalld built-in zones are always present and must not be
|
||||||
|
# reported as unmanaged, so they never surface as noise.
|
||||||
|
mock_cfg.return_value = {"zones": {}}
|
||||||
|
state = {
|
||||||
|
"zones": {
|
||||||
|
"public": {"interfaces": ["eth0"], "services": [], "masquerade": True},
|
||||||
|
"trusted": {"interfaces": ["lo"], "services": [], "masquerade": False},
|
||||||
|
"dmz": {"interfaces": ["eth7"], "services": [], "masquerade": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = firewall.config_pending(state)
|
||||||
|
assert result["unmanaged_zones"] == {}
|
||||||
|
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
def test_public_masquerade_not_pending(self, mock_cfg):
|
||||||
|
# public zone masquerade is driven by apply's propagation step, so a
|
||||||
|
# config-vs-live masquerade mismatch on public is not a pending change.
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state = {
|
||||||
|
"zones": {
|
||||||
|
"public": {
|
||||||
|
"interfaces": ["eth0"],
|
||||||
|
"services": ["http"],
|
||||||
|
"masquerade": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = firewall.config_pending(state)
|
||||||
|
assert not any(c["type"] == "masquerade" for c in result["pending"])
|
||||||
|
assert result["needs_apply"] is False
|
||||||
|
|
||||||
|
@patch("lib.firewall.get_config")
|
||||||
|
def test_non_public_masquerade_is_pending(self, mock_cfg):
|
||||||
|
# A non-public zone with a masquerade mismatch IS a pending change.
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"zones": {
|
||||||
|
"internal": {
|
||||||
|
"interfaces": ["eth1"],
|
||||||
|
"services": [],
|
||||||
|
"masquerade": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state = {
|
||||||
|
"zones": {
|
||||||
|
"internal": {
|
||||||
|
"interfaces": ["eth1"],
|
||||||
|
"services": [],
|
||||||
|
"masquerade": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = firewall.config_pending(state)
|
||||||
|
assert any(
|
||||||
|
c["type"] == "masquerade" and c["zone"] == "internal"
|
||||||
|
for c in result["pending"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for lib/state.py — state store and collect functions."""
|
"""Tests for lib/state.py — state store and collect functions."""
|
||||||
|
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from lib.state import State, state
|
from lib.state import State, state
|
||||||
@@ -155,6 +156,64 @@ class TestCollectAll:
|
|||||||
assert "config" in result
|
assert "config" in result
|
||||||
assert "leases" in result
|
assert "leases" in result
|
||||||
|
|
||||||
|
@patch("lib.state.run_proc")
|
||||||
|
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
|
||||||
|
from lib.state import _collect_dnsmasq
|
||||||
|
|
||||||
|
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
|
||||||
|
applied = {
|
||||||
|
"dhcp": {
|
||||||
|
"ranges": [
|
||||||
|
{
|
||||||
|
"interface": "eth1",
|
||||||
|
"start": "10.4.20.101",
|
||||||
|
"end": "10.4.20.200",
|
||||||
|
"lease_time": "1h",
|
||||||
|
"gateway": "10.4.20.1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"static_leases": [],
|
||||||
|
},
|
||||||
|
"dns": {"upstreams": ["8.8.8.8"], "domain": None, "custom_records": []},
|
||||||
|
}
|
||||||
|
cfg = {
|
||||||
|
"dhcp": {
|
||||||
|
"ranges": [
|
||||||
|
{
|
||||||
|
"interface": "eth1",
|
||||||
|
"start": "10.4.20.100",
|
||||||
|
"end": "10.4.20.200",
|
||||||
|
"lease_time": "12h",
|
||||||
|
"gateway": "10.4.20.1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"static_leases": [],
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
||||||
|
"domain": None,
|
||||||
|
"custom_records": [],
|
||||||
|
},
|
||||||
|
_LAST_APPLIED_CONFIG_KEY: applied,
|
||||||
|
_APPLY_HASH_KEY: "stale-hash",
|
||||||
|
}
|
||||||
|
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
|
||||||
|
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
|
||||||
|
# service check -> active; lease file read -> no lines
|
||||||
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
||||||
|
|
||||||
|
result = _collect_dnsmasq()
|
||||||
|
assert result["status"]["pending_changes"] is True
|
||||||
|
paths = {d["path"] for d in result["status"]["pending_diff"]}
|
||||||
|
assert "dhcp.ranges[0].start" in paths
|
||||||
|
assert "dhcp.ranges[0].lease_time" in paths
|
||||||
|
# Apply metadata must not leak into the returned config.
|
||||||
|
assert _LAST_APPLIED_CONFIG_KEY not in result["config"]
|
||||||
|
assert _APPLY_HASH_KEY not in result["config"]
|
||||||
|
|
||||||
|
|
||||||
class TestCollectFailure:
|
class TestCollectFailure:
|
||||||
def test_state_clears_on_failure(self):
|
def test_state_clears_on_failure(self):
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
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, 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
|
||||||
|
// will become on apply. Mirrors lib.firewall.fw_change_summary.
|
||||||
|
function fwChangeLine(c) {
|
||||||
|
const zone = c.zone || 'unknown';
|
||||||
|
const type = c.type || 'unknown';
|
||||||
|
const join = (arr) => (Array.isArray(arr) && arr.length ? arr.join(', ') : '∅');
|
||||||
|
switch (type) {
|
||||||
|
case 'interfaces':
|
||||||
|
return `Zone ${zone}: interfaces ${join(c.live)} → ${join(c.config)}`;
|
||||||
|
case 'services':
|
||||||
|
return `Zone ${zone}: services ${join(c.live)} → ${join(c.config)}`;
|
||||||
|
case 'rich_rules':
|
||||||
|
return `Zone ${zone}: rich rules ${c.live_count ?? 0} → ${c.config_count ?? 0}`;
|
||||||
|
case 'forward_ports':
|
||||||
|
return `Zone ${zone}: port forwards ${c.live_count ?? 0} → ${c.config_count ?? 0}`;
|
||||||
|
case 'masquerade':
|
||||||
|
return `Zone ${zone}: masquerade ${c.live} → ${c.config}`;
|
||||||
|
case 'target':
|
||||||
|
return `Zone ${zone}: target ${c.live ?? 'default'} → ${c.config ?? 'default'}`;
|
||||||
|
default:
|
||||||
|
return `Zone ${zone}: ${type} changed`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtVal(v) {
|
||||||
|
if (Array.isArray(v)) return v.length ? v.join(', ') : '∅';
|
||||||
|
if (v === null || v === undefined) return '∅';
|
||||||
|
if (typeof v === 'boolean') return v;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a single field-level pending change (applied → current).
|
||||||
|
function diffLine(d) {
|
||||||
|
const val = (x) => ` ${fmtVal(x)}`;
|
||||||
|
if (d.action === 'added') return `${d.path}: added (now${val(d.new)})`;
|
||||||
|
if (d.action === 'removed') return `${d.path}: removed (was${val(d.old)})`;
|
||||||
|
return `${d.path}: ${fmtVal(d.old)} → ${fmtVal(d.new)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
@@ -42,14 +82,34 @@ export default definePage({
|
|||||||
// Pending changes — derived from the config-backed subsystem models.
|
// Pending changes — derived from the config-backed subsystem models.
|
||||||
// Firewall uses pending.needs_apply (config_pending() output); all
|
// Firewall uses pending.needs_apply (config_pending() output); all
|
||||||
// others use status.pending_changes. `system` is metrics-only.
|
// others use status.pending_changes. `system` is metrics-only.
|
||||||
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k => {
|
//
|
||||||
const model = getModel(k === 'networkd' ? 'network' : k);
|
// Each entry is { label, lines: [string] } so the card can render a
|
||||||
const d = model.data || {};
|
// concise per-change summary. Firewall and the hash-based subsystems
|
||||||
if (k === 'firewall') return !!d.pending?.needs_apply;
|
// both carry real per-field diffs (applied → current).
|
||||||
return !!d.status?.pending_changes;
|
|
||||||
});
|
|
||||||
const totalChanges = pendKeys.length;
|
|
||||||
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
|
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
|
||||||
|
const pendingBlocks = [];
|
||||||
|
|
||||||
|
const fwPending = state.firewall.data?.pending || {};
|
||||||
|
if (fwPending.needs_apply) {
|
||||||
|
const lines = (fwPending.pending || []).map(fwChangeLine);
|
||||||
|
for (const z of Object.keys(fwPending.unmanaged_zones || {})) {
|
||||||
|
lines.push(`Zone ${z}: unmanaged (not in config)`);
|
||||||
|
}
|
||||||
|
pendingBlocks.push({ label: pendLabels.firewall, lines });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of ['dnsmasq', 'nginx', 'wireguard', 'networkd']) {
|
||||||
|
const d = getModel(k === 'networkd' ? 'network' : k).data || {};
|
||||||
|
if (d.status?.pending_changes) {
|
||||||
|
const diff = d.status.pending_diff;
|
||||||
|
const lines = (Array.isArray(diff) && diff.length)
|
||||||
|
? diff.map(diffLine)
|
||||||
|
: ['configuration saved but not applied yet'];
|
||||||
|
pendingBlocks.push({ label: pendLabels[k], lines });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalChanges = pendingBlocks.reduce((n, b) => n + b.lines.length, 0);
|
||||||
|
|
||||||
// Build merged interface list
|
// Build merged interface list
|
||||||
const allNames = [...new Set([...fwIfaces.map(f => f.name), ...Object.keys(netIfaces)])];
|
const allNames = [...new Set([...fwIfaces.map(f => f.name), ...Object.keys(netIfaces)])];
|
||||||
@@ -85,11 +145,18 @@ export default definePage({
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// ── Pending changes ──
|
// ── Pending changes ──
|
||||||
const pendingCard = pendKeys.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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
|
<ul class="pending-list">
|
||||||
|
${pendingBlocks.map(b => html`<li>
|
||||||
|
<strong>${b.label}</strong>
|
||||||
|
<ul>
|
||||||
|
${b.lines.map(line => html`<li class="text-sm">${line}</li>`)}
|
||||||
|
</ul>
|
||||||
|
</li>`)}
|
||||||
|
</ul>
|
||||||
<${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" />
|
||||||
|
|||||||
@@ -702,6 +702,36 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pending changes summary list */
|
||||||
|
.pending-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-list > li {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-list > li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-list > li > strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-list > li > ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-list li li {
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Service status inline badge */
|
/* Service status inline badge */
|
||||||
.service-status {
|
.service-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user