Add state management, WebSocket polling, html.js templating, and refactor pages

- lib/state.py: per-subsystem collectors with versioned state store
- daemon/server.py: state refresh on request, batch routing updates
- webui/static/hoover/html.js: new html tag template helper via htm.js
- webui/static/hoover/websocket.js: real-time state change notifications
- webui/static/hoover/vdom.js: VDOM improvements for keyed diff
- All frontend pages refactored to use html templates
- Add tests for state management and polling
- Update docs and AGENTS.md
This commit is contained in:
2026-06-23 21:11:45 +00:00
parent 5025dfaf30
commit 5ba0f31767
26 changed files with 1193 additions and 495 deletions
+2 -2
View File
@@ -50,8 +50,8 @@ Conventions:
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components). - 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. - 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. - 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. - `h()` builds VNodes; `html` tag (from htm) enables JSX-like templates; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
- Events use `on:` prefix (`on:click`, `on:submit`). `class` prop accepts object. - 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)`. - 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. - `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. - No build step — ES modules served raw. Assets versioned via `?v=N` query string.
+73
View File
@@ -5,6 +5,7 @@ Handles routing, batching, and request/response lifecycle.
""" """
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
import os import os
@@ -16,6 +17,7 @@ from typing import Any
from aiohttp import web from aiohttp import web
from daemon.iface import PathLike from daemon.iface import PathLike
from lib.state import _DEFAULT_POLL_INTERVALS
from lib.state import state as state_store from lib.state import state as state_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +26,23 @@ PROJECT_DIR = Path(__file__).resolve().parent.parent
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock" SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
_WS_PORT = int(os.environ.get("VACUUM_WALLD_WS_PORT", "9091")) _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: class Handler:
"""Wrapper for a daemon handler function. """Wrapper for a daemon handler function.
@@ -324,6 +343,7 @@ def create_app() -> web.Application:
# WebSocket subscribers # WebSocket subscribers
_ws_subscribers: set[web.WebSocketResponse] = set() _ws_subscribers: set[web.WebSocketResponse] = set()
_ws_tasks: set[asyncio.Task[None]] = set() _ws_tasks: set[asyncio.Task[None]] = set()
_poll_tasks: set[asyncio.Task[None]] = set()
async def _handle_ws(request: web.Request) -> web.Response: 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)) 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: async def _health(_request: web.Request) -> web.Response:
"""Return the health check response. """Return the health check response.
@@ -458,6 +526,7 @@ def main() -> None:
def _on_shutdown(_sig: int) -> None: def _on_shutdown(_sig: int) -> None:
logger.info("Shutting down daemon...") logger.info("Shutting down daemon...")
_stop_polling()
loop.stop() loop.stop()
for sig in (signal.SIGTERM, signal.SIGINT): for sig in (signal.SIGTERM, signal.SIGINT):
@@ -475,6 +544,10 @@ def main() -> None:
# Populate state from system (blocking — OK at startup) # Populate state from system (blocking — OK at startup)
logger.info("Populating system state...") logger.info("Populating system state...")
state_store.populate() 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("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT) logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
+16
View File
@@ -1503,3 +1503,19 @@ GET /api/logs/app
Return recent application log entries as rendered HTML. Return recent application log entries as rendered HTML.
**Response:** HTML fragment of `<div class="log-line">` elements. **Response:** HTML fragment of `<div class="log-line">` 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.
+24
View File
@@ -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-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. | | networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.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. | | 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 ## Directory Structure
### Config — Declarative Settings ### Config — Declarative Settings
+53
View File
@@ -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. **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`<div class="card">
<h3 style="color:red">Title</h3>
<button onClick=${handler}>Click</button>
</div>`
```
**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`<tr key=${item.id}>
<td>${esc(item.name)}</td>
<td>${item.value}</td>
</tr>`
```
**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 `<Badge>...</Badge>` 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 ### Props
| Prop | Behavior | | Prop | Behavior |
+222
View File
@@ -28,6 +28,13 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
"firewall": 30,
"wireguard": 10,
"dnsmasq": 10,
"networkd": 10,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# State store # State store
@@ -148,6 +155,59 @@ class State:
""" """
return all(v is not None for v in self._data.values()) 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 # Singleton
state = State() state = State()
@@ -158,6 +218,7 @@ state = State()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_COLLECTORS: dict[str, Any] = {} _COLLECTORS: dict[str, Any] = {}
_VOLATILE: dict[str, frozenset[str]] = {}
def register_collector(subsystem: str, fn: Any) -> Any: def register_collector(subsystem: str, fn: Any) -> Any:
@@ -174,6 +235,135 @@ def register_collector(subsystem: str, fn: Any) -> Any:
return fn 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 # Firewall collector
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -307,6 +497,15 @@ def _collect_firewall() -> dict[str, Any]:
register_collector("firewall", _collect_firewall) 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) 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_collector("wireguard", _collect_wireguard)
register_volatile(
"wireguard",
frozenset(
{
"status.peers[].transfer_received",
"status.peers[].transfer_sent",
"status.peers[].latest_handshake",
}
),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Networkd collector # Networkd collector
@@ -889,8 +1099,20 @@ def _collect_networkd() -> dict[str, Any]:
register_collector("networkd", _collect_networkd) register_collector("networkd", _collect_networkd)
register_volatile(
"networkd",
frozenset(
{
"interfaces[].addresses",
}
),
)
__all__ = [ __all__ = [
"_DEFAULT_POLL_INTERVALS",
"State", "State",
"_diff_layers",
"_strip_volatile",
"register_volatile",
"state", "state",
] ]
+20 -20
View File
@@ -122,7 +122,7 @@ class TestCheckNginxRunning:
patch("subprocess.run", return_value=mock_result), patch("subprocess.run", return_value=mock_result),
patch.object(Path, "is_file", return_value=False), patch.object(Path, "is_file", return_value=False),
): ):
passed, msg = _check_nginx_running() passed, _ = _check_nginx_running()
assert passed is False assert passed is False
def test_via_pid_file(self): def test_via_pid_file(self):
@@ -131,10 +131,10 @@ class TestCheckNginxRunning:
def run_side_effect(cmd, **kwargs): def run_side_effect(cmd, **kwargs):
raise FileNotFoundError() raise FileNotFoundError()
pid_file = Path("/var/run/nginx.pid") with (
with patch("subprocess.run", side_effect=run_side_effect): patch("subprocess.run", side_effect=run_side_effect),
with patch.object(Path, "is_file") as mock_is_file: patch.object(Path, "read_text", return_value="1234\n"),
with patch.object(Path, "read_text", return_value="1234\n"): ):
def fake_is_file(self): def fake_is_file(self):
if self == Path("/var/run/nginx.pid"): if self == Path("/var/run/nginx.pid"):
@@ -144,14 +144,14 @@ class TestCheckNginxRunning:
return Path(self).is_file() return Path(self).is_file()
with patch.object(Path, "is_file", fake_is_file): with patch.object(Path, "is_file", fake_is_file):
passed, msg = _check_nginx_running() passed, _ = _check_nginx_running()
assert passed is True assert passed is True
class TestCheckNginxConfig: class TestCheckNginxConfig:
def test_valid_config(self): def test_valid_config(self):
with patch("lib.nginx.test_config", return_value=(True, "test passed")): with patch("lib.nginx.test_config", return_value=(True, "test passed")):
passed, msg = _check_nginx_config() passed, _ = _check_nginx_config()
assert passed is True assert passed is True
def test_invalid_config(self): def test_invalid_config(self):
@@ -175,7 +175,7 @@ class TestCheckFirewallPort80:
with ( with (
patch("lib.common.run_proc", side_effect=proc_side_effect), 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 assert passed is True
def test_blocked_by_firewall(self): def test_blocked_by_firewall(self):
@@ -213,7 +213,7 @@ class TestCheckAcmeHomeWritable:
acme_dir = tmp_path / "acme" acme_dir = tmp_path / "acme"
acme_dir.mkdir() acme_dir.mkdir()
with patch("daemon.handlers.acme._ACME_HOME", acme_dir): with patch("daemon.handlers.acme._ACME_HOME", acme_dir):
passed, msg = _check_acme_home_writable() passed, _ = _check_acme_home_writable()
assert passed is True assert passed is True
@@ -233,7 +233,7 @@ class TestCheckAcmeHomeWritable_Permissions:
acme_dir.chmod(0o444) acme_dir.chmod(0o444)
try: try:
with patch("daemon.handlers.acme._ACME_HOME", acme_dir): with patch("daemon.handlers.acme._ACME_HOME", acme_dir):
passed, msg = _check_acme_home_writable() passed, _ = _check_acme_home_writable()
assert passed is False assert passed is False
finally: finally:
acme_dir.chmod(0o755) acme_dir.chmod(0o755)
@@ -254,7 +254,7 @@ class TestCheckOpensslAvailable:
def test_not_found(self): def test_not_found(self):
with patch("shutil.which", return_value=None): with patch("shutil.which", return_value=None):
passed, msg = _check_openssl_available() passed, _ = _check_openssl_available()
assert passed is False assert passed is False
@@ -268,7 +268,7 @@ class TestCheckPort80Listening:
mock_sock.__exit__ = MagicMock(return_value=False) mock_sock.__exit__ = MagicMock(return_value=False)
with patch("socket.socket", return_value=mock_sock): with patch("socket.socket", return_value=mock_sock):
passed, msg = _check_port_80_listening() passed, _ = _check_port_80_listening()
assert passed is True assert passed is True
def test_not_listening(self): def test_not_listening(self):
@@ -280,7 +280,7 @@ class TestCheckPort80Listening:
mock_sock.__exit__ = MagicMock(return_value=False) mock_sock.__exit__ = MagicMock(return_value=False)
with patch("socket.socket", return_value=mock_sock): with patch("socket.socket", return_value=mock_sock):
passed, msg = _check_port_80_listening() passed, _ = _check_port_80_listening()
assert passed is False assert passed is False
@@ -301,7 +301,7 @@ class TestCheckAcmeAccount:
), ),
patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="ok")), patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="ok")),
): ):
passed, msg = _check_acme_account() passed, _ = _check_acme_account()
assert passed is True assert passed is True
def test_via_account_conf(self, tmp_path): def test_via_account_conf(self, tmp_path):
@@ -320,7 +320,7 @@ class TestCheckAcmeAccount:
patch("daemon.handlers.acme._ACME_HOME", acme_dir), patch("daemon.handlers.acme._ACME_HOME", acme_dir),
patch("subprocess.run", side_effect=run_side_effect), patch("subprocess.run", side_effect=run_side_effect),
): ):
passed, msg = _check_acme_account() passed, _ = _check_acme_account()
assert passed is True assert passed is True
def test_not_configured(self, tmp_path): def test_not_configured(self, tmp_path):
@@ -336,7 +336,7 @@ class TestCheckAcmeAccount:
patch("daemon.handlers.acme._ACME_HOME", acme_dir), patch("daemon.handlers.acme._ACME_HOME", acme_dir),
patch("subprocess.run", side_effect=run_side_effect), patch("subprocess.run", side_effect=run_side_effect),
): ):
passed, msg = _check_acme_account() passed, _ = _check_acme_account()
assert passed is False assert passed is False
@@ -351,7 +351,7 @@ class TestCheckDnsPublic:
patch("socket.gethostbyname", return_value="192.168.1.1"), patch("socket.gethostbyname", return_value="192.168.1.1"),
patch("subprocess.run", return_value=mock_result), patch("subprocess.run", return_value=mock_result),
): ):
passed, msg = _check_dns_public("example.com") passed, _ = _check_dns_public("example.com")
assert passed is True assert passed is True
def test_does_not_resolve(self): def test_does_not_resolve(self):
@@ -362,7 +362,7 @@ class TestCheckDnsPublic:
patch("socket.gethostbyname", return_value="192.168.1.1"), patch("socket.gethostbyname", return_value="192.168.1.1"),
patch("subprocess.run", return_value=mock_result), patch("subprocess.run", return_value=mock_result),
): ):
passed, msg = _check_dns_public("example.com") passed, _ = _check_dns_public("example.com")
assert passed is False assert passed is False
@@ -502,7 +502,7 @@ class TestValidate:
result = _validate("example.com") result = _validate("example.com")
assert result["ready"] is False 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["passed"] is False
assert nginx_check["blocking"] is True assert nginx_check["blocking"] is True
@@ -566,7 +566,7 @@ class TestValidate:
result = _validate("example.com") result = _validate("example.com")
assert result["ready"] is True 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["passed"] is False
assert dns_pub["blocking"] is False assert dns_pub["blocking"] is False
+64
View File
@@ -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)
+233
View File
@@ -175,3 +175,236 @@ class TestStateVersions:
updated = s.get_updated_versions() updated = s.get_updated_versions()
assert updated["firewall"] == 2 assert updated["firewall"] == 2
assert updated["dnsmasq"] == 1 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]
+4
View File
@@ -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<n.length;h++){h&&(1===t&&c(),c(h));for(var i=0;i<n[h].length;i++)l=n[h][i],1===t?"<"===l?(c(),a=[a,"",null],t=3):r+=l:4===t?"--"===r&&">"===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]}
+27 -3
View File
@@ -9,6 +9,20 @@
import { esc } from '../helpers.js?v=7'; import { esc } from '../helpers.js?v=7';
import { att_esc } from '../helpers.js?v=7'; import { att_esc } from '../helpers.js?v=7';
import { apiSubmit } from '../api.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 = []; const _modalQueue = [];
@@ -35,10 +49,15 @@ function _renderModals() {
/** /**
* Open a modal dialog. * 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) { export function openModal(content) {
_modalQueue.push({ renderFn, id: _modalQueue.length }); const entry = typeof content === 'function'
? { renderFn: content, id: _modalQueue.length }
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content) };
_modalQueue.push(entry);
_renderModals(); _renderModals();
} }
@@ -61,6 +80,11 @@ export function closeAllModals() {
_renderModals(); _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. * Render a standard modal layout: title, form fields, action buttons.
* *
+4
View File
@@ -0,0 +1,4 @@
import htm from '../../vendor/htm.js';
import { htmAdapter } from './vdom.js?v=7';
export const html = htm.bind(htmAdapter);
+4 -1
View File
@@ -10,6 +10,9 @@ export { reactive, requestUpdate } from './reactivity.js?v=7';
/* ── VDOM ────────────────────────────────────────────────────── */ /* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js?v=7'; export { h } from './vdom.js?v=7';
/* ── HTM ──────────────────────────────────────────────────────── */
export { html } from './html.js?v=7';
/* ── Render ──────────────────────────────────────────────────── */ /* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js?v=7'; 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'; 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 ────────────────────────────────────── */ /* ── 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 ────────────────────────────────────── */ /* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=7'; export { ToastContainer } from './components/toast.js?v=7';
+23
View File
@@ -21,6 +21,29 @@ export const _unmountFn = { fn: null };
export function setMountFn(fn) { _mountFn.fn = fn; } export function setMountFn(fn) { _mountFn.fn = fn; }
export function setUnmountFn(fn) { _unmountFn.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: * Build a VNode. Three forms:
* h('div', { class: 'x' }, h('span', null, 'hi')) — element * h('div', { class: 'x' }, h('span', null, 'hi')) — element
+3 -2
View File
@@ -57,14 +57,15 @@ function _wsConnect() {
* *
* Expected message shapes: * Expected message shapes:
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] } * { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
* { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
* { type: 'notify', topic: 'firewall' } * { type: 'notify', topic: 'firewall' }
* { type: 'status', topic: 'firewall', … } * { type: 'status', topic: 'firewall', … }
*/ */
function handleMessage(msg) { function handleMessage(msg) {
const topics = []; const topics = [];
if (msg.type === 'versions' || msg.type === 'refresh') { if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
topics.push(...(msg.updated || msg.topics || [])); topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
} else if (msg.type === 'notify') { } else if (msg.type === 'notify') {
topics.push(msg.topic); topics.push(msg.topic);
} else if (msg.type === 'status') { } else if (msg.type === 'status') {
+149 -168
View File
@@ -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'; 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';
const _issueState = { domain: '', modalIdx: -1, account: null, validating: false };
function _accountCard(account) { function _accountCard(account) {
if (!account || !account.registered) { if (!account || !account.registered) {
return h('div', { class: 'card' }, return html`<div class="card">
h('div', { class: 'card-header' }, <div class="card-header">
[ <span>ACME Account</span>
h('span', null, 'ACME Account'), <button class="btn btn-sm btn-primary" style="margin-left:auto"
h('button', { onClick=${() => registerAccountModal()}>Register Account</button>
class: 'btn btn-sm btn-primary', </div>
style: 'margin-left:auto;', <div class="card-body">
'on:click': () => registerAccountModal(), <div class="text-muted text-sm">Not registered</div>
}, 'Register Account'), </div>
] </div>`;
),
h('div', { class: 'card-body' },
h('div', { class: 'text-muted text-sm' }, 'Not registered'),
),
);
} }
return h('div', { class: 'card' }, return html`<div class="card">
h('div', { class: 'card-header' }, <div class="card-header">
[ <span>ACME Account</span>
h('span', null, 'ACME Account'), <button class="btn btn-sm btn-outline" style="margin-left:auto"
h('button', { onClick=${() => settingsModal(account)}>\u2699</button>
class: 'btn btn-sm btn-outline', </div>
style: 'margin-left:auto;', <div class="card-body">
'on:click': () => settingsModal(account), <div>Registered as <strong>${esc(account.email)}</strong></div>
}, '\u2699'), <div class="text-sm text-muted">CA: ${esc(account.ca)}</div>
] </div>
), </div>`;
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)]),
),
);
} }
function registerAccountModal() { function registerAccountModal() {
@@ -78,25 +67,31 @@ function registerAccountModal() {
} }
function settingsModal(account) { function settingsModal(account) {
openModal((inner) => { openModal((inner, idx) => {
inner.innerHTML = '<h2 class="modal-title">Account Settings</h2>' modalVNodes(inner, html`<div>
+ '<div class="modal-body">' <h2 class="modal-title">Account Settings</h2>
+ '<div class="form-group"><label>Current Account</label><div class="text-sm">' <div class="modal-body">
+ esc(account.email) + (account.ca ? ' (' + esc(account.ca) + ')' : '') <div class="form-group">
+ '</div></div>' <label>Current Account</label>
+ '<hr>' <div class="text-sm">${esc(account.email)}${account.ca ? ' (' + esc(account.ca) + ')' : ''}</div>
+ '<div class="form-group"><label>Update Email</label>' </div>
+ '<input id="set-email" type="email" placeholder="new@example.com"></div>' <hr />
+ '<hr>' <div class="form-group">
+ '<div class="text-danger"><strong>Danger Zone</strong></div>' <label>Update Email</label>
+ '<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>' <input id="set-email" type="email" placeholder="new@example.com" />
+ '</div><div class="modal-actions">' </div>
+ '<button class="btn btn-outline" data-action="set-cancel">Cancel</button>' <hr />
+ '<button class="btn btn-primary" data-action="set-save">Save Email</button>' <div class="text-danger"><strong>Danger Zone</strong></div>
+ '</div>'; <button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>
</div>
<div class="modal-actions">
<button class="btn btn-outline" data-action="set-cancel">Cancel</button>
<button class="btn btn-primary" data-action="set-save">Save Email</button>
</div>
</div>`);
inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => { inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => {
closeModal(); closeModal(idx);
}); });
inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => {
@@ -108,7 +103,7 @@ function settingsModal(account) {
}); });
if (resp.ok) { if (resp.ok) {
toast('Email updated', 'success'); toast('Email updated', 'success');
closeModal(); closeModal(idx);
modelFetch('acme'); modelFetch('acme');
} else { } else {
toast(resp.error || 'Failed', 'error'); toast(resp.error || 'Failed', 'error');
@@ -120,7 +115,7 @@ function settingsModal(account) {
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
if (resp.ok) { if (resp.ok) {
toast('Account deactivated', 'success'); toast('Account deactivated', 'success');
closeModal(); closeModal(idx);
modelFetch('acme'); modelFetch('acme');
} else { } else {
toast(resp.error || 'Failed', 'error'); toast(resp.error || 'Failed', 'error');
@@ -129,142 +124,129 @@ function settingsModal(account) {
}); });
} }
function issueCertModal(state) { function createIssueState() {
_issueState.domain = ''; return {
_issueState.modalIdx = -1; step: 'init',
_issueState.account = null; domain: '',
_issueState.validating = false; account: null,
validating: false,
checks: [],
ready: false,
};
}
let _currentIssueState = null;
function issueCertModal(state) {
_currentIssueState = createIssueState();
(async () => { (async () => {
const accountResp = await apiFetch('/api/certs/account'); const accountResp = await apiFetch('/api/certs/account');
_issueState.account = accountResp.ok ? accountResp.data : null; _currentIssueState.account = accountResp.ok ? accountResp.data : null;
_renderIssueModal(state); openModal((inner, modalIdx) => {
modalVNodes(inner, _renderIssueContent());
_bindIssueButtons(inner, modalIdx);
});
})(); })();
} }
function _renderIssueModal(state) { function _renderIssueContent() {
const account = _issueState.account; const s = _currentIssueState;
const account = s.account;
const registered = account && account.registered; const registered = account && account.registered;
const accountBadge = registered const accountBadge = registered
? esc(account.email) + ' (' + esc(account.ca) + ')' ? esc(account.email) + ' (' + esc(account.ca) + ')'
: 'No account registered'; : 'No account registered';
openModal((inner) => { if (s.step === 'results') {
inner.innerHTML = '<h2 class="modal-title">Issue Certificate</h2>' const resultsVNodes = s.checks.map(c => {
+ '<div class="modal-body">' let cls = 'text-success', icon = '\u2713';
+ '<div id="ic-account-info" class="text-sm mb-2">' if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; }
+ '<strong>Using account:</strong> ' + esc(accountBadge) + '</div>' else if (!c.passed) { cls = 'text-warning'; icon = '\u26A0'; }
+ (registered return html`<div>${icon} <strong>${esc(c.name)}</strong>: <span class=${cls}>${esc(c.message)}</span></div>`;
? '<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com"></div>' });
: '<div class="text-warning">Register an ACME account first</div>'
)
+ '<div id="ic-vresults"></div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ (registered
? '<button class="btn btn-primary" data-action="ic-validate">Validate</button>'
: '<button class="btn btn-primary" disabled>Validate</button>'
+ '<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>'
)
+ '</div>';
_issueState.modalIdx = document.querySelectorAll('#modal-root > div').length - 1; return html`<div>
<h2 class="modal-title">Validate: ${esc(s.domain)}</h2>
<div class="modal-body">
<div class="form-group">
<label>Domain</label>
<input id="ic-domain" value=${esc(s.domain)} />
</div>
<div id="ic-vresults">${...resultsVNodes}</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>
<button class="btn btn-primary" data-action="ic-issue"${s.ready ? '' : ' disabled'}>Issue</button>
</div>
</div>`;
}
return html`<div>
<h2 class="modal-title">Issue Certificate</h2>
<div class="modal-body">
<div id="ic-account-info" class="text-sm mb-2">
<strong>Using account:</strong> ${esc(accountBadge)}
</div>
${registered
? html`<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com" /></div>`
: html`<div class="text-warning">Register an ACME account first</div>`}
<div id="ic-vresults"></div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
${registered
? html`<button class="btn btn-primary" data-action="ic-validate">Validate</button>`
: html`<button class="btn btn-primary" disabled>Validate</button>
<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>`}
</div>
</div>`;
}
function _bindIssueButtons(inner, modalIdx) {
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx); closeModal(modalIdx);
}); });
inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => { inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx); closeModal(modalIdx);
registerAccountModal(); registerAccountModal();
}); });
if (!registered) return;
inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => {
if (_issueState.validating) return; if (_currentIssueState.validating) return;
const s = _currentIssueState;
const domain = ($val('ic-domain') || '').trim(); const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; } if (!domain) { toast('Domain is required', 'error'); return; }
_issueState.validating = true; s.validating = true;
_issueState.domain = domain; s.domain = domain;
try { try {
const resp = await apiFetch('/api/certs/validate', { const resp = await apiFetch('/api/certs/validate', { method: 'POST', body: { domain } });
method: 'POST', if (!resp.ok) { toast(resp.error || 'Validation failed', 'error'); return; }
body: { domain }, s.step = 'results';
}); s.checks = resp.data.checks;
if (!resp.ok) { s.ready = resp.data.ready;
toast(resp.error || 'Validation failed', 'error'); refreshModals();
return;
}
_showValidate(inner, domain, resp.data.checks, resp.data.ready, state);
} finally { } finally {
_issueState.validating = false; s.validating = false;
}
});
});
}
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 '<div>' + icon + ' <strong>' + esc(c.name) + '</strong>'
+ ': ' + '<span class="' + cls + '">' + esc(c.message) + '</span></div>';
}).join('');
inner.innerHTML = '<h2 class="modal-title">Validate: ' + esc(domain) + '</h2>'
+ '<div class="modal-body">'
+ '<div class="form-group"><label>Domain</label><input id="ic-domain" value="' + esc(domain) + '"></div>'
+ '<div id="ic-vresults">' + resultsHtml + '</div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ '<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>'
+ '<button class="btn btn-primary" data-action="ic-issue"'
+ (ready ? '' : ' disabled') + '>Issue</button>'
+ '</div>';
inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => {
closeModal(_issueState.modalIdx);
});
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;
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);
} finally {
_issueState.validating = false;
} }
}); });
inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => { inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => {
const body = { domain: _issueState.domain }; const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
method: 'POST',
body,
});
if (issueResp.ok) { if (issueResp.ok) {
toast('Issuance started for ' + _issueState.domain, 'success'); toast('Issuance started for ' + _currentIssueState.domain, 'success');
closeModal(_issueState.modalIdx); closeModal(modalIdx);
const rid = issueResp.data?.request_id; const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid, state); if (rid) pollCertIssue(rid);
} else { } else {
toast(issueResp.error || 'Failed', 'error'); toast(issueResp.error || 'Failed', 'error');
} }
}); });
} }
async function pollCertIssue(rid, state) { async function pollCertIssue(rid) {
poll({ poll({
url: '/api/certs/issue/' + enc(rid), url: '/api/certs/issue/' + enc(rid),
successKey: (d) => d.status === 'completed', successKey: (d) => d.status === 'completed',
@@ -293,32 +275,31 @@ export default definePage({
const rows = (state.acme.data?.certs || []).map(c => { const rows = (state.acme.data?.certs || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining }); const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
return h('tr', { key: c.domain }, return html`<tr key=${c.domain}>
h('td', null, h('strong', null, esc(c.domain || 'unknown'))), <td><strong>${esc(c.domain || 'unknown')}</strong></td>
h('td', { class: 'text-sm' }, esc(c.issuer || '-')), <td class="text-sm">${esc(c.issuer || '-')}</td>
h('td', null, esc(c.expiry || 'N/A')), <td>${esc(c.expiry || 'N/A')}</td>
h('td', null, badge), <td>${badge}</td>
ActionCell({ <${ActionCell}
editLabel: 'Renew', editLabel="Renew"
editClick: async () => { editClick=${async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' }); const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success'); if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error'); else toast(resp.error || 'Failed', 'error');
}, }}
removeUrl: '/api/certs/' + enc(c.domain), removeUrl=${'/api/certs/' + enc(c.domain)}
removeMessage: 'Remove certificate for ' + c.domain + '?', removeMessage=${'Remove certificate for ' + c.domain + '?'}
removeSuccess: 'Certificate removed', removeSuccess="Certificate removed"
removeRefresh: 'acme', removeRefresh="acme" />
}), </tr>`;
);
}); });
return [ return [
PageHeader({ PageHeader({
title: 'Certificates', title: 'Certificates',
subtitle: 'ACME certificate management', subtitle: 'ACME certificate management',
actions: h('button', { class: 'btn btn-primary', actions: html`<button class="btn btn-primary"
'on:click': () => issueCertModal(state) }, 'Issue Certificate'), onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
}), }),
_accountCard(account), _accountCard(account),
rows.length rows.length
+26 -34
View File
@@ -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({ export default definePage({
init() { init() {
@@ -21,41 +21,33 @@ export default definePage({
const dmsk = d.dnsmasq?.status || {}; const dmsk = d.dnsmasq?.status || {};
const wP = (d.wg || {}).peers || []; const wP = (d.wg || {}).peers || [];
const stats = html`<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'} />
</div>`;
const services = html`<div class="grid grid-2">
<div class="card">
<div class="card-header">Services</div>
<div class="card-body">
<ul class="service-list">
<li><${ServiceStatus} state=${dmsk.state || 'down'} label="Dnsmasq" /></li>
<li><${ServiceStatus} state=${d.wg?.state || 'down'} label="WireGuard" /></li>
</ul>
</div>
</div>
</div>`;
return [ return [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'grid grid-4' }, stats,
StatCard({ services,
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' })),
),
),
),
),
]; ];
}, },
}); });
+54 -51
View File
@@ -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({ const addRange = QuickModal({
title: 'Add DHCP Range', title: 'Add DHCP Range',
@@ -74,54 +74,51 @@ export default definePage({
const dnsRecords = cfg.dns_records || []; const dnsRecords = cfg.dns_records || [];
const status = state.dnsmasq.data?.status || {}; const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end }, const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
h('td', null, r.interface || '(global)'), <td>${r.interface || '(global)'}</td>
h('td', null, esc(r.start)), <td>${esc(r.start)}</td>
h('td', null, esc(r.end)), <td>${esc(r.end)}</td>
h('td', null, esc(r.lease_time || '12h')), <td>${esc(r.lease_time || '12h')}</td>
h('td', null, <td>
ConfirmDelete({ <${ConfirmDelete}
url: '/api/dhcp/ranges', url="/api/dhcp/ranges"
message: 'Remove range ' + r.start + ' - ' + r.end + '?', message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
body: { interface: r.interface || '', start: r.start, end: r.end }, body=${{ interface: r.interface || '', start: r.start, end: r.end }}
success: 'Range removed', success="Range removed"
refresh: 'dnsmasq', refresh="dnsmasq" />
}), </td>
), </tr>`);
));
const leaseRows = staticLeases.map((l) => h('tr', { key: l.mac }, const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
h('td', null, esc(l.mac)), <td>${esc(l.mac)}</td>
h('td', null, esc(l.ip)), <td>${esc(l.ip)}</td>
h('td', null, l.hostname || '-'), <td>${l.hostname || '-'}</td>
h('td', null, <td>
ConfirmDelete({ <${ConfirmDelete}
url: '/api/dhcp/static-lease/' + enc(l.mac), url=${'/api/dhcp/static-lease/' + enc(l.mac)}
message: 'Remove lease ' + l.mac + '?', message=${'Remove lease ' + l.mac + '?'}
success: 'Lease removed', success="Lease removed"
refresh: 'dnsmasq', refresh="dnsmasq" />
}), </td>
), </tr>`);
));
const dnsRows = dnsRecords.map((rec) => h('tr', { key: rec.name }, const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))), <td><strong>${esc(rec.name || 'unnamed')}</strong></td>
h('td', { class: 'text-sm' }, esc(rec.address || '-')), <td class="text-sm">${esc(rec.address || '-')}</td>
h('td', null, <td>
ConfirmDelete({ <${ConfirmDelete}
url: '/api/dhcp/dns-record/' + enc(rec.name || ''), url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?', message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
success: 'Record removed', success="Record removed"
refresh: 'dnsmasq', refresh="dnsmasq" />
}), </td>
), </tr>`);
));
const tabNames = ['ranges', 'leases', 'dns', 'active']; const tabNames = ['ranges', 'leases', 'dns', 'active'];
const actions = ActionGroup( const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'), html`<button class="btn btn-primary" onClick=${() => addRange(state)}>Add Range</button>`,
h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'), html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'), html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
ActionButton({ ActionButton({
url: '/api/dhcp/apply', url: '/api/dhcp/apply',
successMsg: 'dnsmasq applied', 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`<tr key=${l.mac || l.ip}>
<td>${esc(l.mac || '-')}</td>
<td>${esc(l.ip || '-')}</td>
<td>${esc(l.hostname || '-')}</td>
<td>${esc(l.expires || '-')}</td>
</tr>`),
emptyText: 'No active leases',
}) : null;
return [ return [
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }), PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }), 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, ? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
state.activeTab === 'dns' state.activeTab === 'dns'
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null, ? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
state.activeTab === 'active' leaseTable,
? 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,
]; ];
}, },
}); });
+16 -22
View File
@@ -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) { async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { 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]) => { const ifaces = Object.entries(netData).map(([name, entry]) => {
let zone = null; let zone = null;
for (const [zoneName, ifaces] of Object.entries(activeZones)) { for (const [zoneName, zIfaces] of Object.entries(activeZones)) {
if ((ifaces || []).includes(name)) { if ((zIfaces || []).includes(name)) {
zone = zoneName; zone = zoneName;
break; break;
} }
@@ -70,26 +70,20 @@ export default definePage({
}; };
}); });
const rows = ifaces.map(iface => { const rows = ifaces.map(iface =>
return h('tr', { key: iface.name }, html`<tr key=${iface.name}>
h('td', null, h('strong', null, iface.name)), <td><strong>${iface.name}</strong></td>
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')), <td class="text-muted">${String(iface.mac || 'N/A')}</td>
h('td', null, (iface.ips || []).join(', ') || 'N/A'), <td>${(iface.ips || []).join(', ') || 'N/A'}</td>
h('td', null, StatusText({ status: iface.state })), <td><${StatusText} status=${iface.state} /></td>
h('td', null, <td>
ZoneSelect({ <${ZoneSelect} zones=${zones} value=${iface.zone}
zones, onChange=${(z) => changeZone(iface.name, z, state)} />
value: iface.zone, <button class="btn btn-sm btn-outline" style="margin-left:8px"
onChange: (z) => changeZone(iface.name, z, state), onClick=${() => cfgModalFn(iface)}>Config</button>
}), </td>
h('button', { </tr>`
class: 'btn btn-sm btn-outline',
style: 'margin-left:8px',
'on:click': () => cfgModalFn(iface),
}, 'Config'),
),
); );
});
return [ return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }), PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
+13 -16
View File
@@ -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 = [ const logTabs = [
{ key: 'journal', label: 'Journal' }, { key: 'journal', label: 'Journal' },
@@ -24,7 +24,7 @@ export default definePage({
const lines = logData.data || []; const lines = logData.data || [];
const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0]; const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0];
const lineVnodes = lines.map((line, i) => const lineVnodes = lines.map((line, i) =>
h('div', { class: 'log-line', key: i }, esc(line)) html`<div class="log-line" key=${i}>${esc(line)}</div>`
); );
const tabsBody = Tabs({ const tabsBody = Tabs({
@@ -39,20 +39,17 @@ export default definePage({
return [ return [
PageHeader({ title: 'Logs', subtitle: 'System and service logs' }), PageHeader({ title: 'Logs', subtitle: 'System and service logs' }),
h('div', { class: 'card', key: 'log-card' }, html`<div class="card" key="log-card">
tabsBody, ${tabsBody}
h('div', { class: 'card-header' }, <div class="card-header">
h('span', null, tab.label), <span>${tab.label}</span>
h('button', { <button class="btn btn-sm btn-outline" style="float:right"
class: 'btn btn-sm btn-outline', onClick=${() => modelFetch('logs', state.activeTab)}>\u21BB</button>
style: 'float:right;', </div>
'on:click': () => modelFetch('logs', state.activeTab), <div class="card-body log-body">
}, '\u21BB'), <pre>${lineVnodes}</pre>
), </div>
h('div', { class: 'card-body log-body' }, </div>`,
h('pre', null, lineVnodes),
),
),
]; ];
}, },
}); });
+42 -47
View File
@@ -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({ const addFwd = QuickModal({
title: 'Add Port Forward', title: 'Add Port Forward',
@@ -47,37 +47,34 @@ export default definePage({
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone)); const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
const ifaceRows = (ifaces) => const ifaceRows = (ifaces) =>
ifaces.map((iface) => ifaces.map((iface) => html`<tr key=${'ii-' + iface.name}>
h('tr', { key: 'ii-' + iface.name }, <td>
h('td', null, <div class="d-flex align-items-center gap-2">
h('div', { class: 'd-flex align-items-center gap-2' }, <${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} />
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }), <strong>${iface.name}</strong>
h('strong', null, iface.name), </div>
), </td>
), <td>${(iface.ips || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')), <td>${(iface.ipv6 || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')), <td>${iface.mac || html`<span class="text-muted">—</span>`}</td>
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')), <td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })), </tr>`);
)
);
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => { const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade; const masq = !!zcfg.masquerade;
return h('tr', { key: 'm-' + zone }, return html`<tr key=${'m-' + zone}>
h('td', null, h('strong', null, zone)), <td><strong>${zone}</strong></td>
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })), <td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
h('td', null, <td>
ActionButton({ <${ActionButton}
url: '/api/firewall/masquerade', url="/api/firewall/masquerade"
cls: 'btn btn-sm btn-outline', cls="btn btn-sm btn-outline"
labelOn: 'Disable', labelOff: 'Enable', condition: masq, labelOn="Disable" labelOff="Enable" condition=${masq}
body: () => ({ zone, enable: !masq }), body=${() => ({ zone, enable: !masq })}
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone, successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone}
refresh: 'firewall', refresh="firewall" />
}), </td>
), </tr>`;
);
}); });
const fwRows = []; const fwRows = [];
@@ -86,21 +83,20 @@ export default definePage({
forwards.forEach((fwd, i) => { forwards.forEach((fwd, i) => {
const port = fwd.port; const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto; const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i }, fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
h('td', null, h('strong', null, zone)), <td><strong>${zone}</strong></td>
h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })), <td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
h('td', null, port), <td>${port}</td>
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'), <td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
h('td', null, fwd['to-port'] || fwd.toport || '-'), <td>${fwd['to-port'] || fwd.toport || '-'}</td>
h('td', null, <td>
ConfirmDelete({ <${ConfirmDelete}
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?', message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success: 'Rule removed', success="Rule removed"
refresh: 'firewall', refresh="firewall" />
}), </td>
), </tr>`);
));
}); });
}); });
@@ -127,9 +123,8 @@ export default definePage({
SectionTitle({ title: 'Port Forwarding' }), SectionTitle({ title: 'Port Forwarding' }),
Card({ children: [ Card({ children: [
ActionGroup( ActionGroup(
h('button', { class: 'btn btn-sm btn-primary', html`<button class="btn btn-sm btn-primary"
'on:click': () => addFwd({ zones: Object.keys(zoneData) }) onClick=${() => addFwd({ zones: Object.keys(zoneData) })}>Add Forward</button>`,
}, 'Add Forward'),
), ),
Table({ Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'], columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
+4 -4
View File
@@ -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({ export default definePage({
init() { init() {
@@ -7,9 +7,9 @@ export default definePage({
render(state) { render(state) {
return [ return [
PageHeader({ title: '404' }), PageHeader({ title: '404' }),
h('div', { class: 'card' }, html`<div class="card">
h('div', { class: 'card-body text-muted' }, 'Page not found: ' + state.path), <div class="card-body text-muted">Page not found: ${state.path}</div>
), </div>`,
]; ];
}, },
}); });
+16 -18
View File
@@ -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({ const addDomain = QuickModal({
title: 'Add Proxy Domain', title: 'Add Proxy Domain',
@@ -66,26 +66,24 @@ export default definePage({
expired: d.cert_status === 'expired', expired: d.cert_status === 'expired',
}); });
return h('tr', { key: d.domain }, return html`<tr key=${d.domain}>
h('td', null, h('strong', null, esc(d.domain))), <td><strong>${esc(d.domain)}</strong></td>
h('td', null, esc(d.backend_host || '-')), <td>${esc(d.backend_host || '-')}</td>
h('td', null, d.backend_port || '-'), <td>${d.backend_port || '-'}</td>
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })), <td><${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /></td>
h('td', null, certBadge), <td>${certBadge}</td>
ActionCell({ <${ActionCell}
editLabel: 'Edit', editLabel="Edit" editClick=${() => editDomain(d)}
editClick: () => editDomain(d), removeUrl=${'/api/proxy/domains/' + enc(d.domain)}
removeUrl: '/api/proxy/domains/' + enc(d.domain), removeMessage=${'Remove proxy for ' + d.domain + '?'}
removeMessage: 'Remove proxy for ' + d.domain + '?', removeSuccess="Domain removed"
removeSuccess: 'Domain removed', removeRefresh={['nginx', 'acme']}
removeRefresh: ['nginx', 'acme'], removeLabel="Delete" />
removeLabel: 'Delete', </tr>`;
}),
);
}); });
const actions = ActionGroup( const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'), html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
ActionButton({ ActionButton({
url: '/api/proxy/apply', url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded', successMsg: 'Nginx applied & reloaded',
+19 -19
View File
@@ -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({ const addRule = QuickModal({
title: 'Add Rich Rule', title: 'Add Rich Rule',
@@ -35,27 +35,27 @@ export default definePage({
}); });
const cards = Object.entries(zoneRules).map(([zone, rules]) => { 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`<tr key=${i}>
<td class="text-muted">${i + 1}</td>
<td class="mono-text td-fullwidth"><${MonoText} text=${ruleText} /></td>
<td>
<${ConfirmDelete}
url=${'/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || '')}
message=${'Remove rule: ' + ruleText.substring(0, 40) + '...?'}
success="Rule removed"
refresh="firewall" />
</td>
</tr>`;
});
return Card({ return Card({
header: 'Zone: ' + esc(zone), header: 'Zone: ' + esc(zone),
key: zone, key: zone,
children: [Table({ children: [Table({
columns: ['#', 'Rule', 'Action'], columns: ['#', 'Rule', 'Action'],
rows: (Array.isArray(rules) ? rules : []).map((entry, i) => { rows: ruleRows,
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',
}),
),
);
}),
emptyText: 'No rules', emptyText: 'No rules',
wrapCard: false, wrapCard: false,
})], })],
@@ -66,8 +66,8 @@ export default definePage({
PageHeader({ PageHeader({
title: 'Rules', title: 'Rules',
subtitle: 'Firewall rich rules', subtitle: 'Firewall rich rules',
actions: h('button', { class: 'btn btn-primary', actions: html`<button class="btn btn-primary"
'on:click': () => addRule({ zones }), }, 'Add Rule'), onClick=${() => addRule({ zones })}>Add Rule</button>`,
}), }),
...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]), ...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]),
]; ];
+22 -25
View File
@@ -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({ const addPeer = QuickModal({
title: 'Add WireGuard Peer', title: 'Add WireGuard Peer',
@@ -66,33 +66,30 @@ export default definePage({
const peerRows = (state.wireguard.data?.peers || []).map(p => { const peerRows = (state.wireguard.data?.peers || []).map(p => {
const hasHandshake = !!p.latest_handshake; const hasHandshake = !!p.latest_handshake;
return h('tr', { key: p.name }, return html`<tr key=${p.name}>
h('td', null, <td>
StatusDot({ status: hasHandshake ? 'success' : 'danger' }), <${StatusDot} status=${hasHandshake ? 'success' : 'danger'} />
h('strong', null, esc(p.name || 'unnamed')), <strong>${esc(p.name || 'unnamed')}</strong>
), </td>
h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })), <td><${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /></td>
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')), <td class="text-sm">${esc(p.allowed_ips || '-')}</td>
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')), <td class="text-sm">${esc(p.endpoint || '-')}</td>
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')), <td class="text-sm">${esc(p.latest_handshake || 'Never')}</td>
h('td', { class: 'text-sm' }, <td class="text-sm">
'Recv: ' + esc(p.transfer_recv || '0'), Recv: ${esc(p.transfer_recv || '0')}<br/>
h('br'), Sent: ${esc(p.transfer_sent || '0')}
'Sent: ' + esc(p.transfer_sent || '0'), </td>
), <${ActionCell}
ActionCell({ editLabel="Config" editClick=${() => downloadConfigModal(p.name, state.wireguard.data?.config, state)}
editLabel: 'Config', removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state), removeMessage=${'Remove peer ' + p.name + '?'}
removeUrl: '/api/wireguard/peers/' + enc(p.name), removeSuccess="Peer removed"
removeMessage: 'Remove peer ' + p.name + '?', removeRefresh="wireguard" />
removeSuccess: 'Peer removed', </tr>`;
removeRefresh: 'wireguard',
}),
);
}); });
const actions = ActionGroup( const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'), html`<button class="btn btn-primary" onClick=${() => addPeer(state)}>Add Peer</button>`,
ActionButton({ ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'), url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop', labelOff: 'Start', condition: isUp, labelOn: 'Stop', labelOff: 'Start', condition: isUp,
+40 -43
View File
@@ -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({ const addZone = QuickModal({
title: 'Add Zone', title: 'Add Zone',
@@ -37,30 +37,30 @@ export default definePage({
const z = typeof zdata === 'object' ? zdata : {}; const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : []; const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : []; const svcsArr = Array.isArray(z.services) ? z.services : [];
return h('div', { class: 'card', key: name, style: 'position:relative;' }, return html`<div class="card" key=${name} style="position:relative">
h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' }, <div style="display:flex;justify-content:space-between;align-items:flex-start">
h('div', null, <div>
h('h3', { style: 'font-size:16px;color:var(--accent);' }, name), <h3 style="font-size:16px;color:var(--accent)">${name}</h3>
h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' }, <div class="text-muted text-sm" style="margin-bottom:10px">
z.target ? 'Target: ' + esc(z.target) : '', ${z.target ? 'Target: ' + esc(z.target) : ''}
), </div>
), </div>
), </div>
h('div', { class: 'text-sm mb-4' }, <div class="text-sm mb-4">
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'), <div class="text-muted" style="margin-bottom:4px">Interfaces</div>
ifacesArr.length ${ifacesArr.length
? ifacesArr.map(i => Badge({ text: esc(i) })) ? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
: h('span', { class: 'text-muted' }, 'None'), : html`<span class="text-muted">None</span>`}
), </div>
h('div', { class: 'text-sm mb-4' }, <div class="text-sm mb-4">
h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'), <div class="text-muted" style="margin-bottom:4px">Services</div>
svcsArr.length ${svcsArr.length
? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' })) ? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
: h('span', { class: 'text-muted' }, 'None'), : html`<span class="text-muted">None</span>`}
), </div>
h('div', { style: 'display:flex;gap:6px;' }, <div style="display:flex;gap:6px">
h('button', { class: 'btn btn-sm btn-outline', <button class="btn btn-sm btn-outline"
'on:click': () => MultiSelectModal({ onClick=${() => MultiSelectModal({
title: 'Interfaces: ' + name, title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces', url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: state.firewall.data?.interfaces || [], options: state.firewall.data?.interfaces || [],
@@ -68,10 +68,9 @@ export default definePage({
fieldKey: 'interfaces', fieldKey: 'interfaces',
successMsg: 'Interfaces updated', successMsg: 'Interfaces updated',
refresh: 'firewall', refresh: 'firewall',
})(), })()}>Interfaces</button>
}, 'Interfaces'), <button class="btn btn-sm btn-outline"
h('button', { class: 'btn btn-sm btn-outline', onClick=${() => MultiSelectModal({
'on:click': () => MultiSelectModal({
title: 'Services: ' + name, title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services', url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.firewall.data?.services || [], options: state.firewall.data?.services || [],
@@ -79,28 +78,26 @@ export default definePage({
fieldKey: 'services', fieldKey: 'services',
successMsg: 'Services updated', successMsg: 'Services updated',
refresh: 'firewall', refresh: 'firewall',
})(), })()}>Services</button>
}, 'Services'), <${ConfirmDelete}
ConfirmDelete({ url=${'/api/firewall/zones/' + enc(name)}
url: '/api/firewall/zones/' + enc(name), message=${'Delete zone ' + name + '?'}
message: 'Delete zone ' + name + '?', success=${'Zone ' + name + ' deleted'}
success: 'Zone ' + name + ' deleted', refresh="firewall"
refresh: 'firewall', label="Delete" />
label: 'Delete', </div>
}), </div>`;
),
);
}); });
return [ return [
PageHeader({ PageHeader({
title: 'Zones', title: 'Zones',
subtitle: 'Firewall zones', subtitle: 'Firewall zones',
actions: h('button', { class: 'btn btn-primary', actions: html`<button class="btn btn-primary"
'on:click': () => addZone(), }, 'Add Zone'), onClick=${() => addZone()}>Add Zone</button>`,
}), }),
zoneCards.length zoneCards.length
? h('div', { class: 'card-grid' }, ...zoneCards) ? html`<div class="card-grid">${zoneCards}</div>`
: Empty({ text: 'No zones configured. Add a zone to get started.' }), : Empty({ text: 'No zones configured. Add a zone to get started.' }),
]; ];
}, },