diff --git a/AGENTS.md b/AGENTS.md
index e7a2306..67f3a25 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,8 +50,8 @@ Conventions:
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components).
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default.
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
-- `h()` builds VNodes; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
-- Events use `on:` prefix (`on:click`, `on:submit`). `class` prop accepts object.
+- `h()` builds VNodes; `html` tag (from htm) enables JSX-like templates; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
+- Events: `h()` uses `on:click` prefix. `html` templates use camelCase `onClick` (adapter translates to `on:click`).
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root.
- No build step — ES modules served raw. Assets versioned via `?v=N` query string.
diff --git a/daemon/server.py b/daemon/server.py
index fb4aa9a..7107dfb 100644
--- a/daemon/server.py
+++ b/daemon/server.py
@@ -5,6 +5,7 @@ Handles routing, batching, and request/response lifecycle.
"""
import asyncio
+import hashlib
import json
import logging
import os
@@ -16,6 +17,7 @@ from typing import Any
from aiohttp import web
from daemon.iface import PathLike
+from lib.state import _DEFAULT_POLL_INTERVALS
from lib.state import state as state_store
logger = logging.getLogger(__name__)
@@ -24,6 +26,23 @@ PROJECT_DIR = Path(__file__).resolve().parent.parent
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091"))
+# Polling intervals per subsystem (seconds). VACUUM_WALL_POLL_INTERVALS overrides.
+_RAW_POLL = os.environ.get("VACUUM_WALL_POLL_INTERVALS", "")
+if _RAW_POLL:
+ _POLL_OVERRIDE: dict[str, int] = {}
+ for pair in _RAW_POLL.split(","):
+ if ":" in pair:
+ name, _, val = pair.partition(":")
+ try:
+ _POLL_OVERRIDE[name.strip()] = int(val.strip())
+ except ValueError:
+ logger.warning(
+ "Invalid poll interval value %r for %r, skipping", val, name
+ )
+ _POLL_INTERVALS = {**_DEFAULT_POLL_INTERVALS, **_POLL_OVERRIDE}
+else:
+ _POLL_INTERVALS = dict(_DEFAULT_POLL_INTERVALS)
+
class Handler:
"""Wrapper for a daemon handler function.
@@ -324,6 +343,7 @@ def create_app() -> web.Application:
# WebSocket subscribers
_ws_subscribers: set[web.WebSocketResponse] = set()
_ws_tasks: set[asyncio.Task[None]] = set()
+_poll_tasks: set[asyncio.Task[None]] = set()
async def _handle_ws(request: web.Request) -> web.Response:
@@ -367,6 +387,54 @@ async def broadcast_versions() -> None:
logger.warning("Removed %d dead WS subscribers", len(dead))
+async def broadcast_tick(subsystems: list[str]) -> None:
+ """Broadcast a lightweight tick to WS clients without version payload."""
+ data = json.dumps({"type": "tick", "subsystems": subsystems})
+ dead: set[web.WebSocketResponse] = set()
+ for ws in _ws_subscribers:
+ try:
+ await ws.send_str(data)
+ except Exception:
+ dead.add(ws)
+ _ws_subscribers.difference_update(dead)
+ if dead:
+ logger.warning("Removed %d dead WS subscribers", len(dead))
+
+
+async def _poll_loop(subsystem: str, interval: int) -> None:
+ """Periodically poll a subsystem for state changes and broadcast as needed."""
+ offset = int(hashlib.md5(subsystem.encode()).hexdigest(), 16) % interval
+ await asyncio.sleep(offset)
+ while True:
+ try:
+ structural, volatile = state_store.poll(subsystem)
+ if structural:
+ state_store.bump(subsystem)
+ await broadcast_versions()
+ elif volatile:
+ await broadcast_tick([subsystem])
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.error("Poll loop error for %s", subsystem, exc_info=True)
+ await asyncio.sleep(interval)
+
+
+def start_polling() -> None:
+ """Start one poll loop task per subsystem."""
+ for subsystem, interval in _POLL_INTERVALS.items():
+ task = asyncio.create_task(_poll_loop(subsystem, interval))
+ task.add_done_callback(_poll_tasks.discard)
+ _poll_tasks.add(task)
+
+
+def _stop_polling() -> None:
+ """Cancel all polling tasks."""
+ for task in _poll_tasks:
+ task.cancel()
+ _poll_tasks.clear()
+
+
async def _health(_request: web.Request) -> web.Response:
"""Return the health check response.
@@ -458,6 +526,7 @@ def main() -> None:
def _on_shutdown(_sig: int) -> None:
logger.info("Shutting down daemon...")
+ _stop_polling()
loop.stop()
for sig in (signal.SIGTERM, signal.SIGINT):
@@ -475,6 +544,10 @@ def main() -> None:
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
+ for subsystem in state_store.SUBSYSTEMS:
+ if state_store.get(subsystem) is not None:
+ state_store.bump(subsystem)
+ loop.run_until_complete(start_polling())
logger.info("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
diff --git a/docs/api.md b/docs/api.md
index 264f4de..361b5b3 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -1502,4 +1502,20 @@ GET /api/logs/app
Return recent application log entries as rendered HTML.
-**Response:** HTML fragment of `
` elements.
\ No newline at end of file
+**Response:** HTML fragment of `
` elements.
+
+---
+
+## WebSocket Protocol
+
+The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state change notifications. On connect, the server sends:
+
+```json
+{"type": "init", "versions": {"firewall": 0, "dnsmasq": 0, ...}}
+```
+
+### Message Types
+
+- **`versions`** — Structural state change. `updated` contains subsystem names whose version counters changed. Triggers full re-fetch.
+- **`tick`** — Volatile-only change (stats, counters, DHCP IPs). `subsystems` contains affected subsystem names. Triggers lightweight per-subsystem re-fetch.
+- **`notify`** — Single-topic notification. `topic` is the subsystem name.
\ No newline at end of file
diff --git a/docs/architecture.md b/docs/architecture.md
index e879279..dec0963 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -81,6 +81,30 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. |
| ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
+#### Background Polling
+
+The daemon runs background polling tasks for subsystems with external runtime state. Each subsystem has a configurable interval and a two-layer diff (structural vs volatile) to minimize unnecessary broadcasts.
+
+| Subsystem | Interval | Rationale |
+|-----------|----------|-----------|
+| firewall | 30s | Most expensive collector (6+ subprocess calls) |
+| wireguard | 10s | Peer connections/handshakes change frequently |
+| dnsmasq | 10s | Lease file + service status |
+| networkd | 10s | Interface up/down, DHCP address changes |
+
+nginx and acme are not polled — they have no external runtime state.
+
+**Two-layer diff:** Each poll cycle classifies changes as:
+- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", ...}` → full UI re-load
+- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystems": [...]}` → lightweight per-subsystem re-fetch
+- **No change**: silence
+
+Volatile fields per subsystem: `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`.
+
+Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firewall:30,wireguard:10,...`).
+
+On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`.
+
## Directory Structure
### Config — Declarative Settings
diff --git a/docs/hoover.md b/docs/hoover.md
index d478f48..1fae449 100644
--- a/docs/hoover.md
+++ b/docs/hoover.md
@@ -261,6 +261,59 @@ h('#comp', { component: MyPage, key: '/dashboard' }, [])
**Children flattening:** `null`, `undefined`, and `false` children are filtered out. String and number primitives are automatically converted to text VNodes.
+### HTM (Tagged HTML Templates)
+
+Hoover ships with **htm** for JSX-like template syntax using tagged template literals. Import and use:
+
+```javascript
+import { html, Badge, ConfirmDelete } from '/static/hoover/index.js';
+
+// Instead of:
+h('div', { class: 'card' },
+ h('h3', { style: 'color:red' }, 'Title'),
+ h('button', { 'on:click': handler }, 'Click')
+)
+
+// Write:
+html`
+
Title
+
+
`
+```
+
+**Event naming:** Use camelCase `onClick=${fn}` — the adapter translates events to Hoover's `on:click` convention. Any attribute starting with `on` followed by a capital letter (e.g., `onSubmit`, `onChange`) is converted.
+
+**Component syntax:** Use `<${Component}>` syntax for inline components:
+
+```javascript
+html`<${Badge} text=${val} variant="info" />`
+html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" refresh="firewall" />`
+```
+
+**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
+
+```javascript
+html`
+
${esc(item.name)}
+
${item.value}
+
`
+```
+
+**Spread attributes:** Use `...${props}` to spread an object as props:
+
+```javascript
+html`<${Badge} ...${badgeProps} />`
+```
+
+**Boolean attributes:** Use `html`<${Badge} readonly />`` for boolean attributes.
+
+**Coexistence with `h()`:** Both `h` and `html` are exported from the barrel. Use whichever is clearer for the given context. Simple elements are often shorter with `h()`, while complex nested structures benefit from `html`.
+
+**Limitations:**
+- No `...` closing syntax — must use self-closing `<${Badge} ... />` or full `<${Badge} ... >${Badge}>` syntax
+- No control flow (`if/for`) in templates — use JavaScript conditionals and `.map()` before interpolation
+- `esc()` is still required for user-controlled text to prevent XSS
+
### Props
| Prop | Behavior |
diff --git a/lib/state.py b/lib/state.py
index 038d176..5b2248b 100644
--- a/lib/state.py
+++ b/lib/state.py
@@ -28,6 +28,13 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
+_DEFAULT_POLL_INTERVALS: dict[str, int] = {
+ "firewall": 30,
+ "wireguard": 10,
+ "dnsmasq": 10,
+ "networkd": 10,
+}
+
# ---------------------------------------------------------------------------
# State store
@@ -148,6 +155,59 @@ class State:
"""
return all(v is not None for v in self._data.values())
+ def poll(self, subsystem: str) -> tuple[bool, bool]:
+ """Run the collector for *subsystem* and compare against current state.
+
+ The polled equivalent of ``populate()`` — same try/except safety,
+ but with two-layer diff before storing.
+
+ Args:
+ subsystem: Subsystem name to poll.
+
+ Returns:
+ ``(structural_changed, volatile_changed)``. ``(False, False)`` on
+ collector failure (no broadcast on failure to avoid noisy ticks).
+ """
+ collector = _COLLECTORS.get(subsystem)
+ if collector is None:
+ return (False, False)
+
+ vol = _VOLATILE.get(subsystem, frozenset())
+ try:
+ new_data = collector()
+ except Exception:
+ logger.warning(
+ "Poll collection failed for %s",
+ subsystem,
+ exc_info=True,
+ )
+ return (False, False)
+
+ old_data = self._data.get(subsystem)
+ structural, volatile = _diff_layers(old_data, new_data, vol)
+ self._data[subsystem] = new_data
+ return (structural, volatile)
+
+ def poll_all(
+ self,
+ intervals: dict[str, int] | None = None,
+ ) -> dict[str, tuple[bool, bool]]:
+ """Poll all subsystems that have a polling interval configured.
+
+ Args:
+ intervals: Subsystems to poll keyed by name. Defaults to
+ ``_DEFAULT_POLL_INTERVALS``.
+
+ Returns:
+ Dict of ``{subsystem: (structural, volatile)}`` for each polled
+ subsystem.
+ """
+ targets = intervals or _DEFAULT_POLL_INTERVALS
+ results: dict[str, tuple[bool, bool]] = {}
+ for name in targets:
+ results[name] = self.poll(name)
+ return results
+
# Singleton
state = State()
@@ -158,6 +218,7 @@ state = State()
# ---------------------------------------------------------------------------
_COLLECTORS: dict[str, Any] = {}
+_VOLATILE: dict[str, frozenset[str]] = {}
def register_collector(subsystem: str, fn: Any) -> Any:
@@ -174,6 +235,135 @@ def register_collector(subsystem: str, fn: Any) -> Any:
return fn
+def register_volatile(subsystem: str, keys: frozenset[str]) -> None:
+ """Register volatile field paths for *subsystem*.
+
+ Args:
+ subsystem: Subsystem name.
+ keys: Frozenset of dot-separated volatile field paths
+ (e.g. ``status.peers[].transfer_received``).
+ """
+ _VOLATILE[subsystem] = keys
+
+
+# ---------------------------------------------------------------------------
+# Two-layer diff
+# ---------------------------------------------------------------------------
+
+
+def _strip_volatile(
+ data: dict[str, Any],
+ volatile: frozenset[str],
+ pop_keys: frozenset[str] | None = None,
+) -> dict[str, Any]:
+ """Return a copy of *data* with volatile fields zeroed out.
+
+ For list-of-dicts fields (``key[].subkey``), strips the volatile sub-keys
+ from each dict in the list. For scalar/dict fields, sets them to ``None``.
+
+ Args:
+ data: State dict to process.
+ volatile: Frozenset of dot-separated volatile field paths.
+ pop_keys: Optional keys to remove from the root dict before stripping.
+
+ Returns:
+ A new dict with volatile fields replaced by ``None``.
+ """
+ stripped = deepcopy(data)
+ if pop_keys:
+ for k in pop_keys:
+ stripped.pop(k, None)
+ for vpath in volatile:
+ # Determine if this is a list-of-dicts pattern
+ list_marker = vpath.index("[]") if "[]" in vpath else -1
+ if list_marker != -1:
+ # Split into prefix (before []), item keys (after [])
+ prefix = vpath[:list_marker].split(".")
+ item_keys = (
+ vpath[list_marker + 3 :].split(".")
+ if list_marker + 3 < len(vpath)
+ else []
+ )
+ parent = stripped
+ for seg in prefix:
+ if isinstance(parent, dict) and seg in parent:
+ parent = parent[seg]
+ else:
+ break
+ if isinstance(parent, list):
+ items = parent
+ elif isinstance(parent, dict):
+ logger.debug(
+ "_strip_volatile: %s resolved to dict, falling back to .values()",
+ vpath,
+ )
+ items = parent.values()
+ else:
+ continue
+
+ for item in items:
+ if isinstance(item, dict):
+ curr = item
+ for i, ik in enumerate(item_keys):
+ if i == len(item_keys) - 1:
+ curr[ik] = None
+ else:
+ if isinstance(curr, dict) and ik in curr:
+ curr = curr[ik]
+ else:
+ break
+ else:
+ # Scalar/dict path
+ segments = vpath.split(".")
+ parent = stripped
+ for i, seg in enumerate(segments):
+ if i == len(segments) - 1:
+ if isinstance(parent, dict) and seg in parent:
+ parent[seg] = None
+ else:
+ if isinstance(parent, dict) and seg in parent:
+ parent = parent[seg]
+ else:
+ break
+ return stripped
+
+
+def _diff_layers(
+ old: dict[str, Any] | None,
+ new: dict[str, Any],
+ volatile: frozenset[str],
+) -> tuple[bool, bool]:
+ """Compare *old* and *new* state using two-layer diff.
+
+ Strips ``timestamp`` from both before comparing.
+
+ Returns:
+ ``(structural_changed, volatile_changed)`` —
+ ``True`` means that layer differs between old and new.
+
+ If structural data changed, volatile is always ``False``
+ (the structural change already triggers a full re-fetch, so
+ the volatile signal is suppressed).
+ """
+ if old is None:
+ return (True, True)
+
+ # Structural diff: compare with volatile/timestamp fields zeroed
+ pop_keys = frozenset(("timestamp",))
+ old_struct = _strip_volatile(old, volatile, pop_keys)
+ new_struct = _strip_volatile(new, volatile, pop_keys)
+ structural = old_struct != new_struct
+
+ # Volatile diff: compare without timestamp
+ volatile_changed = False
+ if not structural:
+ old_no_ts = {k: v for k, v in old.items() if k != "timestamp"}
+ new_no_ts = {k: v for k, v in new.items() if k != "timestamp"}
+ volatile_changed = old_no_ts != new_no_ts
+
+ return (structural, volatile_changed)
+
+
# ---------------------------------------------------------------------------
# Firewall collector
# ---------------------------------------------------------------------------
@@ -307,6 +497,15 @@ def _collect_firewall() -> dict[str, Any]:
register_collector("firewall", _collect_firewall)
+register_volatile(
+ "firewall",
+ frozenset(
+ {
+ "interfaces[].ips",
+ "interfaces[].ipv6",
+ }
+ ),
+)
# ---------------------------------------------------------------------------
@@ -401,6 +600,7 @@ def _collect_dnsmasq() -> dict[str, Any]:
register_collector("dnsmasq", _collect_dnsmasq)
+# dnsmasq has no volatile fields — leases change slowly enough to treat as structural
# ---------------------------------------------------------------------------
@@ -856,6 +1056,16 @@ def _collect_wireguard() -> dict[str, Any]:
register_collector("wireguard", _collect_wireguard)
+register_volatile(
+ "wireguard",
+ frozenset(
+ {
+ "status.peers[].transfer_received",
+ "status.peers[].transfer_sent",
+ "status.peers[].latest_handshake",
+ }
+ ),
+)
# ---------------------------------------------------------------------------
# Networkd collector
@@ -889,8 +1099,20 @@ def _collect_networkd() -> dict[str, Any]:
register_collector("networkd", _collect_networkd)
+register_volatile(
+ "networkd",
+ frozenset(
+ {
+ "interfaces[].addresses",
+ }
+ ),
+)
__all__ = [
+ "_DEFAULT_POLL_INTERVALS",
"State",
+ "_diff_layers",
+ "_strip_volatile",
+ "register_volatile",
"state",
]
diff --git a/tests/test_handler_acme.py b/tests/test_handler_acme.py
index 6a56851..006d1cb 100644
--- a/tests/test_handler_acme.py
+++ b/tests/test_handler_acme.py
@@ -122,7 +122,7 @@ class TestCheckNginxRunning:
patch("subprocess.run", return_value=mock_result),
patch.object(Path, "is_file", return_value=False),
):
- passed, msg = _check_nginx_running()
+ passed, _ = _check_nginx_running()
assert passed is False
def test_via_pid_file(self):
@@ -131,27 +131,27 @@ class TestCheckNginxRunning:
def run_side_effect(cmd, **kwargs):
raise FileNotFoundError()
- pid_file = Path("/var/run/nginx.pid")
- with patch("subprocess.run", side_effect=run_side_effect):
- with patch.object(Path, "is_file") as mock_is_file:
- with patch.object(Path, "read_text", return_value="1234\n"):
+ with (
+ patch("subprocess.run", side_effect=run_side_effect),
+ patch.object(Path, "read_text", return_value="1234\n"),
+ ):
- def fake_is_file(self):
- if self == Path("/var/run/nginx.pid"):
- return True
- if str(self) == "/proc/1234/status":
- return True
- return Path(self).is_file()
+ def fake_is_file(self):
+ if self == Path("/var/run/nginx.pid"):
+ return True
+ if str(self) == "/proc/1234/status":
+ return True
+ return Path(self).is_file()
- with patch.object(Path, "is_file", fake_is_file):
- passed, msg = _check_nginx_running()
- assert passed is True
+ with patch.object(Path, "is_file", fake_is_file):
+ passed, _ = _check_nginx_running()
+ assert passed is True
class TestCheckNginxConfig:
def test_valid_config(self):
with patch("lib.nginx.test_config", return_value=(True, "test passed")):
- passed, msg = _check_nginx_config()
+ passed, _ = _check_nginx_config()
assert passed is True
def test_invalid_config(self):
@@ -175,7 +175,7 @@ class TestCheckFirewallPort80:
with (
patch("lib.common.run_proc", side_effect=proc_side_effect),
):
- passed, msg = _check_firewall_port_80()
+ passed, _ = _check_firewall_port_80()
assert passed is True
def test_blocked_by_firewall(self):
@@ -213,7 +213,7 @@ class TestCheckAcmeHomeWritable:
acme_dir = tmp_path / "acme"
acme_dir.mkdir()
with patch("daemon.handlers.acme._ACME_HOME", acme_dir):
- passed, msg = _check_acme_home_writable()
+ passed, _ = _check_acme_home_writable()
assert passed is True
@@ -233,7 +233,7 @@ class TestCheckAcmeHomeWritable_Permissions:
acme_dir.chmod(0o444)
try:
with patch("daemon.handlers.acme._ACME_HOME", acme_dir):
- passed, msg = _check_acme_home_writable()
+ passed, _ = _check_acme_home_writable()
assert passed is False
finally:
acme_dir.chmod(0o755)
@@ -254,7 +254,7 @@ class TestCheckOpensslAvailable:
def test_not_found(self):
with patch("shutil.which", return_value=None):
- passed, msg = _check_openssl_available()
+ passed, _ = _check_openssl_available()
assert passed is False
@@ -268,7 +268,7 @@ class TestCheckPort80Listening:
mock_sock.__exit__ = MagicMock(return_value=False)
with patch("socket.socket", return_value=mock_sock):
- passed, msg = _check_port_80_listening()
+ passed, _ = _check_port_80_listening()
assert passed is True
def test_not_listening(self):
@@ -280,7 +280,7 @@ class TestCheckPort80Listening:
mock_sock.__exit__ = MagicMock(return_value=False)
with patch("socket.socket", return_value=mock_sock):
- passed, msg = _check_port_80_listening()
+ passed, _ = _check_port_80_listening()
assert passed is False
@@ -301,7 +301,7 @@ class TestCheckAcmeAccount:
),
patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="ok")),
):
- passed, msg = _check_acme_account()
+ passed, _ = _check_acme_account()
assert passed is True
def test_via_account_conf(self, tmp_path):
@@ -320,7 +320,7 @@ class TestCheckAcmeAccount:
patch("daemon.handlers.acme._ACME_HOME", acme_dir),
patch("subprocess.run", side_effect=run_side_effect),
):
- passed, msg = _check_acme_account()
+ passed, _ = _check_acme_account()
assert passed is True
def test_not_configured(self, tmp_path):
@@ -336,7 +336,7 @@ class TestCheckAcmeAccount:
patch("daemon.handlers.acme._ACME_HOME", acme_dir),
patch("subprocess.run", side_effect=run_side_effect),
):
- passed, msg = _check_acme_account()
+ passed, _ = _check_acme_account()
assert passed is False
@@ -351,7 +351,7 @@ class TestCheckDnsPublic:
patch("socket.gethostbyname", return_value="192.168.1.1"),
patch("subprocess.run", return_value=mock_result),
):
- passed, msg = _check_dns_public("example.com")
+ passed, _ = _check_dns_public("example.com")
assert passed is True
def test_does_not_resolve(self):
@@ -362,7 +362,7 @@ class TestCheckDnsPublic:
patch("socket.gethostbyname", return_value="192.168.1.1"),
patch("subprocess.run", return_value=mock_result),
):
- passed, msg = _check_dns_public("example.com")
+ passed, _ = _check_dns_public("example.com")
assert passed is False
@@ -502,7 +502,7 @@ class TestValidate:
result = _validate("example.com")
assert result["ready"] is False
- nginx_check = [c for c in result["checks"] if c["name"] == "nginx_running"][0]
+ nginx_check = next(c for c in result["checks"] if c["name"] == "nginx_running")
assert nginx_check["passed"] is False
assert nginx_check["blocking"] is True
@@ -566,7 +566,7 @@ class TestValidate:
result = _validate("example.com")
assert result["ready"] is True
- dns_pub = [c for c in result["checks"] if c["name"] == "dns_public"][0]
+ dns_pub = next(c for c in result["checks"] if c["name"] == "dns_public")
assert dns_pub["passed"] is False
assert dns_pub["blocking"] is False
diff --git a/tests/test_polling.py b/tests/test_polling.py
new file mode 100644
index 0000000..a1a79ce
--- /dev/null
+++ b/tests/test_polling.py
@@ -0,0 +1,64 @@
+"""Tests for daemon/server.py polling functions."""
+
+import asyncio
+import json
+from unittest.mock import AsyncMock, patch
+
+
+class TestPollIntervals:
+ def test_default_intervals_loaded(self):
+ from daemon.server import _POLL_INTERVALS
+
+ assert "firewall" in _POLL_INTERVALS
+ assert _POLL_INTERVALS["firewall"] == 30
+ assert _POLL_INTERVALS["wireguard"] == 10
+ assert _POLL_INTERVALS["dnsmasq"] == 10
+ assert _POLL_INTERVALS["networkd"] == 10
+
+ def test_env_override(self):
+ """VACUUM_WALL_POLL_INTERVALS env var can override values."""
+ import importlib
+
+ with patch.dict(
+ "os.environ", {"VACUUM_WALL_POLL_INTERVALS": "firewall:60,wireguard:5"}
+ ):
+ import daemon.server
+
+ importlib.reload(daemon.server)
+ assert daemon.server._POLL_INTERVALS["firewall"] == 60
+ assert daemon.server._POLL_INTERVALS["wireguard"] == 5
+ assert daemon.server._POLL_INTERVALS["dnsmasq"] == 10
+ importlib.reload(daemon.server)
+
+
+class TestBroadcastTick:
+ def test_sends_tick_message(self):
+ from daemon.server import _ws_subscribers, broadcast_tick
+
+ mock_ws = AsyncMock()
+ mock_ws.send_str = AsyncMock()
+ _ws_subscribers.add(mock_ws)
+ try:
+ asyncio.run(broadcast_tick(["firewall", "wireguard"]))
+ mock_ws.send_str.assert_called_once()
+ sent = json.loads(mock_ws.send_str.call_args[0][0])
+ assert sent["type"] == "tick"
+ assert sent["subsystems"] == ["firewall", "wireguard"]
+ finally:
+ _ws_subscribers.discard(mock_ws)
+
+ def test_prunes_dead_subscribers(self):
+ from daemon.server import _ws_subscribers, broadcast_tick
+
+ mock_ws = AsyncMock()
+ mock_ws.send_str = AsyncMock(side_effect=Exception("broken"))
+ _ws_subscribers.add(mock_ws)
+ asyncio.run(broadcast_tick(["firewall"]))
+ assert mock_ws not in _ws_subscribers
+
+
+class TestPollTasks:
+ def test_poll_tasks_is_a_set(self):
+ from daemon.server import _poll_tasks
+
+ assert isinstance(_poll_tasks, set)
diff --git a/tests/test_state.py b/tests/test_state.py
index b4bf52d..8d7fd3b 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -175,3 +175,236 @@ class TestStateVersions:
updated = s.get_updated_versions()
assert updated["firewall"] == 2
assert updated["dnsmasq"] == 1
+
+
+class TestStripVolatile:
+ def test_list_of_dicts_strips_nested_keys(self):
+ """Wireguard-style: status.peers[].transfer_received gets stripped."""
+ from lib.state import _strip_volatile
+
+ data = {
+ "status": {
+ "peers": [
+ {
+ "transfer_received": "100B",
+ "transfer_sent": "200B",
+ "latest_handshake": "ago",
+ "public_key": "abc",
+ },
+ {
+ "transfer_received": "300B",
+ "transfer_sent": "400B",
+ "latest_handshake": "now",
+ "public_key": "def",
+ },
+ ]
+ }
+ }
+ vol = frozenset(
+ {"status.peers[].transfer_received", "status.peers[].latest_handshake"}
+ )
+ stripped = _strip_volatile(data, vol)
+ assert stripped["status"]["peers"][0]["transfer_received"] is None
+ assert stripped["status"]["peers"][0]["latest_handshake"] is None
+ assert stripped["status"]["peers"][0]["transfer_sent"] == "200B"
+ assert stripped["status"]["peers"][0]["public_key"] == "abc"
+ assert stripped["status"]["peers"][1]["transfer_received"] is None
+
+ def test_scalar_path_strips(self):
+ """Scalar paths get zeroed to None."""
+ from lib.state import _strip_volatile
+
+ data = {"a": {"b": 1, "c": 2}}
+ vol = frozenset({"a.b"})
+ stripped = _strip_volatile(data, vol)
+ assert stripped["a"]["b"] is None
+ assert stripped["a"]["c"] == 2
+
+ def test_empty_volatile_returns_copy(self):
+ from lib.state import _strip_volatile
+
+ data = {"x": 1}
+ stripped = _strip_volatile(data, frozenset())
+ assert stripped == data
+ assert stripped is not data
+
+ def test_firewall_volatile_strips_ips(self):
+ """Firewall-style: interfaces[].ips gets stripped."""
+ from lib.state import _strip_volatile
+
+ data = {
+ "interfaces": [
+ {
+ "name": "eth0",
+ "ips": ["10.0.0.1/24"],
+ "ipv6": ["fe80::1"],
+ "zone": "internal",
+ },
+ ]
+ }
+ vol = frozenset({"interfaces[].ips", "interfaces[].ipv6"})
+ stripped = _strip_volatile(data, vol)
+ assert stripped["interfaces"][0]["ips"] is None
+ assert stripped["interfaces"][0]["ipv6"] is None
+ assert stripped["interfaces"][0]["name"] == "eth0"
+ assert stripped["interfaces"][0]["zone"] == "internal"
+
+ def test_networkd_volatile_strips_addresses(self):
+ """Networkd-style: interfaces dict keyed by name, addresses stripped via fallback."""
+ from lib.state import _strip_volatile
+
+ data = {
+ "interfaces": {
+ "eth0": {
+ "addresses": ["10.0.0.1/24", "fe80::1"],
+ "state": "routable",
+ "type": "ether",
+ },
+ "lo": {
+ "addresses": ["127.0.0.1/8"],
+ "state": "degraded",
+ "type": "loopback",
+ },
+ }
+ }
+ vol = frozenset({"interfaces[].addresses"})
+ stripped = _strip_volatile(data, vol)
+ assert stripped["interfaces"]["eth0"]["addresses"] is None
+ assert stripped["interfaces"]["eth0"]["state"] == "routable"
+ assert stripped["interfaces"]["eth0"]["type"] == "ether"
+ assert stripped["interfaces"]["lo"]["addresses"] is None
+ assert stripped["interfaces"]["lo"]["state"] == "degraded"
+
+
+class TestDiffLayers:
+ def test_no_change(self):
+ """Identical data (minus timestamp) returns (False, False)."""
+ from lib.state import _diff_layers
+
+ old = {"zones": {"public": {}}, "timestamp": "t1"}
+ new = {"zones": {"public": {}}, "timestamp": "t2"}
+ structural, volatile = _diff_layers(old, new, frozenset())
+ assert structural is False
+ assert volatile is False
+
+ def test_structural_only(self):
+ """Non-volatile change detected as structural."""
+ from lib.state import _diff_layers
+
+ old = {
+ "zones": {"public": {}},
+ "interfaces": [{"ips": None}],
+ "timestamp": "t1",
+ }
+ new = {
+ "zones": {"internal": {}},
+ "interfaces": [{"ips": None}],
+ "timestamp": "t2",
+ }
+ vol = frozenset({"interfaces[].ips"})
+ structural, volatile = _diff_layers(old, new, vol)
+ assert structural is True
+ assert volatile is False
+
+ def test_volatile_only(self):
+ """Only volatile fields changed returns (False, True)."""
+ from lib.state import _diff_layers
+
+ old = {
+ "status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
+ "timestamp": "t1",
+ }
+ new = {
+ "status": {"peers": [{"transfer_received": "200B", "public_key": "abc"}]},
+ "timestamp": "t2",
+ }
+ vol = frozenset({"status.peers[].transfer_received"})
+ structural, volatile = _diff_layers(old, new, vol)
+ assert structural is False
+ assert volatile is True
+
+ def test_neither(self):
+ """No change at all returns (False, False)."""
+ from lib.state import _diff_layers
+
+ old = {
+ "status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
+ "timestamp": "t1",
+ }
+ new = {
+ "status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
+ "timestamp": "t2",
+ }
+ vol = frozenset({"status.peers[].transfer_received"})
+ structural, volatile = _diff_layers(old, new, vol)
+ assert structural is False
+ assert volatile is False
+
+ def test_old_none_returns_both_true(self):
+ """When old is None (first poll), both layers report True."""
+ from lib.state import _diff_layers
+
+ new = {"zones": {}}
+ structural, volatile = _diff_layers(None, new, frozenset())
+ assert structural is True
+ assert volatile is True
+
+
+class TestPoll:
+ def test_poll_no_change(self):
+ """poll() returns (False, False) when collector returns same data."""
+ import uuid
+
+ from lib.state import _COLLECTORS, State
+
+ name = f"test_{uuid.uuid4().hex}"
+ s = State()
+ data = {"value": 1, "timestamp": "t1"}
+ _COLLECTORS[name] = lambda: data
+ s.set(name, data)
+ structural, volatile = s.poll(name)
+ assert structural is False
+ assert volatile is False
+ del _COLLECTORS[name]
+
+ def test_poll_detects_structural(self):
+ """poll() detects structural changes via collector."""
+ import uuid
+
+ from lib.state import _COLLECTORS, _VOLATILE, State
+
+ name = f"test_{uuid.uuid4().hex}"
+ s = State()
+ old = {
+ "zones": {"public": {}},
+ "interfaces": [{"ips": None}],
+ "timestamp": "t1",
+ }
+ new = {
+ "zones": {"internal": {}},
+ "interfaces": [{"ips": None}],
+ "timestamp": "t2",
+ }
+ _COLLECTORS[name] = lambda: new
+ _VOLATILE[name] = frozenset({"interfaces[].ips"})
+ s.set(name, old)
+ structural, _volatile = s.poll(name)
+ assert structural is True
+ del _COLLECTORS[name]
+ del _VOLATILE[name]
+
+ def test_poll_failure_returns_no_broadcast(self):
+ """poll() returns (False, False) and preserves existing data on collector failure."""
+ import uuid
+
+ from lib.state import _COLLECTORS, State
+
+ name = f"test_{uuid.uuid4().hex}"
+ s = State()
+ s.set(name, {"value": 1})
+ _COLLECTORS[name] = lambda: (_ for _ in ()).throw(RuntimeError("fail"))
+ structural, volatile = s.poll(name)
+ assert structural is False
+ assert volatile is False
+ assert s.get(name) == {"value": 1}
+ del _COLLECTORS[name]
diff --git a/vendor/htm.js b/vendor/htm.js
new file mode 100644
index 0000000..485fff3
--- /dev/null
+++ b/vendor/htm.js
@@ -0,0 +1,4 @@
+// htm mini (no caching) — https://github.com/developit/htm
+// Vendored from: htm@3.1.1/mini/index.module.js
+// License: Apache-2.0
+export default function(n){for(var l,e,s=arguments,t=1,r="",u="",a=[0],c=function(n){1===t&&(n||(r=r.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?a.push(n?s[n]:r):3===t&&(n||r)?(a[1]=n?s[n]:r,t=2):2===t&&"..."===r&&n?a[2]=Object.assign(a[2]||{},s[n]):2===t&&r&&!n?(a[2]=a[2]||{})[r]=!0:t>=5&&(5===t?((a[2]=a[2]||{})[e]=n?r?r+s[n]:s[n]:r,t=6):(n||r)&&(a[2][e]+=n?r+s[n]:r)),r=""},h=0;h"===l?(t=1,r=""):r=l+r[0]:u?l===u?u="":r+=l:'"'===l||"'"===l?u=l:">"===l?(c(),t=1):t&&("="===l?(t=5,e=r,r=""):"/"===l&&(t<5||">"===n[h][i+1])?(c(),3===t&&(a=a[0]),t=a,(a=a[0]).push(this.apply(null,t.slice(1))),t=0):" "===l||"\t"===l||"\n"===l||"\r"===l?(c(),t=2):r+=l),3===t&&"!--"===r&&(t=4,a=a[0])}return c(),a.length>2?a.slice(1):a[1]}
diff --git a/webui/static/hoover/components/modal.js b/webui/static/hoover/components/modal.js
index 8d9c065..4fa9b8c 100644
--- a/webui/static/hoover/components/modal.js
+++ b/webui/static/hoover/components/modal.js
@@ -9,6 +9,20 @@
import { esc } from '../helpers.js?v=7';
import { att_esc } from '../helpers.js?v=7';
import { apiSubmit } from '../api.js?v=7';
+import { createDom } from '../vdom.js?v=7';
+
+/**
+ * Render Hoover VNodes into a modal content element.
+ * VDOM is not diffed across modal re-render — modals are transient and
+ * innerHTML is cleared/repainted each time (avoids lifecycle baggage).
+ */
+export function modalVNodes(inner, vnodes) {
+ inner.innerHTML = '';
+ const nodes = Array.isArray(vnodes) ? vnodes : [vnodes];
+ for (const vnode of nodes) {
+ if (vnode) inner.appendChild(createDom(vnode));
+ }
+}
const _modalQueue = [];
@@ -35,10 +49,15 @@ function _renderModals() {
/**
* Open a modal dialog.
*
- * @param {function} renderFn – (contentEl, idx) => void, renders into contentEl
+ * @param {function|object} content – Either:
+ * - renderFn(contentEl, idx) => void (legacy innerHTML path)
+ * - VNode / VNode[] (new VDOM path — uses modalVNodes)
*/
-export function openModal(renderFn) {
- _modalQueue.push({ renderFn, id: _modalQueue.length });
+export function openModal(content) {
+ const entry = typeof content === 'function'
+ ? { renderFn: content, id: _modalQueue.length }
+ : { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) };
+ _modalQueue.push(entry);
_renderModals();
}
@@ -61,6 +80,11 @@ export function closeAllModals() {
_renderModals();
}
+/** Re-render all open modals. Used by long-lived modals that update in place. */
+export function refreshModals() {
+ _renderModals();
+}
+
/**
* Render a standard modal layout: title, form fields, action buttons.
*
diff --git a/webui/static/hoover/html.js b/webui/static/hoover/html.js
new file mode 100644
index 0000000..bc52d91
--- /dev/null
+++ b/webui/static/hoover/html.js
@@ -0,0 +1,4 @@
+import htm from '../../vendor/htm.js';
+import { htmAdapter } from './vdom.js?v=7';
+
+export const html = htm.bind(htmAdapter);
diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js
index 45a55da..e24f2b9 100644
--- a/webui/static/hoover/index.js
+++ b/webui/static/hoover/index.js
@@ -10,6 +10,9 @@ export { reactive, requestUpdate } from './reactivity.js?v=7';
/* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js?v=7';
+/* ── HTM ──────────────────────────────────────────────────────── */
+export { html } from './html.js?v=7';
+
/* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js?v=7';
@@ -38,7 +41,7 @@ export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGr
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=7';
/* ── UI Components: Modal ────────────────────────────────────── */
-export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
+export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=7';
diff --git a/webui/static/hoover/vdom.js b/webui/static/hoover/vdom.js
index 4c625f8..7d52cae 100644
--- a/webui/static/hoover/vdom.js
+++ b/webui/static/hoover/vdom.js
@@ -21,6 +21,29 @@ export const _unmountFn = { fn: null };
export function setMountFn(fn) { _mountFn.fn = fn; }
export function setUnmountFn(fn) { _unmountFn.fn = fn; }
+/**
+ * htm event adapter: translates camelCase events (onClick) to Hoover's on:click.
+ */
+export function htmAdapter(tag, props, ...children) {
+ if (tag === '#text' || tag === '#comp' || typeof tag === 'function') {
+ return h(tag, props, ...children);
+ }
+
+ if (props) {
+ const normalized = {};
+ for (const [key, val] of Object.entries(props)) {
+ if (key.startsWith('on') && key.length > 2 && key[2] >= 'A' && key[2] <= 'Z') {
+ normalized['on:' + key.slice(2).toLowerCase()] = val;
+ } else {
+ normalized[key] = val;
+ }
+ }
+ props = normalized;
+ }
+
+ return h(tag, props, ...children);
+}
+
/**
* Build a VNode. Three forms:
* h('div', { class: 'x' }, h('span', null, 'hi')) — element
diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js
index 113bc0b..aa6cd6f 100644
--- a/webui/static/hoover/websocket.js
+++ b/webui/static/hoover/websocket.js
@@ -57,14 +57,15 @@ function _wsConnect() {
*
* Expected message shapes:
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
+ * { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
* { type: 'notify', topic: 'firewall' }
* { type: 'status', topic: 'firewall', … }
*/
function handleMessage(msg) {
const topics = [];
- if (msg.type === 'versions' || msg.type === 'refresh') {
- topics.push(...(msg.updated || msg.topics || []));
+ if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
+ topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
} else if (msg.type === 'notify') {
topics.push(msg.topic);
} else if (msg.type === 'status') {
diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js
index a4e79a5..aa46c28 100644
--- a/webui/static/pages/certs.js
+++ b/webui/static/pages/certs.js
@@ -1,40 +1,29 @@
-import { h, PageHeader, Empty, Table, Card, Badge, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
-const _issueState = { domain: '', modalIdx: -1, account: null, validating: false };
+import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
function _accountCard(account) {
if (!account || !account.registered) {
- return h('div', { class: 'card' },
- h('div', { class: 'card-header' },
- [
- h('span', null, 'ACME Account'),
- h('button', {
- class: 'btn btn-sm btn-primary',
- style: 'margin-left:auto;',
- 'on:click': () => registerAccountModal(),
- }, 'Register Account'),
- ]
- ),
- h('div', { class: 'card-body' },
- h('div', { class: 'text-muted text-sm' }, 'Not registered'),
- ),
- );
+ return html`