diff --git a/AGENTS.md b/AGENTS.md index 67f3a25..7191382 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ through the daemon client over a Unix socket. - `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `validate_interface_name()`. All `lib/` modules use these instead of defining local helpers. - `lib/logging.py` — Logging setup used by both webui and daemon. - `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls. -- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htmx`, `json-enc`). +- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`). - `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments). - `config//config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`. - `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`. diff --git a/README.md b/README.md index 90d9026..ebb7024 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ See [docs/deployment.md](docs/deployment.md) for the full guide, including troub ```bash git clone && cd vacuum-wall +bash scripts/update-vendor.sh python3 -m venv .venv && . .venv/bin/activate pip install -e ".[dev]" ``` diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py index 1fe7c1f..bf818e3 100644 --- a/daemon/handlers/dnsmasq.py +++ b/daemon/handlers/dnsmasq.py @@ -1,6 +1,5 @@ """Dnsmasq daemon handler.""" -import hashlib import logging from copy import deepcopy from datetime import UTC, datetime @@ -26,7 +25,15 @@ from daemon.iface import ( POST_DNSMASQ_UPSTREAMS, ) from daemon.server import NotFoundError, refresh_state, registry -from lib.common import deep_merge, ensure_dirs, load_json, run, save_json +from lib.common import ( + _APPLY_HASH_KEY, + config_hash, + deep_merge, + ensure_dirs, + load_json, + run, + save_json, +) from lib.sync import SyncEvent, bus logger = logging.getLogger(__name__) @@ -51,20 +58,6 @@ DEFAULT_CFG: dict[str, Any] = { "dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []}, } -# Internal field for tracking applied config version -_APPLY_HASH_KEY = "_last_applied_hash" - - -def _config_hash(cfg: dict[str, Any]) -> str: - """Compute a hash of the config, excluding the _last_applied_hash field. - - Used to detect whether the JSON config has changed since the last apply. - """ - import json - - clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} - return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest() - def _get_state() -> dict[str, Any] | None: """Retrieve cached dnsmasq state from the state store.""" @@ -144,7 +137,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]: dm = _get_dnsmasq_state() if dm: return dm.get("config", {}) - return _get_config() + cfg = _get_config() + return {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} @registry.register(POST_DNSMASQ_CONFIG) @@ -199,11 +193,11 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]: f.write(conf_text) run(["cp", str(tmp), DNSMASQ_CONF], sudo=True) tmp.unlink(missing_ok=True) - run(["systemctl", "reload", "dnsmasq"], sudo=True) - logger.info("dnsmasq config written and reloaded") + run(["systemctl", "restart", "dnsmasq"], sudo=True) + logger.info("dnsmasq config written and restarted") # Store the config hash so state collector can detect pending changes cfg_after = _get_config() - cfg_after[_APPLY_HASH_KEY] = _config_hash(cfg_after) + cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) _save_config(cfg_after) sync_result = bus.emit( SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"}) diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index d16c940..e7681ba 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -39,6 +39,7 @@ from lib.firewall import ( _normalize_target, _parse_active_zones, _parse_zone_output, + fw_change_summary, ) from lib.firewall import ( save_backup as _save_backup, @@ -378,7 +379,15 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: @registry.register(GET_FIREWALL_CONFIG_PENDING) def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]: fw = _get_fw_state() - return fw.get("pending", {}) + pending = fw.get("pending", {}) + + changes = pending.get("pending", []) + + summaries = [ + fw_change_summary(c.get("zone", "unknown"), c.get("type", "unknown"), c) + for c in changes + ] + return {**pending, "pending_summary": summaries} @registry.register(POST_FIREWALL_CONFIG_APPLY) diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py index 2bc871f..85bf6cc 100644 --- a/daemon/handlers/network.py +++ b/daemon/handlers/network.py @@ -21,7 +21,9 @@ from daemon.iface import ( POST_NETWORK_SYSCTL_SET, ) from daemon.server import NotFoundError, refresh_state, registry -from lib.common import run, validate_interface_name +from lib.common import _APPLY_HASH_KEY, config_hash, run, validate_interface_name +from lib.dnsmasq import get_config as _get_dm_cfg +from lib.dnsmasq import save_config as _save_dm_cfg from lib.dnsmasq import set_upstreams from lib.network import ( KNOWN_INTERFACE_KEYS, @@ -195,6 +197,10 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] ) logger.info("Interface '%s' config saved (applied=%s)", name, deployed) + if deployed: + cfg_after = get_config() + cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) + save_config(cfg_after) sync_result = bus.emit( SyncEvent( "network", "config_saved", {"action": "interface_saved", "interface": name} @@ -258,10 +264,17 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]: upstreams = collect_upstream_dns(cfg) if upstreams: set_upstreams(upstreams) + # Update dnsmasq apply hash so pending-changes detection stays correct + dm_cfg = _get_dm_cfg() + dm_cfg[_APPLY_HASH_KEY] = config_hash(dm_cfg) + _save_dm_cfg(dm_cfg) logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams)) except Exception: logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True) + cfg_after = get_config() + cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) + save_config(cfg_after) sync_result = bus.emit( SyncEvent("network", "config_saved", {"action": "config_applied"}) ) diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 538233b..8f6166f 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -23,7 +23,15 @@ from daemon.iface import ( ) from daemon.server import NotFoundError, refresh_state, registry from lib.acme import find_cert_dir -from lib.common import ensure_dirs, load_json, run, run_proc, save_json +from lib.common import ( + _APPLY_HASH_KEY, + config_hash, + ensure_dirs, + load_json, + run, + run_proc, + save_json, +) logger = logging.getLogger(__name__) @@ -300,7 +308,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]: ng = _get_nginx_state() if ng: return ng.get("config", {}) - return _get_config() + cfg = _get_config() + return {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} @registry.register(POST_NGINX_CONFIG) @@ -507,6 +516,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]: if not ok: raise RuntimeError(f"nginx config test failed: {msg}") _reload_nginx() + cfg_after = _get_config() + cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) + _save_config(cfg_after) refresh_state(["nginx"]) return {"applied": True} diff --git a/daemon/handlers/status.py b/daemon/handlers/status.py new file mode 100644 index 0000000..6a147a0 --- /dev/null +++ b/daemon/handlers/status.py @@ -0,0 +1,146 @@ +"""Aggregate status handler. + +Exposes pending changes across all subsystems and a single apply-all +endpoint that invokes each subsystem's apply in the correct order. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from daemon.handlers.dnsmasq import apply_config as dnsmasq_apply_config +from daemon.handlers.firewall import config_apply as firewall_config_apply +from daemon.handlers.network import apply_all as network_apply_all +from daemon.handlers.nginx import apply as nginx_apply +from daemon.handlers.wireguard import apply as wireguard_apply +from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL +from daemon.server import refresh_state, registry +from lib.firewall import fw_change_summary +from lib.state import state as state_store + +logger = logging.getLogger(__name__) + +SYS_ORDER = ["networkd", "firewall", "wireguard", "dnsmasq", "nginx"] +SYS_LABELS = { + "networkd": "Network", + "firewall": "Firewall", + "wireguard": "WireGuard", + "dnsmasq": "DHCP/DNS", + "nginx": "Nginx", +} +SYS_APPLY = { + "networkd": network_apply_all, + "firewall": firewall_config_apply, + "wireguard": wireguard_apply, + "dnsmasq": dnsmasq_apply_config, + "nginx": nginx_apply, +} + + +@registry.register(GET_STATUS_PENDING) +def status_pending(_request: Any, _body: Any) -> dict[str, Any]: + """Aggregate pending changes across all subsystems. + + Returns: + Dict with per-subsystem pending status and total change count. + """ + fw = state_store.get("firewall") or {} + pending_fw = fw.get("pending", {}) + fw_needs_apply = pending_fw.get("needs_apply", False) + fw_pending_list = pending_fw.get("pending", []) + + fw_changes = [] + for c in fw_pending_list: + zone = c.get("zone", "unknown") + ctype = c.get("type", "unknown") + summary = fw_change_summary(zone, ctype, c) + fw_changes.append({"summary": summary, "detail": ""}) + + fw_result = { + "needs_apply": fw_needs_apply, + "change_count": len(fw_changes), + "changes": fw_changes, + } + + hash_subsystems = { + "dnsmasq": _hash_subsystem("dnsmasq", state_store.get("dnsmasq")), + "nginx": _hash_subsystem("nginx", state_store.get("nginx")), + "wireguard": _hash_subsystem("wireguard", state_store.get("wireguard")), + "networkd": _hash_subsystem("networkd", state_store.get("networkd")), + } + + total = len(fw_changes) + for _name, result in hash_subsystems.items(): + total += len(result["changes"]) + + return { + "firewall": fw_result, + "dnsmasq": hash_subsystems["dnsmasq"], + "nginx": hash_subsystems["nginx"], + "wireguard": hash_subsystems["wireguard"], + "networkd": hash_subsystems["networkd"], + "total_changes": total, + } + + +@registry.register(POST_STATUS_APPLY_ALL) +def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]: + """Apply pending changes for all subsystems in dependency order. + + Order: network -> firewall -> wireguard -> dnsmasq -> nginx. + + Returns: + Dict with applied subsystems and any errors encountered. + """ + applied = [] + errors = {} + + 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"], + } + + for name in SYS_ORDER: + if name == "firewall": + if not fw_pending: + continue + else: + if not hash_pending.get(name, False): + continue + + handler = SYS_APPLY[name] + try: + handler(None, None) + applied.append(name) + except Exception as exc: + label = SYS_LABELS.get(name, name) + errors[label] = str(exc) + logger.error("Apply-all failed for %s: %s", name, exc) + + refresh_state(SYS_ORDER) + return {"applied": applied, "errors": errors} + + +def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]: + """Build pending result for a hash-based subsystem.""" + if state is None: + return {"pending_changes": False, "summary": "Up to date", "changes": []} + + status = state.get("status", {}) + pending = status.get("pending_changes", False) + label = SYS_LABELS.get(name, name) + + if pending: + summary = f"{label} configuration has unapplied changes" + return { + "pending_changes": True, + "summary": summary, + "changes": [{"summary": summary, "detail": ""}], + } + + return {"pending_changes": False, "summary": "Up to date", "changes": []} diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py index 1a69bac..771840f 100644 --- a/daemon/handlers/wireguard.py +++ b/daemon/handlers/wireguard.py @@ -24,7 +24,15 @@ from daemon.iface import ( POST_WIREGUARD_PEERS_ADD, ) from daemon.server import NotFoundError, refresh_state, registry -from lib.common import deep_merge, load_json, run, run_proc, save_json +from lib.common import ( + _APPLY_HASH_KEY, + config_hash, + deep_merge, + load_json, + run, + run_proc, + save_json, +) from lib.sync import SyncEvent, bus logger = logging.getLogger(__name__) @@ -102,7 +110,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]: if wg: return wg.get("config", {}) cfg = _get_config() - safe = dict(cfg) + safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} if "interface" in safe: safe["interface"] = dict(safe["interface"]) safe["interface"].pop("private_key", None) @@ -174,6 +182,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]: local_tmp.unlink(missing_ok=True) run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True) logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) + cfg_after = _get_config() + cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) + _save_config(cfg_after) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "config_applied"}) ) diff --git a/daemon/iface.py b/daemon/iface.py index 5bd978c..c96b808 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -146,6 +146,8 @@ GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all") POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh") GET_WS: Endpoint = _ep("GET", "/ws") POST_BATCH: Endpoint = _ep("POST", "/batch") +GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending") +POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all") # Collect all endpoint module-level constants for __all__ verification _all_endpoints = [ diff --git a/daemon/server.py b/daemon/server.py index 47f8b60..169bb87 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -506,6 +506,7 @@ def _register_routes() -> None: logs, # noqa: F401 network, # noqa: F401 nginx, # noqa: F401 + status, # noqa: F401 wireguard, # noqa: F401 ) @@ -538,7 +539,7 @@ def main() -> None: _stop_polling() try: await asyncio.wait_for(runner.cleanup(), timeout=5) - except asyncio.TimeoutError: + except TimeoutError: logger.warning("Runner cleanup timed out, abandoning") if Path(socket_path).exists(): os.unlink(socket_path) diff --git a/docs/architecture.md b/docs/architecture.md index 044d1f8..2a4c36a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0 Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server) vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload -vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq +vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ACME provider vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0 vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload diff --git a/docs/overview.md b/docs/overview.md index 8329d36..00e03ee 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -136,7 +136,7 @@ After installation, access the management interface at `https://.local │ ├── config.md │ └── hoover.md # Hoover SPA framework └── scripts/ # Utility scripts - └── update-vendor.sh # Download acme.sh binary + └── update-vendor.sh # Download vendored libraries (acme.sh, htm) ``` ## Documentation diff --git a/docs/security.md b/docs/security.md index 1d209d7..a427f3f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -29,10 +29,9 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p | Nginx file ops | `cp -- * /etc/nginx/snippets/*` | Copy rendered config files to system paths | | Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files | | Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files | -| Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration | +| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration | | Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status | | Networkd | `systemctl is-active dnsmasq` | Check dnsmasq service status | -| Dnsmasq file ops | `tee /etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration | | Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists | | Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/*` | Copy rendered config files | | Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table | diff --git a/install.sh b/install.sh index 2af0c79..bb36575 100755 --- a/install.sh +++ b/install.sh @@ -182,6 +182,10 @@ apt-get install -y -qq \ apache2-utils \ avahi-daemon +# --- 1b. Vendored libraries --- +log "Downloading vendored libraries..." +bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed" + # --- 2. Setup users --- log "WebUI user: $USER_NAME (group: $USER_GROUP)" diff --git a/lib/common.py b/lib/common.py index b94433e..5425be3 100644 --- a/lib/common.py +++ b/lib/common.py @@ -4,6 +4,7 @@ Provides common helpers for JSON persistence, subprocess execution, deep merging, and directory creation used across all subsystem modules. """ +import hashlib import json import os import re @@ -12,6 +13,21 @@ from copy import deepcopy from pathlib import Path from typing import Any +_APPLY_HASH_KEY = "_last_applied_hash" + + +def config_hash(cfg: dict[str, Any]) -> str: + """Compute a SHA-256 hash of *cfg* excluding the ``_last_applied_hash`` key. + + Args: + cfg: Config dict, possibly containing ``_last_applied_hash``. + + Returns: + Hex digest of the stripped config JSON. + """ + clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} + return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest() + def validate_interface_name(name: str) -> str: """Validate a Linux network interface name. @@ -154,6 +170,8 @@ def ensure_dirs(*dirs: Path) -> None: __all__ = [ + "_APPLY_HASH_KEY", + "config_hash", "deep_merge", "ensure_dirs", "load_json", diff --git a/lib/dnsmasq.py b/lib/dnsmasq.py index 7556eac..1712b95 100644 --- a/lib/dnsmasq.py +++ b/lib/dnsmasq.py @@ -1,18 +1,15 @@ -"""Dnsmasq configuration manager for Vacuum Wall SSL proxy firewall. +"""Dnsmasq config persistence for Vacuum Wall. -Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range, -static leases, and custom DNS records through sudo. +Provides load/save for the declarative JSON config and upstream-management +helpers used by the sync bus and network handler. All mutation and +apply logic lives in daemon/handlers/dnsmasq.py. """ import logging -import subprocess from copy import deepcopy -from datetime import UTC, datetime from pathlib import Path from typing import Any -from jinja2 import Environment, FileSystemLoader - from lib.common import deep_merge, ensure_dirs, load_json, save_json logger = logging.getLogger(__name__) @@ -22,17 +19,7 @@ CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq" DATA_DIR = PROJECT_DIR / "data" / "dnsmasq" CONFIG_PATH = CONFIG_DIR / "config.json" FRAGMENTS_DIR = DATA_DIR / "fragments" -DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" -LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases" -ENV = Environment( - loader=FileSystemLoader(str(PROJECT_DIR / "system")), - autoescape=False, - lstrip_blocks=True, - trim_blocks=True, -) - -# --- defaults --- DEFAULT_CFG: dict[str, Any] = { "dhcp": { "ranges": [], @@ -66,244 +53,7 @@ def save_config(cfg: dict[str, Any]) -> None: logger.info("dnsmasq config saved") -def apply_config() -> None: - """Write generated config to disk via sudo tee, then reload dnsmasq.""" - cfg = get_config() - conf_text = generate_conf(cfg) - - ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR) - subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True) - subprocess.run( - ["sudo", "tee", DNSMASQ_CONF, "--"], - input=conf_text, - capture_output=True, - text=True, - check=True, - ) - subprocess.run( - ["sudo", "systemctl", "reload", "dnsmasq"], - capture_output=True, - text=True, - check=True, - ) - logger.info("dnsmasq config written and reloaded") - - -# ───────── config generation ───────────────────────────────────────── - - -def generate_conf(cfg: dict[str, Any]) -> str: - """Render a complete dnsmasq.conf text block from the config dict.""" - dhcp_cfg = cfg.get("dhcp", {}) - dns_cfg = cfg.get("dns", {}) - - interfaces = [ - r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r - ] - - # Fallback: use network-managed interface addresses for listen-address - listen_addresses = [] - try: - from lib.network import get_config as _get_net_config - - net_cfg = _get_net_config() - for _iface, info in net_cfg.get("interfaces", {}).items(): - for addr_str in info.get("addresses", []): - if "/" in addr_str: - addr_str = addr_str.split("/")[0] - listen_addresses.append(addr_str) - except Exception: - pass - - tmpl = ENV.get_template("dnsmasq.conf") - return tmpl.render( - timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - interfaces=interfaces or None, - listen_addresses=listen_addresses if listen_addresses else None, - dhcp=dhcp_cfg, - dns=dns_cfg, - fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None, - ) - - -# ───────── dhcp management ─────────────────────────────────────────── - - -def set_dhcp_range( - iface: str, - start: str, - end: str, - lease_time: str = "12h", - gateway: str | None = None, - dns: str | None = None, -) -> None: - """Add or replace the DHCP range for a given interface.""" - cfg = get_config() - ranges = cfg["dhcp"]["ranges"] - - found = False - for i, r in enumerate(ranges): - if r.get("interface") == iface: - ranges[i] = { - "interface": iface, - "start": start, - "end": end, - "lease_time": lease_time, - } - if gateway: - ranges[i]["gateway"] = gateway - if dns: - ranges[i]["dns"] = dns - found = True - break - - if not found: - entry: dict[str, Any] = { - "interface": iface, - "start": start, - "end": end, - "lease_time": lease_time, - } - if gateway: - entry["gateway"] = gateway - if dns: - entry["dns"] = dns - ranges.append(entry) - - save_config(cfg) - logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end) - - -def remove_dhcp_range(iface: str, start: str, end: str) -> None: - """Remove a DHCP range by interface + IP range.""" - cfg = get_config() - cfg["dhcp"]["ranges"] = [ - r - for r in cfg["dhcp"]["ranges"] - if not ( - r.get("interface") == iface - and r.get("start") == start - and r.get("end") == end - ) - ] - save_config(cfg) - logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end) - - -def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None: - """Add (or update) a static DHCP lease by MAC address.""" - cfg = get_config() - leases = cfg["dhcp"]["static_leases"] - - for i, lease in enumerate(leases): - if lease["mac"].lower() == mac.lower(): - leases[i].update({"mac": mac, "ip": ip}) - if hostname is not None: - leases[i]["hostname"] = hostname - save_config(cfg) - logger.info("Static DHCP lease updated: %s -> %s", mac, ip) - return - - entry: dict[str, Any] = {"mac": mac, "ip": ip} - if hostname: - entry["hostname"] = hostname - leases.append(entry) - save_config(cfg) - logger.info("Static DHCP lease added: %s -> %s", mac, ip) - - -def remove_static_lease(mac: str) -> None: - """Remove a static DHCP lease by MAC address.""" - cfg = get_config() - cfg["dhcp"]["static_leases"] = [ - lease - for lease in cfg["dhcp"]["static_leases"] - if lease["mac"].lower() != mac.lower() - ] - save_config(cfg) - logger.info("Static DHCP lease removed for MAC %s", mac) - - -# ───────── dns record management ───────────────────────────────────── - - -def add_dns_record(name: str, address: str, hostname: str | None = None) -> None: - """Add or update a custom DNS A record.""" - cfg = get_config() - records = cfg["dns"]["custom_records"] - - for i, r in enumerate(records): - if r["name"] == name: - records[i].update({"name": name, "address": address}) - if hostname is not None: - records[i]["hostname"] = hostname - save_config(cfg) - logger.info("DNS record updated: %s -> %s", name, address) - return - - entry: dict[str, Any] = {"name": name, "address": address} - if hostname: - entry["hostname"] = hostname - records.append(entry) - save_config(cfg) - logger.info("DNS record added: %s -> %s", name, address) - - -def remove_dns_record(name: str) -> None: - """Remove a custom DNS record by name.""" - cfg = get_config() - cfg["dns"]["custom_records"] = [ - r for r in cfg["dns"]["custom_records"] if r["name"] != name - ] - save_config(cfg) - logger.info("DNS record removed: %s", name) - - -# ───────── lease table ─────────────────────────────────────────────── - - -def _parse_lease_line(line: str) -> dict[str, Any] | None: - """Parse one line from dnsmasq.leases into a dict.""" - line = line.strip() - if not line or line.startswith("#"): - return None - parts = line.split() - if len(parts) < 3: - return None - - try: - ts = datetime.fromtimestamp(int(parts[0]), tz=UTC) - except (ValueError, OSError): - ts = None - - return { - "expires_at": ts, - "mac": parts[1], - "ip": parts[2], - "hostname": parts[3] if len(parts) > 3 else "", - "interface": parts[4] if len(parts) > 4 else "", - } - - -def get_lease_table() -> list[dict[str, Any]]: - """Read and parse the current dnsmasq lease file.""" - leases: list[dict[str, Any]] = [] - try: - result = subprocess.run( - ["sudo", "cat", LEASE_FILE], - capture_output=True, - text=True, - check=True, - ) - for entry in map(_parse_lease_line, result.stdout.splitlines()): - if entry is not None: - leases.append(entry) - except subprocess.CalledProcessError: - pass - return leases - - -# ───────── upstream / domain helpers ───────────────────────────────── +# ───────── upstream helpers ────────────────────────────────────────── def set_upstreams(servers: list[str]) -> None: @@ -322,64 +72,9 @@ def set_domain(domain: str | None) -> None: logger.info("DNS domain set to '%s'", domain) -# ───────── status / info ───────────────────────────────────────────── - - -def get_status() -> dict[str, Any]: - """Return service status, config summary, and current lease count.""" - cfg = get_config() - - try: - proc = subprocess.run( - ["sudo", "systemctl", "is-active", "dnsmasq"], - capture_output=True, - text=True, - ) - active = proc.stdout.strip() == "active" - except Exception: - active = False - - conf_exists = Path(DNSMASQ_CONF).is_file() - if conf_exists: - try: - with open(DNSMASQ_CONF) as f: - conf_on_disk = f.read() - except PermissionError: - conf_on_disk = "" - else: - conf_on_disk = "" - - expected = generate_conf(cfg) - - leases = get_lease_table() - - return { - "service_active": active, - "config_file_exists": conf_exists, - "config_in_sync": conf_on_disk == expected, - "dhcp_ranges": len(cfg["dhcp"]["ranges"]), - "static_leases": len(cfg["dhcp"]["static_leases"]), - "custom_dns_records": len(cfg["dns"]["custom_records"]), - "upstreams": cfg["dns"]["upstreams"], - "domain": cfg["dns"].get("domain"), - "active_leases": len(leases), - "leases": leases, - } - - __all__ = [ - "add_dns_record", - "add_static_lease", - "apply_config", - "generate_conf", "get_config", - "get_lease_table", - "get_status", - "remove_dhcp_range", - "remove_dns_record", - "remove_static_lease", "save_config", - "set_dhcp_range", "set_domain", "set_upstreams", ] diff --git a/lib/firewall.py b/lib/firewall.py index e57b15a..d9c5b72 100644 --- a/lib/firewall.py +++ b/lib/firewall.py @@ -379,6 +379,37 @@ def config_pending(state: dict[str, Any]) -> dict[str, Any]: return _compute_pending_changes(cfg, live_zones) +def fw_change_summary(zone: str, ctype: str, change: dict[str, Any]) -> str: + """Build a human-readable summary string for a firewall change.""" + if ctype == "interfaces": + config_if = change.get("config", []) + live_if = change.get("live", []) + return f"Zone {zone}: interfaces changed (config: {config_if}, live: {live_if})" + if ctype == "services": + config_sv = change.get("config", []) + live_sv = change.get("live", []) + return f"Zone {zone}: services changed (config: {config_sv}, live: {live_sv})" + if ctype == "rich_rules": + cfg_count = change.get("config_count", 0) + live_count = change.get("live_count", 0) + return ( + f"Zone {zone}: rich rules differ (config: {cfg_count}, live: {live_count})" + ) + if ctype == "forward_ports": + cfg_count = change.get("config_count", 0) + live_count = change.get("live_count", 0) + return f"Zone {zone}: port forwards differ (config: {cfg_count}, live: {live_count})" + if ctype == "masquerade": + cfg_val = change.get("config", False) + live_val = change.get("live", False) + return f"Zone {zone}: masquerade changed (config: {cfg_val}, live: {live_val})" + if ctype == "target": + cfg_val = change.get("config", "default") + live_val = change.get("live", "default") + return f"Zone {zone}: target changed (config: {cfg_val}, live: {live_val})" + return f"Zone {zone}: {ctype} changed" + + __all__ = [ "CONFIG_DIR", "CONFIG_FILE", @@ -396,6 +427,7 @@ __all__ = [ "_parse_interfaces", "_parse_zone_output", "config_pending", + "fw_change_summary", "get_config", "load_backup", "save_backup", diff --git a/lib/state.py b/lib/state.py index cd40cc5..e67fcf3 100644 --- a/lib/state.py +++ b/lib/state.py @@ -5,8 +5,6 @@ state instead of invoking subprocesses on every request. """ import contextlib -import hashlib -import json import logging import os from copy import deepcopy @@ -14,7 +12,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any, ClassVar -from lib.common import load_json, run, run_proc +from lib.common import _APPLY_HASH_KEY, config_hash, load_json, run, run_proc from lib.firewall import ( _parse_active_zones, _parse_all_zones_output, @@ -590,18 +588,13 @@ def _collect_dnsmasq() -> dict[str, Any]: # Check config file on disk conf_exists = Path(DNSMASQ_CONF).is_file() - # Check if JSON config has changed since last apply - _APPLY_HASH_KEY = "_last_applied_hash" - pending_changes = True - if _APPLY_HASH_KEY in cfg: - clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} - current_hash = hashlib.sha256( - json.dumps(clean, sort_keys=True).encode() - ).hexdigest() - pending_changes = cfg[_APPLY_HASH_KEY] != current_hash + pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash( + cfg + ) + safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} return { - "config": cfg, + "config": safe_cfg, "status": { "service_active": service_active, "config_file_exists": conf_exists, @@ -684,9 +677,15 @@ def _collect_nginx() -> dict[str, Any]: entry["is_websocket"] = True domains.append(entry) + pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash( + cfg + ) + + safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} return { - "config": cfg, + "config": safe_cfg, "domains": domains, + "status": {"pending_changes": pending_changes}, "timestamp": _now_iso(), } @@ -918,8 +917,12 @@ def _collect_wireguard() -> dict[str, Any]: except Exception: pass - # Safe config (strip private key) - safe = dict(cfg) + pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash( + cfg + ) + + # Safe config (strip private key and internal hash) + safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} if "interface" in safe: safe["interface"] = dict(safe["interface"]) safe["interface"].pop("private_key", None) @@ -1000,6 +1003,7 @@ def _collect_wireguard() -> dict[str, Any]: except Exception: pass + status["pending_changes"] = pending_changes return { "config": safe, "status": status, @@ -1029,24 +1033,46 @@ def _collect_networkd() -> dict[str, Any]: """Collect networkd interface state from networkctl. Returns: - Dict with interface runtime state parsed from networkctl output. - Returns empty data if networkctl is not available. + Dict with interface runtime state parsed from networkctl output, + config, and pending changes status. """ + CONFIG_PATH = PROJECT_DIR / "config" / "network" / "config.json" + + # Load config + net_cfg: dict[str, Any] = {} + if CONFIG_PATH.exists(): + with contextlib.suppress(Exception): + net_cfg = load_json(CONFIG_PATH) + + pending_changes = _APPLY_HASH_KEY not in net_cfg or net_cfg[ + _APPLY_HASH_KEY + ] != config_hash(net_cfg) + result: dict[str, dict[str, Any]] = {} + safe_net_cfg = {k: v for k, v in net_cfg.items() if k != _APPLY_HASH_KEY} try: raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True) result = parse_networkctl_status(raw) if not result: - return {"interfaces": {}, "timestamp": _now_iso()} + return { + "interfaces": {}, + "config": safe_net_cfg, + "status": {"pending_changes": pending_changes}, + "timestamp": _now_iso(), + } except Exception: return { "interfaces": {}, + "config": safe_net_cfg, + "status": {"pending_changes": pending_changes}, "timestamp": _now_iso(), } return { "interfaces": result, + "config": safe_net_cfg, + "status": {"pending_changes": pending_changes}, "timestamp": _now_iso(), } diff --git a/scripts/update-vendor.sh b/scripts/update-vendor.sh index 8ee89c9..acfc76a 100755 --- a/scripts/update-vendor.sh +++ b/scripts/update-vendor.sh @@ -5,6 +5,7 @@ set -euo pipefail # ---- Library versions ---- ACME_VERSION="3.1.3" +HTM_VERSION="3.1.1" VENDOR="vendor" @@ -22,6 +23,32 @@ download "acme.sh@${ACME_VERSION}" \ "https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \ "${VENDOR}/acme.sh" +download "htm@${HTM_VERSION}" \ + "https://raw.githubusercontent.com/developit/htm/${HTM_VERSION}/mini/index.module.js" \ + "${VENDOR}/htm.js" + chmod +x "${VENDOR}/acme.sh" +# --- Symlinks for webui --- +WEBUI_VENDOR="webui/static/vendor" +mkdir -p "$WEBUI_VENDOR" + +WEBUI_LINKS=( + "htm.js:../../../vendor/htm.js" +) + +for entry in "${WEBUI_LINKS[@]}"; do + IFS=':' read -r name target <<< "$entry" + if [[ -L "${WEBUI_VENDOR}/${name}" ]]; then + cur=$(readlink "${WEBUI_VENDOR}/${name}") + if [[ "$cur" != "$target" ]]; then + echo "[symlink] ${WEBUI_VENDOR}/${name} → ${target} (updated)" + ln -sf "$target" "${WEBUI_VENDOR}/${name}" + fi + else + echo "[symlink] ${WEBUI_VENDOR}/${name} → ${target}" + ln -sf "$target" "${WEBUI_VENDOR}/${name}" + fi +done + echo "[done] All libraries vendored." diff --git a/system/sudoers.d/vacuum-walld b/system/sudoers.d/vacuum-walld index 2c10a76..47941a2 100644 --- a/system/sudoers.d/vacuum-walld +++ b/system/sudoers.d/vacuum-walld @@ -18,10 +18,9 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf # Dnsmasq management -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl restart dnsmasq {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/* diff --git a/tests/test-applyconfirm.js b/tests/test-applyconfirm.js new file mode 100644 index 0000000..d3922d4 --- /dev/null +++ b/tests/test-applyconfirm.js @@ -0,0 +1,239 @@ +/** + * Tests for hoover/components/applyconfirm.js + * + * Component-level tests: VNode structure, buildRows logic, + * and integration behaviour. Run with `node tests/test-applyconfirm.js`. + */ + +import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js'; + +const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key); + +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 ApplyConfirm component\n'); + +// === isPending === +test('isPending returns true for needs_apply', () => { + assertEq(isPending({ needs_apply: true }), true); +}); + +test('isPending returns true for pending_changes', () => { + assertEq(isPending({ pending_changes: true }), true); +}); + +test('isPending returns false when neither flag set', () => { + assertEq(isPending({}), false); +}); + +test('isPending returns false for explicit false', () => { + assertEq(isPending({ needs_apply: false, pending_changes: false }), false); +}); + +// === SUBSYSTEM_LIST === +test('SUBSYSTEM_LIST contains 5 subsystems', () => { + assertEq(SUBSYSTEM_LIST.length, 5); +}); + +test('SUBSYSTEM_LIST uses networkd key (not network)', () => { + assertIncludes(SUBSYSTEM_KEYS.join(','), 'networkd', 'SUBSYSTEM_LIST should contain networkd'); + assert(SUBSYSTEM_KEYS.indexOf('network') === -1, 'SUBSYSTEM_LIST should NOT contain network'); +}); + +test('SUBSYSTEM_LIST keys match daemon response keys', () => { + const expectedKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd']; + for (const key of expectedKeys) { + assert(SUBSYSTEM_KEYS.includes(key), `SUBSYSTEM_LIST should contain ${key}`); + } +}); + +// === buildRows === +test('buildRows returns 5 rows for empty subsystems', () => { + const rows = buildRows({}, {}); + assertEq(rows.length, 5, 'should have 5 subsystem rows for empty state'); +}); + +test('buildRows marks pending firewall subsystem correctly', () => { + const data = { + firewall: { + needs_apply: true, + changes: [ + { summary: 'Zone internal: interfaces changed', detail: '' }, + ], + }, + }; + const rows = buildRows(data, {}); + const fwRow = rows[0]; + assertIncludes(fwRow.props.class, 'pending', 'firewall row should have pending class'); +}); + +test('buildRows changes are VNodes with proper structure', () => { + const data = { + firewall: { + needs_apply: true, + changes: [ + { summary: 'Zone internal: interfaces changed', detail: '' }, + ], + }, + }; + const rows = buildRows(data, {}); + const fwRow = rows[0]; + // Status span should contain "1 pending changes" + const statusSpan = fwRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status')); + assert(statusSpan, 'should have status span'); + const textChild = statusSpan.ch.find(c => c.tag === '#text'); + assert(textChild && textChild.text.includes('pending changes'), 'status should contain pending changes count'); +}); + +test('buildRows marks pending dnsmasq subsystem correctly', () => { + const data = { + dnsmasq: { + pending_changes: true, + changes: [ + { summary: 'DHCP/DNS configuration has unapplied changes', detail: '' }, + ], + }, + }; + const rows = buildRows(data, {}); + const dnsmasqRow = rows[1]; + assertIncludes(dnsmasqRow.props.class, 'pending', 'dnsmasq row should have pending class'); +}); + +test('buildRows shows correct change count text', () => { + const data = { + dnsmasq: { + pending_changes: true, + changes: [ + { summary: 'Range 1', detail: '' }, + { summary: 'Range 2', detail: '' }, + ], + }, + }; + const rows = buildRows(data, {}); + const dnsmasqRow = rows[1]; + const statusSpan = dnsmasqRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status')); + const textChild = statusSpan.ch.find(c => c.tag === '#text'); + assertEq(textChild.text, '2 pending changes'); +}); + +test('buildRows shows up-to-date for non-pending', () => { + const data = { + nginx: { pending_changes: false, changes: [] }, + wireguard: { pending_changes: false, changes: [] }, + }; + const rows = buildRows(data, {}); + const nginxRow = rows[2]; + assert( + !nginxRow.props.class.includes('pending'), + 'nginx row should not have pending class', + ); + const statusSpan = nginxRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status')); + const textChild = statusSpan.ch.find(c => c.tag === '#text'); + assertEq(textChild.text, 'Up to date'); +}); + +test('buildRows shows expand icon and details when expanded', () => { + const data = { + firewall: { + needs_apply: true, + changes: [ + { summary: 'Zone internal: interfaces changed', detail: '' }, + { summary: 'Zone dmz: services changed', detail: '' }, + ], + }, + }; + const rows = buildRows(data, { firewall: true }); + assertEq(rows.length, 6, 'should have 6 items (5 rows + 1 detail section)'); +}); + +test('buildRows hides expand icon when not expanded', () => { + const data = { + firewall: { + needs_apply: true, + changes: [ + { summary: 'Zone internal: interfaces changed', detail: '' }, + ], + }, + }; + const rows = buildRows(data, {}); + assertEq(rows.length, 5, 'should only have 5 rows, no detail section'); +}); + +test('buildRows pending flag but no changes treated as up-to-date', () => { + const data = { + firewall: { needs_apply: true, changes: [] }, + }; + const rows = buildRows(data, {}); + const fwRow = rows[0]; + assert( + !fwRow.props.class.includes('pending'), + 'no changes = up to date', + ); +}); + +test('buildRows detail section contains item VNodes', () => { + const data = { + firewall: { + needs_apply: true, + changes: [ + { summary: 'Zone internal: interfaces changed', detail: '' }, + ], + }, + }; + const rows = buildRows(data, { firewall: true }); + const detailSection = rows[1]; + assertIncludes(detailSection.props.class, 'apply-detail-section', 'should be detail section'); + assert(detailSection.ch.length > 0, 'detail section should have children'); +}); + +test('buildRows handles networkd key correctly', () => { + const data = { + networkd: { + pending_changes: true, + changes: [ + { summary: 'Network configuration has unapplied changes', detail: '' }, + ], + }, + }; + const rows = buildRows(data, {}); + const networkdRow = rows[4]; // networkd is 5th in list + assertIncludes(networkdRow.props.class, 'pending', 'networkd row should have pending class'); +}); + +test('buildRows row VNodes have correct tag', () => { + const data = { + firewall: { needs_apply: true, changes: [{ summary: 'test', detail: '' }] }, + }; + const rows = buildRows(data, {}); + for (const row of rows.slice(0, 5)) { + assertEq(row.tag, 'div', 'row should be a div'); + assert(row.props.class.includes('apply-subsystem-row'), 'row should have apply-subsystem-row class'); + } +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); \ No newline at end of file diff --git a/tests/test_dnsmasq.py b/tests/test_dnsmasq.py index 4c198ce..1c5ed0d 100644 --- a/tests/test_dnsmasq.py +++ b/tests/test_dnsmasq.py @@ -70,87 +70,6 @@ class TestSaveConfig: assert loaded["dns"]["domain"] == "test.lan" -class TestSetDhcpRange: - def test_add_new_range(self, temp_data_dir): - dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200") - cfg = dnsmasq.get_config() - assert len(cfg["dhcp"]["ranges"]) == 1 - assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1" - assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100" - - def test_replace_existing_range(self, temp_data_dir): - dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200") - dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250") - cfg = dnsmasq.get_config() - assert len(cfg["dhcp"]["ranges"]) == 1 - assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150" - - -class TestStaticLeases: - def test_add_static_lease(self, temp_data_dir): - dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer") - cfg = dnsmasq.get_config() - assert len(cfg["dhcp"]["static_leases"]) == 1 - assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF" - assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer" - - def test_update_static_lease(self, temp_data_dir): - dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50") - dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51") - cfg = dnsmasq.get_config() - assert len(cfg["dhcp"]["static_leases"]) == 1 - assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51" - - def test_remove_static_lease(self, temp_data_dir): - dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50") - dnsmasq.add_static_lease("11:22:33", "10.0.0.51") - dnsmasq.remove_static_lease("aa:bb:cc") - cfg = dnsmasq.get_config() - assert len(cfg["dhcp"]["static_leases"]) == 1 - assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33" - - -class TestDnsRecords: - def test_add_dns_record(self, temp_data_dir): - dnsmasq.add_dns_record("host", "10.0.0.100") - cfg = dnsmasq.get_config() - assert len(cfg["dns"]["custom_records"]) == 1 - - def test_remove_dns_record(self, temp_data_dir): - dnsmasq.add_dns_record("host", "10.0.0.100") - dnsmasq.add_dns_record("other", "10.0.0.101") - dnsmasq.remove_dns_record("host") - cfg = dnsmasq.get_config() - assert len(cfg["dns"]["custom_records"]) == 1 - assert cfg["dns"]["custom_records"][0]["name"] == "other" - - -class TestParseLeaseLine: - def test_valid_line(self): - line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1" - result = dnsmasq._parse_lease_line(line) - assert result is not None - assert result["mac"] == "AA:BB:CC:DD:EE:FF" - assert result["ip"] == "10.0.0.50" - assert result["hostname"] == "printer" - - def test_empty_line(self): - assert dnsmasq._parse_lease_line("") is None - - def test_comment_line(self): - assert dnsmasq._parse_lease_line("# comment") is None - - def test_short_line(self): - assert dnsmasq._parse_lease_line("incomplete") is None - - def test_minimal_fields(self): - line = "1700000000 AA:BB:CC 10.0.0.50" - result = dnsmasq._parse_lease_line(line) - assert result is not None - assert result["hostname"] == "" - assert result["interface"] == "" - - class TestUpstreamsAndDomain: def test_set_upstreams(self, temp_data_dir): dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"]) diff --git a/tests/test_firewall.py b/tests/test_firewall.py index 357a354..dd48de2 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -477,6 +477,172 @@ class TestDaemonConfigPending: result = daemonfirewall.config_pending_handler(None, None) assert result["needs_apply"] is True + @patch("lib.state.state") + def test_no_state_mutation(self, mock_st): + pending = { + "needs_apply": True, + "pending": [{"zone": "public", "type": "services"}], + } + mock_st.get.return_value = {**_mock_state(), "pending": pending} + original_keys = set(pending.keys()) + result = daemonfirewall.config_pending_handler(None, None) + assert "pending_summary" in result + assert set(pending.keys()) == original_keys, ( + "config_pending_handler must not mutate state store pending dict" + ) + + @patch("lib.state.state") + def test_detail_text_interfaces(self, mock_st): + mock_st.get.return_value = { + **_mock_state(), + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "internal", + "type": "interfaces", + "config": ["eth1", "eth2"], + "live": ["eth1"], + } + ], + }, + } + result = daemonfirewall.config_pending_handler(None, None) + assert len(result["pending_summary"]) == 1 + assert "Zone internal: interfaces changed" in result["pending_summary"][0] + assert "eth2" in result["pending_summary"][0] + + @patch("lib.state.state") + def test_detail_text_services(self, mock_st): + mock_st.get.return_value = { + **_mock_state(), + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "dmz", + "type": "services", + "config": ["ssh", "dns"], + "live": ["ssh"], + } + ], + }, + } + result = daemonfirewall.config_pending_handler(None, None) + assert len(result["pending_summary"]) == 1 + assert "Zone dmz: services changed" in result["pending_summary"][0] + + @patch("lib.state.state") + def test_detail_text_rich_rules(self, mock_st): + mock_st.get.return_value = { + **_mock_state(), + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "public", + "type": "rich_rules", + "config_count": 3, + "live_count": 1, + } + ], + }, + } + result = daemonfirewall.config_pending_handler(None, None) + assert len(result["pending_summary"]) == 1 + assert "Zone public: rich rules differ" in result["pending_summary"][0] + assert "config: 3" in result["pending_summary"][0] + assert "live: 1" in result["pending_summary"][0] + + @patch("lib.state.state") + def test_detail_text_masquerade(self, mock_st): + mock_st.get.return_value = { + **_mock_state(), + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "wan", + "type": "masquerade", + "config": True, + "live": False, + } + ], + }, + } + result = daemonfirewall.config_pending_handler(None, None) + assert len(result["pending_summary"]) == 1 + assert "Zone wan: masquerade changed" in result["pending_summary"][0] + assert "config: True" in result["pending_summary"][0] + + @patch("lib.state.state") + def test_detail_text_target(self, mock_st): + mock_st.get.return_value = { + **_mock_state(), + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "trusted", + "type": "target", + "config": "ACCEPT", + "live": "default", + } + ], + }, + } + result = daemonfirewall.config_pending_handler(None, None) + assert len(result["pending_summary"]) == 1 + assert "Zone trusted: target changed" in result["pending_summary"][0] + assert "config: ACCEPT" in result["pending_summary"][0] + + @patch("lib.state.state") + def test_detail_text_unknown_type(self, mock_st): + mock_st.get.return_value = { + **_mock_state(), + "pending": { + "needs_apply": True, + "pending": [{"zone": "public", "type": "foobarLayout"}], + }, + } + result = daemonfirewall.config_pending_handler(None, None) + assert len(result["pending_summary"]) == 1 + assert "Zone public: foobarLayout changed" in result["pending_summary"][0] + + @patch("lib.state.state") + def test_detail_text_mixed_types(self, mock_st): + mock_st.get.return_value = { + **_mock_state(), + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "internal", + "type": "interfaces", + "config": ["eth1"], + "live": [], + }, + { + "zone": "dmz", + "type": "services", + "config": ["ssh", "dns"], + "live": ["ssh"], + }, + { + "zone": "public", + "type": "rich_rules", + "config_count": 2, + "live_count": 1, + }, + ], + }, + } + result = daemonfirewall.config_pending_handler(None, None) + assert len(result["pending_summary"]) == 3 + assert "Zone internal: interfaces changed" in result["pending_summary"][0] + assert "Zone dmz: services changed" in result["pending_summary"][1] + assert "Zone public: rich rules differ" in result["pending_summary"][2] + # --------------------------------------------------------------------------- # Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port diff --git a/tests/test_server.py b/tests/test_server.py index 0c1154e..ebe8f58 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -66,7 +66,7 @@ class TestBlueprintsRegistered: def test_all_blueprints_registered(self, client): from webui.server import BLUEPRINTS - assert len(BLUEPRINTS) == 7 + assert len(BLUEPRINTS) == 8 names = [name for name, _ in BLUEPRINTS] assert "firewall" in names assert "network" in names diff --git a/tests/test_status_pending.py b/tests/test_status_pending.py new file mode 100644 index 0000000..188e7ee --- /dev/null +++ b/tests/test_status_pending.py @@ -0,0 +1,342 @@ +"""Tests for daemon/handlers/status.py — aggregate pending + apply-all.""" + +from typing import Any, ClassVar +from unittest.mock import MagicMock, patch + +from daemon.handlers import status +from lib.state import State + + +def _make_state(**kwargs): + """Create a minimal in-memory state for pending checks.""" + st = State() + for name, data in kwargs.items(): + st.set(name, data) + return st + + +def _mock_state_store(state_dict): + """Return a mock that looks like state_store.get().""" + mock = MagicMock() + mock.get.side_effect = lambda name: state_dict.get(name) + return mock + + +class TestFwChangeSummary: + """Test fw_change_summary helper from status module.""" + + def test_interfaces_summary(self): + s = status.fw_change_summary( + "internal", "interfaces", {"config": ["eth1"], "live": []} + ) + assert "Zone internal: interfaces changed" in s + assert "eth1" in s + + def test_services_summary(self): + s = status.fw_change_summary( + "dmz", "services", {"config": ["ssh", "dns"], "live": ["ssh"]} + ) + assert "Zone dmz: services changed" in s + + def test_rich_rules_summary(self): + s = status.fw_change_summary( + "public", "rich_rules", {"config_count": 2, "live_count": 1} + ) + assert "config: 2" in s + assert "live: 1" in s + + def test_forward_ports_summary(self): + s = status.fw_change_summary( + "wan", "forward_ports", {"config_count": 3, "live_count": 0} + ) + assert "Zone wan: port forwards differ" in s + + def test_masquerade_summary(self): + s = status.fw_change_summary( + "lan", "masquerade", {"config": True, "live": False} + ) + assert "Zone lan: masquerade changed" in s + + def test_target_summary(self): + s = status.fw_change_summary( + "vpn", "target", {"config": "ACCEPT", "live": "default"} + ) + assert "Zone vpn: target changed" in s + + def test_unknown_type_summary(self): + s = status.fw_change_summary("public", "weird", {}) + assert "Zone public: weird changed" in s + + +class TestHashSubsystem: + """Test _hash_subsystem helper from status module.""" + + def test_no_state(self): + result = status._hash_subsystem("nginx", None) + assert result["pending_changes"] is False + assert result["summary"] == "Up to date" + + def test_pending_true(self): + st = {"status": {"pending_changes": True}} + result = status._hash_subsystem("wireguard", st) + assert result["pending_changes"] is True + assert "unapplied changes" in result["summary"] + assert len(result["changes"]) == 1 + + def test_pending_false(self): + st = {"status": {"pending_changes": False}} + result = status._hash_subsystem("networkd", st) + assert result["pending_changes"] is False + + def test_empty_status(self): + st = {} + result = status._hash_subsystem("dnsmasq", st) + assert result["pending_changes"] is False + + +class TestStatusPending: + """Test the aggregate pending endpoint.""" + + @patch("daemon.handlers.status.state_store") + def test_all_synced(self, mock_store): + mock_store.get.return_value = { + "firewall": {"pending": {"needs_apply": False, "pending": []}}, + "dnsmasq": {"status": {"pending_changes": False}}, + "nginx": {"status": {"pending_changes": False}}, + "wireguard": {"status": {"pending_changes": False}}, + "networkd": {"status": {"pending_changes": False}}, + } + result = status.status_pending(None, None) + assert result["total_changes"] == 0 + assert not result["firewall"]["needs_apply"] + assert not result["dnsmasq"]["pending_changes"] + + def _patch_store(self, data): + mock = MagicMock() + mock.get.side_effect = lambda name: data.get(name) + return patch("daemon.handlers.status.state_store", mock) + + def test_firewall_pending_only(self): + with self._patch_store( + { + "firewall": { + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "internal", + "type": "interfaces", + "config": ["eth1"], + "live": [], + } + ], + } + }, + "dnsmasq": {"status": {"pending_changes": False}}, + "nginx": {"status": {"pending_changes": False}}, + "wireguard": {"status": {"pending_changes": False}}, + "networkd": {"status": {"pending_changes": False}}, + } + ): + result = status.status_pending(None, None) + assert result["total_changes"] == 1 + assert result["firewall"]["change_count"] == 1 + + def test_multiple_subsystems_pending(self): + with self._patch_store( + { + "firewall": { + "pending": { + "needs_apply": True, + "pending": [ + { + "zone": "lan", + "type": "services", + "config": ["ssh"], + "live": [], + }, + { + "zone": "wan", + "type": "masquerade", + "config": True, + "live": False, + }, + ], + } + }, + "dnsmasq": {"status": {"pending_changes": True}}, + "nginx": {"status": {"pending_changes": False}}, + "wireguard": {"status": {"pending_changes": True}}, + "networkd": {"status": {"pending_changes": False}}, + } + ): + result = status.status_pending(None, None) + assert result["total_changes"] == 4 # 2 FW + 1 DHCP + 1 WG + assert result["firewall"]["change_count"] == 2 + assert result["firewall"]["needs_apply"] is True + assert result["dnsmasq"]["pending_changes"] is True + assert result["wireguard"]["pending_changes"] is True + + def test_empty_state(self): + with self._patch_store({}): + result = status.status_pending(None, None) + assert result["total_changes"] == 0 + assert not result["firewall"]["needs_apply"] + + def test_firewall_no_pending_key(self): + with self._patch_store( + { + "firewall": {}, + "dnsmasq": {"status": {"pending_changes": False}}, + "nginx": None, + "wireguard": None, + "networkd": None, + } + ): + result = status.status_pending(None, None) + assert result["total_changes"] == 0 + assert not result["firewall"]["needs_apply"] + + +class TestStatusApplyAll: + """Test the apply-all endpoint. + + Patches SYS_APPLY dict entries directly since they hold function + references at import time. + """ + + _fake_pending_all: ClassVar[dict[str, Any]] = { + "firewall": {"needs_apply": False, "change_count": 0, "changes": []}, + "dnsmasq": {"pending_changes": False, "summary": "Up to date", "changes": []}, + "nginx": {"pending_changes": False, "summary": "Up to date", "changes": []}, + "wireguard": {"pending_changes": False, "summary": "Up to date", "changes": []}, + "networkd": {"pending_changes": False, "summary": "Up to date", "changes": []}, + } + + @patch("daemon.handlers.status.status_pending") + @patch("daemon.handlers.status.refresh_state") + def test_nothing_to_apply(self, mock_refresh, mock_pending): + mock_pending.return_value = self._fake_pending_all + result = status.status_apply_all(None, None) + assert result["applied"] == [] + assert result["errors"] == {} + mock_refresh.assert_called_once() + + def test_applies_pending_subsystems(self): + mock_net = MagicMock() + mock_fw = MagicMock() + + pending_data = {**self._fake_pending_all} + pending_data["firewall"]["needs_apply"] = True + pending_data["firewall"]["change_count"] = 1 + pending_data["networkd"]["pending_changes"] = True + + with ( + patch("daemon.handlers.status.status_pending", return_value=pending_data), + patch("daemon.handlers.status.refresh_state"), + patch.dict( + "daemon.handlers.status.SYS_APPLY", + { + "networkd": mock_net, + "firewall": mock_fw, + }, + ), + ): + result = status.status_apply_all(None, None) + assert "networkd" in result["applied"] + assert "firewall" in result["applied"] + mock_net.assert_called_once() + mock_fw.assert_called_once() + + def test_error_in_subsystem(self): + mock_fw = MagicMock(side_effect=RuntimeError("firewalld not running")) + + pending_data = {**self._fake_pending_all} + pending_data["firewall"]["needs_apply"] = True + pending_data["firewall"]["change_count"] = 1 + + with ( + patch("daemon.handlers.status.status_pending", return_value=pending_data), + patch("daemon.handlers.status.refresh_state"), + patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}), + ): + result = status.status_apply_all(None, None) + assert "firewall" not in result["applied"] + assert "Firewall" in result["errors"] + assert "firewalld not running" in result["errors"]["Firewall"] + + def test_order_is_respected(self): + call_order = [] + + def track(name): + def wrapper(*args): + call_order.append(name) + + return wrapper + + mock_net = MagicMock(side_effect=track("networkd")) + mock_wg = MagicMock(side_effect=track("wireguard")) + + pending_data = {**self._fake_pending_all} + pending_data["wireguard"]["pending_changes"] = True + pending_data["networkd"]["pending_changes"] = True + + with ( + patch("daemon.handlers.status.status_pending", return_value=pending_data), + patch("daemon.handlers.status.refresh_state"), + patch.dict( + "daemon.handlers.status.SYS_APPLY", + { + "networkd": mock_net, + "wireguard": mock_wg, + }, + ), + ): + status.status_apply_all(None, None) + assert call_order == ["networkd", "wireguard"] + + def test_partial_failure_still_applies_others(self): + mock_fw = MagicMock(side_effect=RuntimeError("fail")) + mock_nginx = MagicMock() + + pending_data = {**self._fake_pending_all} + pending_data["firewall"]["needs_apply"] = True + pending_data["firewall"]["change_count"] = 1 + pending_data["nginx"]["pending_changes"] = True + + with ( + patch("daemon.handlers.status.status_pending", return_value=pending_data), + patch("daemon.handlers.status.refresh_state"), + patch.dict( + "daemon.handlers.status.SYS_APPLY", + { + "firewall": mock_fw, + "nginx": mock_nginx, + }, + ), + ): + result = status.status_apply_all(None, None) + assert "firewall" not in result["applied"] + assert "nginx" in result["applied"] + assert "Firewall" in result["errors"] + mock_nginx.assert_called_once() + + +class TestSysOrder: + """Verify SYS_ORDER and SYS_LABELS constants.""" + + def test_order_network_first(self): + assert status.SYS_ORDER[0] == "networkd" + + def test_all_subsystems_present(self): + expected = {"networkd", "firewall", "wireguard", "dnsmasq", "nginx"} + assert set(status.SYS_ORDER) == expected + + def test_labels_match(self): + for name in status.SYS_ORDER: + assert name in status.SYS_LABELS + assert name in status.SYS_APPLY + + def test_apply_functions_callable(self): + for name in status.SYS_ORDER: + assert callable(status.SYS_APPLY[name]) diff --git a/vendor/.empty b/vendor/.empty new file mode 100644 index 0000000..e69de29 diff --git a/webui/api/status.py b/webui/api/status.py new file mode 100644 index 0000000..5918731 --- /dev/null +++ b/webui/api/status.py @@ -0,0 +1,51 @@ +"""Aggregate status API blueprint. + +Exposed at /api/status/* and delegates all operations to vacuum-walld. +""" + +from __future__ import annotations + +import logging + +from flask import Blueprint + +from daemon.client import get, post +from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL +from webui.api.common import _error, _ok + +logger = logging.getLogger(__name__) +bp = Blueprint("status", __name__) + + +@bp.route("/pending", methods=["GET"]) +def pending(): + """Retrieve aggregate pending changes across all subsystems. + + Endpoint: + GET /api/status/pending + + Returns: + JSON response with per-subsystem pending status and total change count. + """ + try: + return _ok(get(GET_STATUS_PENDING)) + except RuntimeError as exc: + logger.error("Failed to get pending status: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/apply-all", methods=["POST"]) +def apply_all(): + """Apply pending changes for all subsystems in dependency order. + + Endpoint: + POST /api/status/apply-all + + Returns: + JSON response with applied subsystems list and any errors encountered. + """ + try: + return _ok(post(POST_STATUS_APPLY_ALL)) + except RuntimeError as exc: + logger.error("Failed to apply all pending changes: %s", exc) + return _error(str(exc), 500) diff --git a/webui/server.py b/webui/server.py index 5526095..eb45abd 100644 --- a/webui/server.py +++ b/webui/server.py @@ -26,6 +26,7 @@ from webui.api.firewall import bp as firewall_bp from webui.api.logs import bp as logs_bp from webui.api.network import bp as network_bp from webui.api.proxy import bp as proxy_bp +from webui.api.status import bp as status_bp from webui.api.wireguard import bp as wireguard_bp # --------------------------------------------------------------------------- @@ -94,6 +95,7 @@ app.register_blueprint(proxy_bp, url_prefix="/api/proxy") app.register_blueprint(certs_bp, url_prefix="/api/certs") app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard") app.register_blueprint(logs_bp, url_prefix="/api/logs") +app.register_blueprint(status_bp, url_prefix="/api/status") BLUEPRINTS = [ ("firewall", firewall_bp), @@ -103,6 +105,7 @@ BLUEPRINTS = [ ("certs", certs_bp), ("wireguard", wireguard_bp), ("logs", logs_bp), + ("status", status_bp), ] for name, _ in BLUEPRINTS: diff --git a/webui/static/hoover/components/applyconfirm.js b/webui/static/hoover/components/applyconfirm.js new file mode 100644 index 0000000..d82b2e1 --- /dev/null +++ b/webui/static/hoover/components/applyconfirm.js @@ -0,0 +1,138 @@ +/** + * Hoover — components/applyconfirm.js + * + * Apply button with cross-subsystem confirmation modal. + * Fetches pending changes from /api/status/pending, shows them in an + * expandable modal, then applies all via /api/status/apply-all. + */ + +import { h } from '../vdom.js?v=8'; +import { html } from '../html.js?v=8'; +import { reactive } from '../reactivity.js?v=8'; +import { apiFetch, toast } from '../api.js?v=8'; +import { modelFetch } from '../model.js?v=8'; +import { openModal, closeModal, modalVNodes } from './modal.js?v=8'; + +export const SUBSYSTEM_LIST = [ + { key: 'firewall', label: 'Firewall' }, + { key: 'dnsmasq', label: 'DHCP/DNS' }, + { key: 'nginx', label: 'Nginx' }, + { key: 'wireguard', label: 'WireGuard' }, + { key: 'networkd', label: 'Network' }, +]; + +/** + * Extract pending state from a subsystem result. + * Handles firewall's `needs_apply` vs hash subsystems' `pending_changes`. + */ +export function isPending(ss) { + return (ss.needs_apply || ss.pending_changes || false); +} + +/** + * Build the VNode array for modal rows given pending data and expanded state. + */ +export function buildRows(pendingData, expanded) { + const vnodeList = []; + + for (const sub of SUBSYSTEM_LIST) { + const ss = pendingData[sub.key] || {}; + const changes = ss.changes || []; + const hasPending = isPending(ss) && changes.length > 0; + const isExpanded = !!expanded[sub.key]; + + vnodeList.push(html`
+${sub.label} +${hasPending ? changes.length + ' pending changes' : 'Up to date'} +${hasPending ? html`\u25B6` : ''} +
`); + + if (hasPending && isExpanded) { + vnodeList.push(html`
${changes.map(c => html`
${c.summary || c.detail || c}
`)}
`); + } + } + + return vnodeList; +} + +/** + * POST apply-all, toast result, close modal, refresh models. + */ +async function doApply(successMsg, refreshTargets) { + const resp = await apiFetch('/api/status/apply-all', { method: 'POST' }); + if (resp.ok) { + toast(successMsg, 'success'); + closeModal(); + if (refreshTargets) { + const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets]; + names.forEach(n => modelFetch(n)); + } + } else { + toast(resp.error || 'Apply failed', 'error'); + } +} + +/** + * Fetch pending state, then open the confirmation modal. + */ +async function openApplyModal(successMsg, refreshTargets) { + 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) => { + const rows = buildRows(pendingData, expanded); + + if (totalChanges === 0) { + modalVNodes(inner, html`
+ +
No pending changes to apply.
+
+
`); + return; + } + + modalVNodes(inner, html`
+ + +
+
`); + }); +} + +/** + * Apply button with cross-subsystem confirmation modal. + * + * @param {object} props + * @param {boolean} props.pending - Whether any subsystem has pending changes + * @param {string} [props.label] - Apply button text (default: 'Apply') + * @param {string} [props.syncedLabel] - Synced button text (default: 'Synced') + * @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced) + * @param {string} [props.successMsg] - Success toast message (default: 'All changes applied') + * @param {string|string[]} [props.refresh] - Model name(s) to refresh after apply + */ +export function ApplyConfirm(props = {}) { + const label = props.label || 'Apply'; + const syncedLabel = props.syncedLabel || 'Synced'; + const successMsg = props.successMsg || 'All changes applied'; + + return h('button', { + class: props.cls !== undefined + ? props.cls + : (props.pending ? 'btn btn-primary' : 'btn btn-outline'), + 'on:click': () => { + if (!props.pending) { + toast(successMsg || 'All synced', 'info'); + return; + } + openApplyModal(successMsg, props.refresh); + }, + }, props.pending ? label : syncedLabel); +} \ No newline at end of file diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index 1e5f8d6..eb85a45 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -43,5 +43,8 @@ export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, cert /* ── UI Components: Modal ────────────────────────────────────── */ export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=8'; +/* ── UI Components: Apply ────────────────────────────────────── */ +export { ApplyConfirm } from './components/applyconfirm.js?v=8'; + /* ── UI Components: Toast ────────────────────────────────────── */ export { ToastContainer } from './components/toast.js?v=8'; diff --git a/webui/static/style.css b/webui/static/style.css index dbe0b53..b4a25b1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -699,6 +699,82 @@ body { text-align: center; } +/* ApplyConfirm modal */ +.apply-subsystem-row { + display: flex; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border); + cursor: default; +} + +.apply-subsystem-row.pending { + cursor: pointer; +} + +.apply-subsystem-row.pending:hover { + background: rgba(0, 180, 216, 0.08); +} + +.apply-subsystem-name { + flex: 1; + font-weight: 600; + font-size: 14px; +} + +.apply-subsystem-status { + margin-left: 12px; + color: var(--text-muted); + font-size: 13px; +} + +.apply-subsystem-status.pending { + color: var(--warning); + font-weight: 500; +} + +.apply-expand-icon { + margin-left: 8px; + transition: transform 0.2s; + font-size: 12px; +} + +.apply-expand-icon.expanded { + transform: rotate(90deg); +} + +.apply-detail-section { + padding: 8px 12px; + background: rgba(0, 0, 0, 0.15); + margin: 4px 0 4px 12px; + border-radius: 4px; + font-size: 13px; +} + +.apply-detail-item { + padding: 4px 0; + border-bottom: 1px solid var(--border); +} + +.apply-detail-item:last-child { + border-bottom: none; +} + +.apply-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; + margin-top: 12px; + border-top: 1px solid var(--border); +} + +.apply-no-changes { + padding: 16px; + text-align: center; + color: var(--text-muted); +} + /* Responsive */ @media (max-width: 768px) { .sidebar { diff --git a/webui/static/vendor/.empty b/webui/static/vendor/.empty new file mode 100644 index 0000000..e69de29