From 2e49dec6330e6bb63e0d698bf23b2cc625f6921f Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Mon, 13 Jul 2026 14:30:35 +0000 Subject: [PATCH] feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages --- daemon/handlers/acme.py | 8 +- daemon/handlers/firewall.py | 4 + daemon/handlers/nginx.py | 98 +++++++- docs/hoover.md | 8 +- scripts/install.sh | 27 +-- system/nginx/server_block.conf | 4 +- tests/test_handler_acme.py | 50 ++-- webui/static/app.js | 4 +- webui/static/hoover/api.js | 80 +++++-- webui/static/hoover/component.js | 6 +- .../static/hoover/components/applyconfirm.js | 37 +-- webui/static/hoover/components/data.js | 127 ++++++---- webui/static/hoover/components/layout.js | 6 +- webui/static/hoover/components/modal.js | 82 +++++-- webui/static/hoover/components/toast.js | 4 +- webui/static/hoover/html.js | 2 +- webui/static/hoover/index.js | 30 +-- webui/static/hoover/model.js | 2 +- webui/static/hoover/render.js | 6 +- webui/static/hoover/router.js | 4 +- webui/static/hoover/websocket.js | 2 +- webui/static/pages/backends.js | 191 +++++++++++---- webui/static/pages/certs.js | 110 +++++---- webui/static/pages/dashboard.js | 2 +- webui/static/pages/dhcp.js | 5 +- webui/static/pages/interfaces.js | 2 +- webui/static/pages/logs.js | 2 +- webui/static/pages/nat.js | 7 +- webui/static/pages/notfound.js | 2 +- webui/static/pages/proxy.js | 222 ++++++++---------- webui/static/pages/rules.js | 3 +- webui/static/pages/wireguard.js | 25 +- webui/static/pages/zones.js | 3 +- webui/static/style.css | 36 +++ 34 files changed, 790 insertions(+), 411 deletions(-) diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index 57219d5..938d63d 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -981,10 +981,10 @@ def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str raise ValueError("'domain' is required") days = body.get("days", 365) - cert_dir = _ACME_HOME / domain - cert_dir.mkdir(parents=True, exist_ok=True) - cert_file = cert_dir / "fullchain.cer" - key_file = cert_dir / f"{domain}.key" + certs_dir = PROJECT_DIR / "data" / "certs" + certs_dir.mkdir(parents=True, exist_ok=True) + cert_file = certs_dir / f"{domain}.crt" + key_file = certs_dir / f"{domain}.key" if cert_file.is_file() and key_file.is_file(): logger.info("Self-signed cert for %s already exists, skipping", domain) diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index d5adbca..dbb3f49 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -691,6 +691,10 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] enable = body.get("enable") if not zone or enable is None: raise ValueError("'zone' and 'enable' (bool) are required") + if zone == "public" and enable: + raise ValueError( + "Masquerade (NAT) is not supported on the public zone — enable it on 'internal' or 'vpn' instead" + ) action = "--add-masquerade" if enable else "--remove-masquerade" run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) _reload() diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 90538b5..8938694 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -2,6 +2,7 @@ import logging import os +import subprocess from copy import deepcopy from pathlib import Path from typing import Any @@ -354,11 +355,54 @@ def _reload_nginx() -> None: logger.info("nginx configuration applied and reloaded") +def _ensure_self_signed_cert(domain: str) -> None: + """Auto-generate a self-signed cert for *domain* if not yet present.""" + certs_dir = PROJECT_DIR / "data" / "certs" + certs_dir.mkdir(parents=True, exist_ok=True) + cert_file = certs_dir / f"{domain}.crt" + key_file = certs_dir / f"{domain}.key" + if cert_file.is_file() and key_file.is_file(): + return + logger.info("Auto-generating self-signed cert for %s", domain) + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key_file), + "-out", + str(cert_file), + "-days", + "365", + "-nodes", + "-subj", + f"/CN={domain}", + ], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + cert_file.chmod(0o644) + key_file.chmod(0o600) + + def _write_all_sites() -> None: """Regenerate all site configs and ACME challenge site.""" ensure_dirs(SITES_DIR) cfg = _get_config() backends = cfg.get("backends", {}) + # Auto-generate self-signed certs for management domains that need them + for name, dom in cfg.get("domains", {}).items(): + cert = dom.get("cert") + paths = _ngx_resolve_paths(dom, backends) + has_management = any(p.get("is_management") for p in paths.values()) + if has_management and (cert == "selfsigned" or cert is None): + _ensure_self_signed_cert(name) + existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set() written: set[str] = set() for name, dom in cfg.get("domains", {}).items(): @@ -396,13 +440,19 @@ def _hash_password(password: str) -> str: return sha256_crypt.hash(password) -def _write_htpasswd(user: str, password: str) -> None: - """Add or update a user entry in the .htpasswd file using SHA-256 hashing.""" - ensure_dirs(DATA_DIR) +def _write_htpasswd( + user: str, password: str, htpasswd_path: Path | None = None +) -> None: + """Add or update a user entry in the .htpasswd file using SHA-256 hashing. + + If *htpasswd_path* is not given, defaults to :data:`HTPASSWD_FILE`. + """ + target = htpasswd_path or HTPASSWD_FILE + ensure_dirs(target.parent) hashed = _hash_password(password) existing: dict[str, str] = {} - if HTPASSWD_FILE.exists(): - with open(HTPASSWD_FILE) as f: + if target.exists(): + with open(target) as f: for line in f: line = line.strip() if not line or line.startswith("#"): @@ -411,12 +461,12 @@ def _write_htpasswd(user: str, password: str) -> None: if len(parts) == 2: existing[parts[0]] = line existing[user] = f"{user}:{hashed}" - tmp = HTPASSWD_FILE.with_suffix(".tmp") + tmp = target.with_suffix(".tmp") with open(tmp, "w") as f: for _uname, entry in existing.items(): f.write(entry + "\n") os.chmod(tmp, 0o640) - os.replace(tmp, HTPASSWD_FILE) + os.replace(tmp, target) def _get_nginx_state() -> dict[str, Any]: @@ -502,7 +552,18 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: auth = body.get("auth") if auth is not None: - entry["auth"] = auth + # Write htpasswd file when a password is provided, then store + # only {user, htpasswd path} — never persist the raw password. + if auth.get("user") and auth.get("pass"): + htpasswd_path = auth.get( + "htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd") + ) + if isinstance(htpasswd_path, str) and not Path(htpasswd_path).is_absolute(): + htpasswd_path = PROJECT_DIR / htpasswd_path + _write_htpasswd(auth["user"], auth["pass"], Path(htpasswd_path)) + entry["auth"] = {"user": auth["user"], "htpasswd": str(htpasswd_path)} + else: + entry["auth"] = auth cfg["domains"][domain] = entry _save_config(cfg) @@ -561,7 +622,26 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if body["auth"] is None: entry.pop("auth", None) else: - entry["auth"] = body["auth"] + # Normalize: if auth has `pass`, write htpasswd and store only + # `{user, htpasswd path}` — never persist the raw password. + if body["auth"].get("user") and body["auth"].get("pass"): + htpasswd_path = body["auth"].get( + "htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd") + ) + if ( + isinstance(htpasswd_path, str) + and not Path(htpasswd_path).is_absolute() + ): + htpasswd_path = PROJECT_DIR / htpasswd_path + _write_htpasswd( + body["auth"]["user"], body["auth"]["pass"], Path(htpasswd_path) + ) + entry["auth"] = { + "user": body["auth"]["user"], + "htpasswd": str(htpasswd_path), + } + else: + entry["auth"] = body["auth"] _save_config(cfg) refresh_state(["nginx"]) diff --git a/docs/hoover.md b/docs/hoover.md index c880593..3cb0cce 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -803,7 +803,7 @@ Card container with optional header. #### `ConfirmDelete(props)` -Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh. +Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh. Shows a spinner animation during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion until the model refresh removes it from the DOM. ```javascript ConfirmDelete({ @@ -825,10 +825,11 @@ ConfirmDelete({ | `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` | | `label` | Button text (default: `'Remove'`) | | `body` | Optional JSON body to send with DELETE | +| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success until model refresh removes it from the DOM. Requires `_deleting.has(key)` class binding on the parent element. | #### `ActionButton(props)` -Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons. +Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons. Shows a spinner animation during API calls and auto-disables the button to prevent double-submit. ```javascript ActionButton({ @@ -872,7 +873,7 @@ ActionButton({ #### `ActionCell(props)` -Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action. +Standardizes "action button + ConfirmDelete" in a table cell. The delete button shows a spinner during API calls and supports pending-deletion row styling. Use for rows that need an edit action alongside a delete action. ```javascript ActionCell({ @@ -899,6 +900,7 @@ ActionCell({ | `removeLabel` | Delete button label (default: `'Remove'`) | | `removeBody` | Optional JSON body to send with DELETE | | `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) | +| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. | #### `certStatusBadge(props)` diff --git a/scripts/install.sh b/scripts/install.sh index 761ef54..b540d59 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -376,29 +376,24 @@ else fi } - # 1A. Self-signed certificate + # 1A. Self-signed certificate for management domain _daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate" - # 1C. Management proxy domain - _daemon_post "/nginx/domains/add" "$(jq -n \ + # 1C. Management proxy domain — try idempotent update first, fall back to add + local mgmt_json + mgmt_json="$(jq -n \ --arg domain "$DOMAIN" \ --arg user "$MGMT_USER" \ --arg pass "$MGMT_PASS" \ '{ domain: $domain, - paths: { - "/": { - backend: {host: "127.0.0.1", port: 9090, proto: "http"}, - is_management: true - }, - "/ws": { - backend: {host: "127.0.0.1", port: 9091, proto: "http"}, - is_websocket: true - } - }, - auth_user: $user, - auth_pass: $pass - }')" "Management domain configured" + backend: "webui", + cert: "selfsigned", + force_ssl: true, + auth: {user: $user, pass: $pass} + }')" + _daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \ + _daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured" _daemon_post "/nginx/apply" "{}" "Nginx config applied" diff --git a/system/nginx/server_block.conf b/system/nginx/server_block.conf index 9e3a313..363b282 100644 --- a/system/nginx/server_block.conf +++ b/system/nginx/server_block.conf @@ -33,8 +33,8 @@ server { ssl_certificate_key {{ certs_dir }}/{{ domain }}.key; {% endif %} {% elif has_management %} - ssl_certificate {{ acme_cert_dir }}/fullchain.cer; - ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; + ssl_certificate {{ certs_dir }}/{{ domain }}.crt; + ssl_certificate_key {{ certs_dir }}/{{ domain }}.key; {% endif %} include snippets/vacuum-wall-ssl.conf; diff --git a/tests/test_handler_acme.py b/tests/test_handler_acme.py index d2c2006..9086d90 100644 --- a/tests/test_handler_acme.py +++ b/tests/test_handler_acme.py @@ -34,51 +34,51 @@ from daemon.server import ConflictError class TestGenerateSelfSigned: def test_generate_creates_files(self, tmp_path): with ( - patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"), + patch("daemon.handlers.acme.PROJECT_DIR", tmp_path), ): result = generate_self_signed(None, {"domain": "test.local"}) assert result["domain"] == "test.local" assert result["generated"] is True - cert_dir = tmp_path / "acme" / "test.local" - assert result["cert"] == str(cert_dir / "fullchain.cer") - assert result["key"] == str(cert_dir / "test.local.key") - assert (cert_dir / "fullchain.cer").is_file() - assert (cert_dir / "test.local.key").is_file() + certs_dir = tmp_path / "data" / "certs" + assert result["cert"] == str(certs_dir / "test.local.crt") + assert result["key"] == str(certs_dir / "test.local.key") + assert (certs_dir / "test.local.crt").is_file() + assert (certs_dir / "test.local.key").is_file() def test_generate_idempotent_skips_existing(self, tmp_path): - cert_dir = tmp_path / "acme" / "test.local" - cert_dir.mkdir(parents=True) - (cert_dir / "fullchain.cer").write_text("dummy-cert") - (cert_dir / "test.local.key").write_text("dummy-key") + certs_dir = tmp_path / "data" / "certs" + certs_dir.mkdir(parents=True) + (certs_dir / "test.local.crt").write_text("dummy-cert") + (certs_dir / "test.local.key").write_text("dummy-key") - with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"): + with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path): result = generate_self_signed(None, {"domain": "test.local"}) assert result["generated"] is False def test_generate_partial_existing(self, tmp_path): - cert_dir = tmp_path / "acme" / "test.local" - cert_dir.mkdir(parents=True) - (cert_dir / "fullchain.cer").write_text("dummy-cert") + certs_dir = tmp_path / "data" / "certs" + certs_dir.mkdir(parents=True) + (certs_dir / "test.local.crt").write_text("dummy-cert") # key missing -> should regenerate - with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"): + with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path): result = generate_self_signed(None, {"domain": "test.local"}) assert result["generated"] is True def test_generate_custom_days(self, tmp_path): with ( - patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"), + patch("daemon.handlers.acme.PROJECT_DIR", tmp_path), patch("subprocess.run") as mock_run, ): def _create_files(*args, **kwargs): - cert_dir = tmp_path / "acme" / "test.local" - cert_dir.mkdir(parents=True, exist_ok=True) - (cert_dir / "fullchain.cer").touch() - (cert_dir / "test.local.key").touch() + certs_dir = tmp_path / "data" / "certs" + certs_dir.mkdir(parents=True, exist_ok=True) + (certs_dir / "test.local.crt").touch() + (certs_dir / "test.local.key").touch() return Path("") mock_run.side_effect = _create_files @@ -88,15 +88,15 @@ class TestGenerateSelfSigned: idx = args.index("-days") assert args[idx + 1] == "730" - cert_dir = tmp_path / "acme" / "test.local" - if (cert_dir / "fullchain.cer").is_file(): - assert cert_dir.is_dir() + certs_dir = tmp_path / "data" / "certs" + if (certs_dir / "test.local.crt").is_file(): + assert certs_dir.is_dir() def test_generate_creates_directory(self, tmp_path): - with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"): + with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path): generate_self_signed(None, {"domain": "test.local"}) - assert (tmp_path / "acme" / "test.local").is_dir() + assert (tmp_path / "data" / "certs").is_dir() def test_generate_requires_domain(self): with pytest.raises(ValueError, match="domain"): diff --git a/webui/static/app.js b/webui/static/app.js index 81aada2..74c0d52 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -6,8 +6,8 @@ import ZonesPage from '/static/pages/zones.js?v=9'; import RulesPage from '/static/pages/rules.js?v=9'; import NatPage from '/static/pages/nat.js?v=9'; import DhcpPage from '/static/pages/dhcp.js?v=9'; -import ProxyPage from '/static/pages/proxy.js?v=9'; -import BackendsPage from '/static/pages/backends.js?v=9'; +import ProxyPage from '/static/pages/proxy.js?v=10'; +import BackendsPage from '/static/pages/backends.js?v=10'; import CertsPage from '/static/pages/certs.js?v=9'; import WireguardPage from '/static/pages/wireguard.js?v=9'; import LogsPage from '/static/pages/logs.js?v=9'; diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 512b07b..31bb310 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -3,10 +3,12 @@ * * JSON-friendly fetch wrapper with automatic header management. * Toast notification system with auto-dismiss. + * Modal processing guard for async form submissions. */ -import { modelFetch } from './model.js?v=8'; -import { requestUpdate } from './reactivity.js?v=8'; +import { modelFetch } from './model.js?v=9'; +import { requestUpdate } from './reactivity.js?v=9'; +import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9'; /** * JSON-friendly fetch wrapper. @@ -178,6 +180,33 @@ export async function poll(opts) { }, interval); } +/** + * Centralized handler wrapper that encapsulates processing guard, + * processing state, error handling, and modal re-render. + * + * Used by any handler not using `apiSubmit`. The async function receives + * no arguments and should perform validation (via `throw`), API calls, + * success/error toasting, modal closing, and data refreshing. + * + * @param {function} fn – Async handler function + * @returns {function} Wrapped handler + */ +export function formAction(fn) { + return async () => { + if (isModalProcessing()) return; + setModalProcessing(true); + refreshModals(); + try { + await fn(); + } catch (e) { + toast(e.message || 'Failed', 'error'); + } finally { + setModalProcessing(false); + refreshModals(); + } + }; +} + /** * Generate action button descriptors for modal form submission. * @@ -211,28 +240,37 @@ export function apiSubmit(opts) { label: submitText, cls: 'btn-primary', action: 's', + processing: true, handler: async () => { - const b = body ? body() : {}; - if (validate) { - const err = validate(b); - if (err) { toast(err, 'error'); return; } - } - const res = await apiFetch(url, { method, body: b }); - if (res.ok) { - const synced = res.data?.synced; - let msg = successMsg; - if (synced && synced.length) { - msg += ' (auto-synced: ' + synced.join(', ') + ')'; - synced.forEach(s => modelFetch(s)); + if (isModalProcessing()) return; + setModalProcessing(true); + refreshModals(); + try { + const b = body ? body() : {}; + if (validate) { + const err = validate(b); + if (err) { toast(err, 'error'); return; } } - toast(msg, 'success'); - if (closeModal) closeModal(); - if (refresh) { - const models = Array.isArray(refresh) ? refresh : [refresh]; - await Promise.all(models.map(m => modelFetch(m))); + const res = await apiFetch(url, { method, body: b }); + if (res.ok) { + const synced = res.data?.synced; + let msg = successMsg; + if (synced && synced.length) { + msg += ' (auto-synced: ' + synced.join(', ') + ')'; + synced.forEach(s => modelFetch(s)); + } + toast(msg, 'success'); + if (closeModal) closeModal(); + if (refresh) { + const models = Array.isArray(refresh) ? refresh : [refresh]; + await Promise.all(models.map(m => modelFetch(m))); + } + } else { + toast(res.error || 'Failed', 'error'); } - } else { - toast(res.error || 'Failed', 'error'); + } finally { + setModalProcessing(false); + refreshModals(); } }, }, diff --git a/webui/static/hoover/component.js b/webui/static/hoover/component.js index 7eb9026..1631347 100644 --- a/webui/static/hoover/component.js +++ b/webui/static/hoover/component.js @@ -15,9 +15,9 @@ * }); */ -import { reactive } from './reactivity.js?v=8'; -import { h } from './vdom.js?v=8'; -import { _compExpandedCache } from './render.js?v=8'; +import { reactive } from './reactivity.js?v=9'; +import { h } from './vdom.js?v=9'; +import { _compExpandedCache } from './render.js?v=9'; /** Registry of mounted components: key → { state } */ const _mounted = new Map(); diff --git a/webui/static/hoover/components/applyconfirm.js b/webui/static/hoover/components/applyconfirm.js index d82b2e1..e0d6e70 100644 --- a/webui/static/hoover/components/applyconfirm.js +++ b/webui/static/hoover/components/applyconfirm.js @@ -6,12 +6,12 @@ * expandable modal, then applies all via /api/status/apply-all. */ -import { h } from '../vdom.js?v=8'; -import { html } from '../html.js?v=8'; -import { reactive } from '../reactivity.js?v=8'; -import { apiFetch, toast } from '../api.js?v=8'; -import { modelFetch } from '../model.js?v=8'; -import { openModal, closeModal, modalVNodes } from './modal.js?v=8'; +import { h } from '../vdom.js?v=9'; +import { html } from '../html.js?v=9'; +import { reactive } from '../reactivity.js?v=9'; +import { apiFetch, toast } from '../api.js?v=9'; +import { modelFetch } from '../model.js?v=9'; +import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js?v=9'; export const SUBSYSTEM_LIST = [ { key: 'firewall', label: 'Firewall' }, @@ -59,16 +59,23 @@ ${hasPending ? html`'; - }).join('') + ''; + }).join('') + ''; - actions.forEach(a => { - const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]'); - if (btn) btn.addEventListener('click', a.handler); - }); + // Mark modal as having editable inputs + const topEntry = _modalQueue[_modalQueue.length - 1]; + if (topEntry) topEntry._hasInputs = true; + + // Build action buttons with processing-aware rendering + const actionsBar = inner.querySelector('.modal-actions'); + const actionBtns = []; + for (const a of actions) { + const actionId = 'am-' + a.action + '-' + (_modalQueue.length - 1); + const btn = document.createElement('button'); + btn.className = 'btn ' + a.cls; + btn.id = actionId; + if (a.processing && isModalProcessing(_modalQueue.length - 1)) { + btn.disabled = true; + btn.innerHTML = ''; + } else { + btn.appendChild(document.createTextNode(a.label)); + } + // Store reference for later re-binding + actionBtns.push({ btn, action: a }); + if (a.handler) { + const origHandler = a.handler; + btn.addEventListener('click', () => { + refreshModals(); + origHandler(); + }); + } + actionsBar.appendChild(btn); + } } /** diff --git a/webui/static/hoover/components/toast.js b/webui/static/hoover/components/toast.js index 514a371..dad0bc1 100644 --- a/webui/static/hoover/components/toast.js +++ b/webui/static/hoover/components/toast.js @@ -5,8 +5,8 @@ * Uses the toast/dismissToast state from api.js. */ -import { h } from '../vdom.js?v=8'; -import { _toasts, dismissToast } from '../api.js?v=8'; +import { h } from '../vdom.js?v=9'; +import { _toasts, dismissToast } from '../api.js?v=9'; /** * Render all pending toast notifications. diff --git a/webui/static/hoover/html.js b/webui/static/hoover/html.js index 12d5fa8..6469d27 100644 --- a/webui/static/hoover/html.js +++ b/webui/static/hoover/html.js @@ -1,4 +1,4 @@ import htm from '../../vendor/htm.js'; -import { htmAdapter } from './vdom.js?v=8'; +import { htmAdapter } from './vdom.js?v=9'; export const html = htm.bind(htmAdapter); diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index eb85a45..4c5a8ad 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -5,46 +5,46 @@ */ /* ── Reactivity ──────────────────────────────────────────────── */ -export { reactive, requestUpdate } from './reactivity.js?v=8'; +export { reactive, requestUpdate } from './reactivity.js?v=9'; /* ── VDOM ────────────────────────────────────────────────────── */ -export { h } from './vdom.js?v=8'; +export { h } from './vdom.js?v=9'; /* ── HTM ──────────────────────────────────────────────────────── */ -export { html } from './html.js?v=8'; +export { html } from './html.js?v=9'; /* ── Render ──────────────────────────────────────────────────── */ -export { render } from './render.js?v=8'; +export { render } from './render.js?v=9'; /* ── Component ───────────────────────────────────────────────── */ -export { definePage, hComp } from './component.js?v=8'; +export { definePage, hComp } from './component.js?v=9'; /* ── Router ──────────────────────────────────────────────────── */ -export { createRouter, Link } from './router.js?v=8'; +export { createRouter, Link } from './router.js?v=9'; /* ── WebSocket ───────────────────────────────────────────────── */ -export { connect, onMessage } from './websocket.js?v=8'; +export { connect, onMessage } from './websocket.js?v=9'; /* ── API & Toast ─────────────────────────────────────────────── */ -export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad } from './api.js?v=8'; +export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction } from './api.js?v=9'; /* ── Model ───────────────────────────────────────────────────── */ -export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=8'; +export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=9'; /* ── Helpers ─────────────────────────────────────────────────── */ -export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=8'; +export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=9'; /* ── UI Components: Layout ───────────────────────────────────── */ -export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=8'; +export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=9'; /* ── UI Components: Data ─────────────────────────────────────── */ -export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=8'; +export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=9'; /* ── UI Components: Modal ────────────────────────────────────── */ -export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=8'; +export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=9'; /* ── UI Components: Apply ────────────────────────────────────── */ -export { ApplyConfirm } from './components/applyconfirm.js?v=8'; +export { ApplyConfirm } from './components/applyconfirm.js?v=9'; /* ── UI Components: Toast ────────────────────────────────────── */ -export { ToastContainer } from './components/toast.js?v=8'; +export { ToastContainer } from './components/toast.js?v=9'; diff --git a/webui/static/hoover/model.js b/webui/static/hoover/model.js index 1ffed1f..f7a7f69 100644 --- a/webui/static/hoover/model.js +++ b/webui/static/hoover/model.js @@ -13,7 +13,7 @@ * collectLoadingModels(...models) — combine loading/refreshing/error */ -import { reactive } from './reactivity.js?v=8'; +import { reactive } from './reactivity.js?v=9'; /** Registered models: name → { model, subsystem, fetch } */ const _models = new Map(); diff --git a/webui/static/hoover/render.js b/webui/static/hoover/render.js index e06e31a..4c1e849 100644 --- a/webui/static/hoover/render.js +++ b/webui/static/hoover/render.js @@ -5,12 +5,12 @@ * batched re-render loop integration with reactivity.js. */ -import { requestUpdate, setCommitFn } from './reactivity.js?v=8'; +import { requestUpdate, setCommitFn } from './reactivity.js?v=9'; import { _vnodeDom, createDom, getDom, patchNode, sweepDom, setMountFn, setUnmountFn, -} from './vdom.js?v=8'; -import { mountComponent, unmountComponent } from './component.js?v=8'; +} from './vdom.js?v=9'; +import { mountComponent, unmountComponent } from './component.js?v=9'; /** Container → previous root vnodes */ export const _renderSlots = new Map(); diff --git a/webui/static/hoover/router.js b/webui/static/hoover/router.js index bf32231..3d1dcf4 100644 --- a/webui/static/hoover/router.js +++ b/webui/static/hoover/router.js @@ -5,8 +5,8 @@ * navigation). Link component for client-side navigation. */ -import { reactive } from './reactivity.js?v=8'; -import { h } from './vdom.js?v=8'; +import { reactive } from './reactivity.js?v=9'; +import { h } from './vdom.js?v=9'; /** * Hash-based router. diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index aecfdb4..840cf7c 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -6,7 +6,7 @@ * Page-level subscribe/unsubscribe is replaced by the model layer. */ -import { refreshByTopic } from './model.js?v=8'; +import { refreshByTopic } from './model.js?v=9'; let _wsConn = null; let _wsReconnectMs = 0; diff --git a/webui/static/pages/backends.js b/webui/static/pages/backends.js index 6fb214c..d9a5206 100644 --- a/webui/static/pages/backends.js +++ b/webui/static/pages/backends.js @@ -1,15 +1,77 @@ -import { html, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch } from '/static/hoover/index.js?v=8'; -import { openModal, closeModal } from '/static/hoover/components/modal.js?v=8'; +import { h, html, PageHeader, Badge, Empty, Table, renderGuard, renderGuardMulti, ConfirmDelete, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup } from '/static/hoover/index.js?v=9'; +import { openModal, closeModal, isModalProcessing, setModalProcessing, refreshModals } from '/static/hoover/components/modal.js?v=9'; +import { _deleting } from '/static/hoover/components/data.js?v=9'; + +// --------------------------------------------------------------------------- +// Build a datalist element from DHCP leases +// --------------------------------------------------------------------------- +function _buildHostDatalist(leases, uniqueId) { + const datalist = document.createElement('datalist'); + datalist.id = 'hosts-' + uniqueId; + for (const lease of leases) { + const option = document.createElement('option'); + option.value = lease.ip; + if (lease.hostname) { + option.setAttribute('label', lease.hostname); + const displayIp = esc(lease.ip); + const displayHost = esc(lease.hostname); + option.appendChild(document.createTextNode(displayIp + ' (' + displayHost + ')')); + } else { + option.appendChild(document.createTextNode(esc(lease.ip))); + } + datalist.appendChild(option); + } + return datalist; +} + +// --------------------------------------------------------------------------- +// Inject discovered hosts hint section into modal +// --------------------------------------------------------------------------- +function _injectDiscoveredHosts(modalContent, leases, datalistId) { + const hostsHint = document.createElement('div'); + hostsHint.className = 'form-group'; + const details = document.createElement('details'); + const summary = document.createElement('summary'); + summary.textContent = leases.length + ' host' + (leases.length !== 1 ? 's' : '') + ' discovered'; + details.appendChild(summary); + + for (const lease of leases) { + const item = document.createElement('div'); + item.style.cssText = 'cursor:pointer;padding:2px 4px;margin:2px 0;border-radius:4px;font-size:0.875rem;'; + const hostname = lease.hostname ? lease.hostname + ' → ' : ''; + item.textContent = hostname + lease.ip; + item.style.color = '#0d6efd'; + item.addEventListener('click', () => { + const hostInput = document.querySelector('#' + datalistId + '-host-input'); + if (hostInput) { + hostInput.value = lease.ip; + } + details.removeAttribute('open'); + }); + item.addEventListener('mouseenter', () => { item.style.background = '#e9ecef'; }); + item.addEventListener('mouseleave', () => { item.style.background = ''; }); + details.appendChild(item); + } + + hostsHint.appendChild(details); + const pathsGroup = modalContent.querySelector('#paths-' + datalistId.split('-')[1])?.parentElement; + if (pathsGroup) { + pathsGroup.parentElement.insertBefore(hostsHint, pathsGroup); + } +} // --------------------------------------------------------------------------- // Add a path row element to the paths container // --------------------------------------------------------------------------- -function _addPathRow(container, data) { +function _addPathRow(container, data, datalistId, isFirst) { const row = document.createElement('div'); row.className = 'path-row-row'; + const hostListAttr = datalistId ? ' list="' + datalistId + '"' : ''; + const hostInputId = isFirst ? ' id="' + datalistId + '-host-input"' : ''; row.innerHTML = ` - + +