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} ... >` 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`
+
+ ACME Account + +
+
+
Not registered
+
+
`; } - return h('div', { class: 'card' }, - h('div', { class: 'card-header' }, - [ - h('span', null, 'ACME Account'), - h('button', { - class: 'btn btn-sm btn-outline', - style: 'margin-left:auto;', - 'on:click': () => settingsModal(account), - }, '\u2699'), - ] - ), - h('div', { class: 'card-body' }, - h('div', null, ['Registered as ', h('strong', null, esc(account.email))]), - h('div', { class: 'text-sm text-muted' }, ['CA: ', esc(account.ca)]), - ), - ); + return html`
+
+ ACME Account + +
+
+
Registered as ${esc(account.email)}
+
CA: ${esc(account.ca)}
+
+
`; } function registerAccountModal() { @@ -78,25 +67,31 @@ function registerAccountModal() { } function settingsModal(account) { - openModal((inner) => { - inner.innerHTML = '' - + ''; + openModal((inner, idx) => { + modalVNodes(inner, html`
+ + + +
`); inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => { - closeModal(); + closeModal(idx); }); inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => { @@ -108,7 +103,7 @@ function settingsModal(account) { }); if (resp.ok) { toast('Email updated', 'success'); - closeModal(); + closeModal(idx); modelFetch('acme'); } else { toast(resp.error || 'Failed', 'error'); @@ -120,7 +115,7 @@ function settingsModal(account) { const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); if (resp.ok) { toast('Account deactivated', 'success'); - closeModal(); + closeModal(idx); modelFetch('acme'); } else { toast(resp.error || 'Failed', 'error'); @@ -129,142 +124,129 @@ function settingsModal(account) { }); } -function issueCertModal(state) { - _issueState.domain = ''; - _issueState.modalIdx = -1; - _issueState.account = null; - _issueState.validating = false; +function createIssueState() { + return { + step: 'init', + domain: '', + account: null, + validating: false, + checks: [], + ready: false, + }; +} +let _currentIssueState = null; + +function issueCertModal(state) { + _currentIssueState = createIssueState(); (async () => { const accountResp = await apiFetch('/api/certs/account'); - _issueState.account = accountResp.ok ? accountResp.data : null; - _renderIssueModal(state); + _currentIssueState.account = accountResp.ok ? accountResp.data : null; + openModal((inner, modalIdx) => { + modalVNodes(inner, _renderIssueContent()); + _bindIssueButtons(inner, modalIdx); + }); })(); } -function _renderIssueModal(state) { - const account = _issueState.account; +function _renderIssueContent() { + const s = _currentIssueState; + const account = s.account; const registered = account && account.registered; const accountBadge = registered ? esc(account.email) + ' (' + esc(account.ca) + ')' : 'No account registered'; - openModal((inner) => { - inner.innerHTML = '' - + ''; - - _issueState.modalIdx = document.querySelectorAll('#modal-root > div').length - 1; - - inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { - closeModal(_issueState.modalIdx); + if (s.step === 'results') { + const resultsVNodes = s.checks.map(c => { + let cls = 'text-success', icon = '\u2713'; + if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; } + else if (!c.passed) { cls = 'text-warning'; icon = '\u26A0'; } + return html`
${icon} ${esc(c.name)}: ${esc(c.message)}
`; }); - inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => { - closeModal(_issueState.modalIdx); - registerAccountModal(); - }); + return html`
+ + + +
`; + } - if (!registered) return; - - inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { - if (_issueState.validating) return; - const domain = ($val('ic-domain') || '').trim(); - if (!domain) { toast('Domain is required', 'error'); return; } - _issueState.validating = true; - _issueState.domain = domain; - try { - const resp = await apiFetch('/api/certs/validate', { - method: 'POST', - body: { domain }, - }); - if (!resp.ok) { - toast(resp.error || 'Validation failed', 'error'); - return; - } - _showValidate(inner, domain, resp.data.checks, resp.data.ready, state); - } finally { - _issueState.validating = false; - } - }); - }); + return html`
+ + + +
`; } -function _showValidate(inner, domain, checks, ready, state) { - const resultsHtml = checks.map(c => { - let cls = 'text-success'; - let icon = '\u2713'; - if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; } - else if (!c.passed && !c.blocking) { cls = 'text-warning'; icon = '\u26A0'; } - return '
' + icon + ' ' + esc(c.name) + '' - + ': ' + '' + esc(c.message) + '
'; - }).join(''); - - inner.innerHTML = '' - + ''; - +function _bindIssueButtons(inner, modalIdx) { inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { - closeModal(_issueState.modalIdx); + closeModal(modalIdx); + }); + + inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => { + closeModal(modalIdx); + registerAccountModal(); }); inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { - if (_issueState.validating) return; - const domain2 = ($val('ic-domain') || '').trim(); - if (!domain2) { toast('Domain is required', 'error'); return; } - _issueState.validating = true; - _issueState.domain = domain2; + if (_currentIssueState.validating) return; + const s = _currentIssueState; + const domain = ($val('ic-domain') || '').trim(); + if (!domain) { toast('Domain is required', 'error'); return; } + s.validating = true; + s.domain = domain; try { - const resp2 = await apiFetch('/api/certs/validate', { - method: 'POST', - body: { domain: domain2 }, - }); - if (!resp2.ok) { toast(resp2.error || 'Validation failed', 'error'); return; } - _showValidate(inner, domain2, resp2.data.checks, resp2.data.ready, state); + const resp = await apiFetch('/api/certs/validate', { method: 'POST', body: { domain } }); + if (!resp.ok) { toast(resp.error || 'Validation failed', 'error'); return; } + s.step = 'results'; + s.checks = resp.data.checks; + s.ready = resp.data.ready; + refreshModals(); } finally { - _issueState.validating = false; + s.validating = false; } }); inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => { - const body = { domain: _issueState.domain }; - const issueResp = await apiFetch('/api/certs/issue/start', { - method: 'POST', - body, - }); + const body = { domain: _currentIssueState.domain }; + const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body }); if (issueResp.ok) { - toast('Issuance started for ' + _issueState.domain, 'success'); - closeModal(_issueState.modalIdx); + toast('Issuance started for ' + _currentIssueState.domain, 'success'); + closeModal(modalIdx); const rid = issueResp.data?.request_id; - if (rid) pollCertIssue(rid, state); + if (rid) pollCertIssue(rid); } else { toast(issueResp.error || 'Failed', 'error'); } }); } -async function pollCertIssue(rid, state) { +async function pollCertIssue(rid) { poll({ url: '/api/certs/issue/' + enc(rid), successKey: (d) => d.status === 'completed', @@ -293,32 +275,31 @@ export default definePage({ const rows = (state.acme.data?.certs || []).map(c => { const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining }); - return h('tr', { key: c.domain }, - h('td', null, h('strong', null, esc(c.domain || 'unknown'))), - h('td', { class: 'text-sm' }, esc(c.issuer || '-')), - h('td', null, esc(c.expiry || 'N/A')), - h('td', null, badge), - ActionCell({ - editLabel: 'Renew', - editClick: async () => { + return html` + ${esc(c.domain || 'unknown')} + ${esc(c.issuer || '-')} + ${esc(c.expiry || 'N/A')} + ${badge} + <${ActionCell} + editLabel="Renew" + editClick=${async () => { const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' }); if (resp.ok) toast('Renewal started for ' + c.domain, 'success'); else toast(resp.error || 'Failed', 'error'); - }, - removeUrl: '/api/certs/' + enc(c.domain), - removeMessage: 'Remove certificate for ' + c.domain + '?', - removeSuccess: 'Certificate removed', - removeRefresh: 'acme', - }), - ); + }} + removeUrl=${'/api/certs/' + enc(c.domain)} + removeMessage=${'Remove certificate for ' + c.domain + '?'} + removeSuccess="Certificate removed" + removeRefresh="acme" /> + `; }); return [ PageHeader({ title: 'Certificates', subtitle: 'ACME certificate management', - actions: h('button', { class: 'btn btn-primary', - 'on:click': () => issueCertModal(state) }, 'Issue Certificate'), + actions: html``, }), _accountCard(account), rows.length diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index 8651763..00c2893 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -1,4 +1,4 @@ -import { h, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=7'; export default definePage({ init() { @@ -21,41 +21,33 @@ export default definePage({ const dmsk = d.dnsmasq?.status || {}; const wP = (d.wg || {}).peers || []; + const stats = html`
+ <${StatCard} label="Active Zones" value=${Object.keys(fwZones).length} + meta=${Object.keys(fwZones).join(', ') || 'None'} /> + <${StatCard} label="Interfaces Up" value=${upC + '/' + nCount} + meta=${upI.map(i => i.name).join(', ') || 'None up'} /> + <${StatCard} label="WireGuard" value=${String(d.wg?.state || 'unknown')} + meta=${wP.length + ' peers'} /> + <${StatCard} label="Certificates" value=${certs.length} + meta=${certW.length + ' expiring/expired'} /> +
`; + + const services = html`
+
+
Services
+
+
    +
  • <${ServiceStatus} state=${dmsk.state || 'down'} label="Dnsmasq" />
  • +
  • <${ServiceStatus} state=${d.wg?.state || 'down'} label="WireGuard" />
  • +
+
+
+
`; + return [ PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), - h('div', { class: 'grid grid-4' }, - StatCard({ - label: 'Active Zones', - value: Object.keys(fwZones).length, - meta: Object.keys(fwZones).join(', ') || 'None', - }), - StatCard({ - label: 'Interfaces Up', - value: upC + '/' + nCount, - meta: upI.map(i => i.name).join(', ') || 'None up', - }), - StatCard({ - label: 'WireGuard', - value: String(d.wg?.state || 'unknown'), - meta: wP.length + ' peers', - }), - StatCard({ - label: 'Certificates', - value: certs.length, - meta: certW.length + ' expiring/expired', - }), - ), - h('div', { class: 'grid grid-2' }, - h('div', { class: 'card' }, - h('div', { class: 'card-header' }, 'Services'), - h('div', { class: 'card-body' }, - h('ul', { class: 'service-list' }, - h('li', null, ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })), - h('li', null, ServiceStatus({ state: d.wg?.state || 'down', label: 'WireGuard' })), - ), - ), - ), - ), + stats, + services, ]; }, }); \ No newline at end of file diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 9055a7e..e64bd5b 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7'; const addRange = QuickModal({ title: 'Add DHCP Range', @@ -74,54 +74,51 @@ export default definePage({ const dnsRecords = cfg.dns_records || []; const status = state.dnsmasq.data?.status || {}; - const rangesRows = ranges.map((r) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end }, - h('td', null, r.interface || '(global)'), - h('td', null, esc(r.start)), - h('td', null, esc(r.end)), - h('td', null, esc(r.lease_time || '12h')), - h('td', null, - ConfirmDelete({ - url: '/api/dhcp/ranges', - message: 'Remove range ' + r.start + ' - ' + r.end + '?', - body: { interface: r.interface || '', start: r.start, end: r.end }, - success: 'Range removed', - refresh: 'dnsmasq', - }), - ), - )); + const rangesRows = ranges.map((r) => html` + ${r.interface || '(global)'} + ${esc(r.start)} + ${esc(r.end)} + ${esc(r.lease_time || '12h')} + + <${ConfirmDelete} + url="/api/dhcp/ranges" + message=${'Remove range ' + r.start + ' - ' + r.end + '?'} + body=${{ interface: r.interface || '', start: r.start, end: r.end }} + success="Range removed" + refresh="dnsmasq" /> + + `); - const leaseRows = staticLeases.map((l) => h('tr', { key: l.mac }, - h('td', null, esc(l.mac)), - h('td', null, esc(l.ip)), - h('td', null, l.hostname || '-'), - h('td', null, - ConfirmDelete({ - url: '/api/dhcp/static-lease/' + enc(l.mac), - message: 'Remove lease ' + l.mac + '?', - success: 'Lease removed', - refresh: 'dnsmasq', - }), - ), - )); + const leaseRows = staticLeases.map((l) => html` + ${esc(l.mac)} + ${esc(l.ip)} + ${l.hostname || '-'} + + <${ConfirmDelete} + url=${'/api/dhcp/static-lease/' + enc(l.mac)} + message=${'Remove lease ' + l.mac + '?'} + success="Lease removed" + refresh="dnsmasq" /> + + `); - const dnsRows = dnsRecords.map((rec) => h('tr', { key: rec.name }, - h('td', null, h('strong', null, esc(rec.name || 'unnamed'))), - h('td', { class: 'text-sm' }, esc(rec.address || '-')), - h('td', null, - ConfirmDelete({ - url: '/api/dhcp/dns-record/' + enc(rec.name || ''), - message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?', - success: 'Record removed', - refresh: 'dnsmasq', - }), - ), - )); + const dnsRows = dnsRecords.map((rec) => html` + ${esc(rec.name || 'unnamed')} + ${esc(rec.address || '-')} + + <${ConfirmDelete} + url=${'/api/dhcp/dns-record/' + enc(rec.name || '')} + message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'} + success="Record removed" + refresh="dnsmasq" /> + + `); const tabNames = ['ranges', 'leases', 'dns', 'active']; const actions = ActionGroup( - h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'), - h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'), - h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'), + html``, + html``, + html``, ActionButton({ url: '/api/dhcp/apply', successMsg: 'dnsmasq applied', @@ -130,6 +127,18 @@ export default definePage({ }), ); + const leaseTable = state.activeTab === 'active' + ? Table({ + columns: ['MAC', 'IP', 'Hostname', 'Expires'], + rows: (state.dnsmasq.data?.leases || []).map((l) => html` + ${esc(l.mac || '-')} + ${esc(l.ip || '-')} + ${esc(l.hostname || '-')} + ${esc(l.expires || '-')} + `), + emptyText: 'No active leases', + }) : null; + return [ PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }), ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }), @@ -140,13 +149,7 @@ export default definePage({ ? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null, state.activeTab === 'dns' ? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null, - state.activeTab === 'active' - ? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.dnsmasq.data?.leases || []).map((l) => h('tr', { key: l.mac || l.ip }, - h('td', null, esc(l.mac || '-')), - h('td', null, esc(l.ip || '-')), - h('td', null, esc(l.hostname || '-')), - h('td', null, esc(l.expires || '-')), - )), emptyText: 'No active leases' }) : null, + leaseTable, ]; }, -}); \ No newline at end of file +}); diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js index 887ccbf..a7c31e5 100644 --- a/webui/static/pages/interfaces.js +++ b/webui/static/pages/interfaces.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7'; async function changeZone(name, zone, state) { const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { @@ -54,8 +54,8 @@ export default definePage({ const ifaces = Object.entries(netData).map(([name, entry]) => { let zone = null; - for (const [zoneName, ifaces] of Object.entries(activeZones)) { - if ((ifaces || []).includes(name)) { + for (const [zoneName, zIfaces] of Object.entries(activeZones)) { + if ((zIfaces || []).includes(name)) { zone = zoneName; break; } @@ -70,26 +70,20 @@ export default definePage({ }; }); - const rows = ifaces.map(iface => { - return h('tr', { key: iface.name }, - h('td', null, h('strong', null, iface.name)), - h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')), - h('td', null, (iface.ips || []).join(', ') || 'N/A'), - h('td', null, StatusText({ status: iface.state })), - h('td', null, - ZoneSelect({ - zones, - value: iface.zone, - onChange: (z) => changeZone(iface.name, z, state), - }), - h('button', { - class: 'btn btn-sm btn-outline', - style: 'margin-left:8px', - 'on:click': () => cfgModalFn(iface), - }, 'Config'), - ), - ); - }); + const rows = ifaces.map(iface => + html` + ${iface.name} + ${String(iface.mac || 'N/A')} + ${(iface.ips || []).join(', ') || 'N/A'} + <${StatusText} status=${iface.state} /> + + <${ZoneSelect} zones=${zones} value=${iface.zone} + onChange=${(z) => changeZone(iface.name, z, state)} /> + + + ` + ); return [ PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }), @@ -100,4 +94,4 @@ export default definePage({ }), ]; }, -}); \ No newline at end of file +}); diff --git a/webui/static/pages/logs.js b/webui/static/pages/logs.js index ad55311..d3d9731 100644 --- a/webui/static/pages/logs.js +++ b/webui/static/pages/logs.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Tabs, esc, definePage, renderGuard, modelFetch, getModel } from '/static/hoover/index.js?v=7'; const logTabs = [ { key: 'journal', label: 'Journal' }, @@ -24,7 +24,7 @@ export default definePage({ const lines = logData.data || []; const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0]; const lineVnodes = lines.map((line, i) => - h('div', { class: 'log-line', key: i }, esc(line)) + html`
${esc(line)}
` ); const tabsBody = Tabs({ @@ -39,20 +39,17 @@ export default definePage({ return [ PageHeader({ title: 'Logs', subtitle: 'System and service logs' }), - h('div', { class: 'card', key: 'log-card' }, - tabsBody, - h('div', { class: 'card-header' }, - h('span', null, tab.label), - h('button', { - class: 'btn btn-sm btn-outline', - style: 'float:right;', - 'on:click': () => modelFetch('logs', state.activeTab), - }, '\u21BB'), - ), - h('div', { class: 'card-body log-body' }, - h('pre', null, lineVnodes), - ), - ), + html`
+ ${tabsBody} +
+ ${tab.label} + +
+
+
${lineVnodes}
+
+
`, ]; }, }); diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js index 7091e5b..9fcc4ac 100644 --- a/webui/static/pages/nat.js +++ b/webui/static/pages/nat.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7'; const addFwd = QuickModal({ title: 'Add Port Forward', @@ -47,37 +47,34 @@ export default definePage({ const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone)); const ifaceRows = (ifaces) => - ifaces.map((iface) => - h('tr', { key: 'ii-' + iface.name }, - h('td', null, - h('div', { class: 'd-flex align-items-center gap-2' }, - StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }), - h('strong', null, iface.name), - ), - ), - h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')), - h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')), - h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')), - h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })), - ) - ); + ifaces.map((iface) => html` + +
+ <${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} /> + ${iface.name} +
+ + ${(iface.ips || []).join(', ') || html``} + ${(iface.ipv6 || []).join(', ') || html``} + ${iface.mac || html``} + <${Badge} text=${iface.zone || '—'} variant="secondary" /> + `); const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => { const masq = !!zcfg.masquerade; - return h('tr', { key: 'm-' + zone }, - h('td', null, h('strong', null, zone)), - h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })), - h('td', null, - ActionButton({ - url: '/api/firewall/masquerade', - cls: 'btn btn-sm btn-outline', - labelOn: 'Disable', labelOff: 'Enable', condition: masq, - body: () => ({ zone, enable: !masq }), - successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone, - refresh: 'firewall', - }), - ), - ); + return html` + ${zone} + <${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /> + + <${ActionButton} + url="/api/firewall/masquerade" + cls="btn btn-sm btn-outline" + labelOn="Disable" labelOff="Enable" condition=${masq} + body=${() => ({ zone, enable: !masq })} + successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone} + refresh="firewall" /> + + `; }); const fwRows = []; @@ -86,21 +83,20 @@ export default definePage({ forwards.forEach((fwd, i) => { const port = fwd.port; const proto = fwd['proxy-protocol'] || fwd.proto; - fwRows.push(h('tr', { key: 'f-' + zone + '-' + i }, - h('td', null, h('strong', null, zone)), - h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })), - h('td', null, port), - h('td', null, fwd['to-addr'] || fwd.toaddr || '-'), - h('td', null, fwd['to-port'] || fwd.toport || '-'), - h('td', null, - ConfirmDelete({ - url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), - message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?', - success: 'Rule removed', - refresh: 'firewall', - }), - ), - )); + fwRows.push(html` + ${zone} + <${Badge} text=${proto || 'tcp'} variant="info" /> + ${port} + ${fwd['to-addr'] || fwd.toaddr || '-'} + ${fwd['to-port'] || fwd.toport || '-'} + + <${ConfirmDelete} + url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)} + message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'} + success="Rule removed" + refresh="firewall" /> + + `); }); }); @@ -127,9 +123,8 @@ export default definePage({ SectionTitle({ title: 'Port Forwarding' }), Card({ children: [ ActionGroup( - h('button', { class: 'btn btn-sm btn-primary', - 'on:click': () => addFwd({ zones: Object.keys(zoneData) }) - }, 'Add Forward'), + html``, ), Table({ columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'], @@ -140,4 +135,4 @@ export default definePage({ ]}), ]; }, -}); \ No newline at end of file +}); diff --git a/webui/static/pages/notfound.js b/webui/static/pages/notfound.js index 9c73293..1d43d46 100644 --- a/webui/static/pages/notfound.js +++ b/webui/static/pages/notfound.js @@ -1,4 +1,4 @@ -import { h, PageHeader, definePage } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, definePage } from '/static/hoover/index.js?v=7'; export default definePage({ init() { @@ -7,9 +7,9 @@ export default definePage({ render(state) { return [ PageHeader({ title: '404' }), - h('div', { class: 'card' }, - h('div', { class: 'card-body text-muted' }, 'Page not found: ' + state.path), - ), + html`
+
Page not found: ${state.path}
+
`, ]; }, }); diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index 2ee291b..16f71af 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7'; const addDomain = QuickModal({ title: 'Add Proxy Domain', @@ -66,26 +66,24 @@ export default definePage({ expired: d.cert_status === 'expired', }); - return h('tr', { key: d.domain }, - h('td', null, h('strong', null, esc(d.domain))), - h('td', null, esc(d.backend_host || '-')), - h('td', null, d.backend_port || '-'), - h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })), - h('td', null, certBadge), - ActionCell({ - editLabel: 'Edit', - editClick: () => editDomain(d), - removeUrl: '/api/proxy/domains/' + enc(d.domain), - removeMessage: 'Remove proxy for ' + d.domain + '?', - removeSuccess: 'Domain removed', - removeRefresh: ['nginx', 'acme'], - removeLabel: 'Delete', - }), - ); + return html` + ${esc(d.domain)} + ${esc(d.backend_host || '-')} + ${d.backend_port || '-'} + <${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /> + ${certBadge} + <${ActionCell} + editLabel="Edit" editClick=${() => editDomain(d)} + removeUrl=${'/api/proxy/domains/' + enc(d.domain)} + removeMessage=${'Remove proxy for ' + d.domain + '?'} + removeSuccess="Domain removed" + removeRefresh={['nginx', 'acme']} + removeLabel="Delete" /> + `; }); const actions = ActionGroup( - h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'), + html``, ActionButton({ url: '/api/proxy/apply', successMsg: 'Nginx applied & reloaded', @@ -104,4 +102,4 @@ export default definePage({ : Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }), ]; }, -}); \ No newline at end of file +}); diff --git a/webui/static/pages/rules.js b/webui/static/pages/rules.js index 11f0946..ef06928 100644 --- a/webui/static/pages/rules.js +++ b/webui/static/pages/rules.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Empty, Card, ConfirmDelete, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, MonoText, QuickModal } from '/static/hoover/index.js?v=7'; const addRule = QuickModal({ title: 'Add Rich Rule', @@ -35,27 +35,27 @@ export default definePage({ }); const cards = Object.entries(zoneRules).map(([zone, rules]) => { + const ruleRows = (Array.isArray(rules) ? rules : []).map((entry, i) => { + const ruleId = typeof entry === 'object' ? entry.id : null; + const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry); + return html` + ${i + 1} + <${MonoText} text=${ruleText} /> + + <${ConfirmDelete} + url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')} + message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'} + success="Rule removed" + refresh="firewall" /> + + `; + }); return Card({ header: 'Zone: ' + esc(zone), key: zone, children: [Table({ columns: ['#', 'Rule', 'Action'], - rows: (Array.isArray(rules) ? rules : []).map((entry, i) => { - const ruleId = typeof entry === 'object' ? entry.id : null; - const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry); - return h('tr', { key: i }, - h('td', { class: 'text-muted' }, i + 1), - h('td', { class: 'mono-text td-fullwidth' }, MonoText({ text: ruleText })), - h('td', null, - ConfirmDelete({ - url: '/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''), - message: 'Remove rule: ' + ruleText.substring(0, 40) + '...?', - success: 'Rule removed', - refresh: 'firewall', - }), - ), - ); - }), + rows: ruleRows, emptyText: 'No rules', wrapCard: false, })], @@ -66,10 +66,10 @@ export default definePage({ PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules', - actions: h('button', { class: 'btn btn-primary', - 'on:click': () => addRule({ zones }), }, 'Add Rule'), + actions: html``, }), ...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]), ]; }, -}); \ No newline at end of file +}); diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js index 8c85887..259f7c3 100644 --- a/webui/static/pages/wireguard.js +++ b/webui/static/pages/wireguard.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=7'; const addPeer = QuickModal({ title: 'Add WireGuard Peer', @@ -66,33 +66,30 @@ export default definePage({ const peerRows = (state.wireguard.data?.peers || []).map(p => { const hasHandshake = !!p.latest_handshake; - return h('tr', { key: p.name }, - h('td', null, - StatusDot({ status: hasHandshake ? 'success' : 'danger' }), - h('strong', null, esc(p.name || 'unnamed')), - ), - h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })), - h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')), - h('td', { class: 'text-sm' }, esc(p.endpoint || '-')), - h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')), - h('td', { class: 'text-sm' }, - 'Recv: ' + esc(p.transfer_recv || '0'), - h('br'), - 'Sent: ' + esc(p.transfer_sent || '0'), - ), - ActionCell({ - editLabel: 'Config', - editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state), - removeUrl: '/api/wireguard/peers/' + enc(p.name), - removeMessage: 'Remove peer ' + p.name + '?', - removeSuccess: 'Peer removed', - removeRefresh: 'wireguard', - }), - ); + return html` + + <${StatusDot} status=${hasHandshake ? 'success' : 'danger'} /> + ${esc(p.name || 'unnamed')} + + <${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /> + ${esc(p.allowed_ips || '-')} + ${esc(p.endpoint || '-')} + ${esc(p.latest_handshake || 'Never')} + + Recv: ${esc(p.transfer_recv || '0')}
+ Sent: ${esc(p.transfer_sent || '0')} + + <${ActionCell} + editLabel="Config" editClick=${() => downloadConfigModal(p.name, state.wireguard.data?.config, state)} + removeUrl=${'/api/wireguard/peers/' + enc(p.name)} + removeMessage=${'Remove peer ' + p.name + '?'} + removeSuccess="Peer removed" + removeRefresh="wireguard" /> + `; }); const actions = ActionGroup( - h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'), + html``, ActionButton({ url: '/api/wireguard/' + (isUp ? 'down' : 'up'), labelOn: 'Stop', labelOff: 'Start', condition: isUp, @@ -122,4 +119,4 @@ export default definePage({ : Empty({ text: 'No peers configured. Add a peer above.' }), ]; }, -}); \ No newline at end of file +}); diff --git a/webui/static/pages/zones.js b/webui/static/pages/zones.js index 5dfbf12..4ce033d 100644 --- a/webui/static/pages/zones.js +++ b/webui/static/pages/zones.js @@ -1,4 +1,4 @@ -import { h, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=7'; +import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js?v=7'; const addZone = QuickModal({ title: 'Add Zone', @@ -37,30 +37,30 @@ export default definePage({ const z = typeof zdata === 'object' ? zdata : {}; const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : []; const svcsArr = Array.isArray(z.services) ? z.services : []; - return h('div', { class: 'card', key: name, style: 'position:relative;' }, - h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' }, - h('div', null, - h('h3', { style: 'font-size:16px;color:var(--accent);' }, name), - h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' }, - z.target ? 'Target: ' + esc(z.target) : '', - ), - ), - ), - h('div', { class: 'text-sm mb-4' }, - h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'), - ifacesArr.length - ? ifacesArr.map(i => Badge({ text: esc(i) })) - : h('span', { class: 'text-muted' }, 'None'), - ), - h('div', { class: 'text-sm mb-4' }, - h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'), - svcsArr.length - ? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' })) - : h('span', { class: 'text-muted' }, 'None'), - ), - h('div', { style: 'display:flex;gap:6px;' }, - h('button', { class: 'btn btn-sm btn-outline', - 'on:click': () => MultiSelectModal({ + return html`
+
+
+

${name}

+
+ ${z.target ? 'Target: ' + esc(z.target) : ''} +
+
+
+
+
Interfaces
+ ${ifacesArr.length + ? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`) + : html`None`} +
+
+
Services
+ ${svcsArr.length + ? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`) + : html`None`} +
+
+ + + <${ConfirmDelete} + url=${'/api/firewall/zones/' + enc(name)} + message=${'Delete zone ' + name + '?'} + success=${'Zone ' + name + ' deleted'} + refresh="firewall" + label="Delete" /> +
+
`; }); return [ PageHeader({ title: 'Zones', subtitle: 'Firewall zones', - actions: h('button', { class: 'btn btn-primary', - 'on:click': () => addZone(), }, 'Add Zone'), + actions: html``, }), zoneCards.length - ? h('div', { class: 'card-grid' }, ...zoneCards) + ? html`
${zoneCards}
` : Empty({ text: 'No zones configured. Add a zone to get started.' }), ]; }, -}); \ No newline at end of file +});