From 593dece92bd16f167a1e86bd2f8fbd3e2b169ed0 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Tue, 16 Jun 2026 03:35:41 +0000 Subject: [PATCH] refactor: replace Jinja templates with static frontend pages --- webui/server.py | 415 ++------------ webui/static/app.js | 657 ++++------------------- webui/static/hoover/api.js | 95 ++++ webui/static/hoover/component.js | 142 +++++ webui/static/hoover/components/data.js | 59 ++ webui/static/hoover/components/layout.js | 26 + webui/static/hoover/components/modal.js | 103 ++++ webui/static/hoover/components/toast.js | 40 ++ webui/static/hoover/helpers.js | 51 ++ webui/static/hoover/index.js | 41 ++ webui/static/hoover/reactivity.js | 59 ++ webui/static/hoover/render.js | 225 ++++++++ webui/static/hoover/router.js | 61 +++ webui/static/hoover/vdom.js | 317 +++++++++++ webui/static/hoover/websocket.js | 134 +++++ webui/static/index.html | 14 + webui/static/pages/certs.js | 157 ++++++ webui/static/pages/dashboard.js | 104 ++++ webui/static/pages/dhcp.js | 326 +++++++++++ webui/static/pages/interfaces.js | 159 ++++++ webui/static/pages/logs.js | 79 +++ webui/static/pages/nat.js | 186 +++++++ webui/static/pages/notfound.js | 19 + webui/static/pages/proxy.js | 187 +++++++ webui/static/pages/rules.js | 132 +++++ webui/static/pages/wireguard.js | 212 ++++++++ webui/static/pages/zones.js | 233 ++++++++ webui/static/style.css | 230 +++++++- webui/templates/base.html | 598 --------------------- webui/templates/certs.html | 94 ---- webui/templates/dashboard.html | 124 ----- webui/templates/dhcp.html | 214 -------- webui/templates/interfaces.html | 111 ---- webui/templates/logs.html | 151 ------ webui/templates/nat.html | 131 ----- webui/templates/proxy.html | 151 ------ webui/templates/rules.html | 83 --- webui/templates/wireguard.html | 123 ----- webui/templates/zones.html | 90 ---- 39 files changed, 3532 insertions(+), 2801 deletions(-) create mode 100644 webui/static/hoover/api.js create mode 100644 webui/static/hoover/component.js create mode 100644 webui/static/hoover/components/data.js create mode 100644 webui/static/hoover/components/layout.js create mode 100644 webui/static/hoover/components/modal.js create mode 100644 webui/static/hoover/components/toast.js create mode 100644 webui/static/hoover/helpers.js create mode 100644 webui/static/hoover/index.js create mode 100644 webui/static/hoover/reactivity.js create mode 100644 webui/static/hoover/render.js create mode 100644 webui/static/hoover/router.js create mode 100644 webui/static/hoover/vdom.js create mode 100644 webui/static/hoover/websocket.js create mode 100644 webui/static/index.html create mode 100644 webui/static/pages/certs.js create mode 100644 webui/static/pages/dashboard.js create mode 100644 webui/static/pages/dhcp.js create mode 100644 webui/static/pages/interfaces.js create mode 100644 webui/static/pages/logs.js create mode 100644 webui/static/pages/nat.js create mode 100644 webui/static/pages/notfound.js create mode 100644 webui/static/pages/proxy.js create mode 100644 webui/static/pages/rules.js create mode 100644 webui/static/pages/wireguard.js create mode 100644 webui/static/pages/zones.js delete mode 100644 webui/templates/base.html delete mode 100644 webui/templates/certs.html delete mode 100644 webui/templates/dashboard.html delete mode 100644 webui/templates/dhcp.html delete mode 100644 webui/templates/interfaces.html delete mode 100644 webui/templates/logs.html delete mode 100644 webui/templates/nat.html delete mode 100644 webui/templates/proxy.html delete mode 100644 webui/templates/rules.html delete mode 100644 webui/templates/wireguard.html delete mode 100644 webui/templates/zones.html diff --git a/webui/server.py b/webui/server.py index 671ad23..4b6fb33 100644 --- a/webui/server.py +++ b/webui/server.py @@ -12,15 +12,14 @@ import os import signal import sys import time -from datetime import datetime from pathlib import Path -from typing import Any -from flask import Flask, render_template, request +from flask import Flask, abort, request +from werkzeug.middleware.proxy_fix import ProxyFix from daemon.client import get +from daemon.iface import GET_STATUS_ALL from lib.logging import setup_logging -from lib.network import get_config from webui.api.certs import bp as certs_bp from webui.api.dhcp import bp as dhcp_bp from webui.api.firewall import bp as firewall_bp @@ -79,6 +78,15 @@ signal.signal(signal.SIGHUP, _sighup_handler) app = Flask(__name__) app.config["SECRET_KEY"] = os.urandom(32).hex() +STATIC_DIR = Path(__file__).resolve().parent / "static" + +# Cache-control: short TTL in dev, aggressive caching in prod (versioned assets) +_DEV_MODE = bool(os.environ.get("VACUUM_WALL_DEV")) or False +app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000 + +# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + app.register_blueprint(firewall_bp, url_prefix="/api/firewall") app.register_blueprint(network_bp, url_prefix="/api/network") app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp") @@ -100,7 +108,6 @@ BLUEPRINTS = [ for name, _ in BLUEPRINTS: logger.info("Registered blueprint '%s' at /api/%s", name, name) - # --------------------------------------------------------------------------- # Request logging # --------------------------------------------------------------------------- @@ -132,387 +139,65 @@ def _log_request_finish(response): response.status_code, elapsed_ms, ) + + # Set cache headers: short in dev, long with staleness tolerance in prod + if response.content_type.startswith("text/html"): + # index.html: always short cache so browser revalidates + response.headers["Cache-Control"] = "no-cache" + elif response.content_type.startswith(("text/javascript", "text/css")): + if _DEV_MODE: + response.headers["Cache-Control"] = "max-age=5" + else: + response.headers["Cache-Control"] = ( + "public, max-age=31536000, stale-while-revalidate=86400" + ) + return response # --------------------------------------------------------------------------- -# Jinja2 custom filters +# API proxy routes # --------------------------------------------------------------------------- -@app.template_filter("timestamp") -def timestamp_filter(value): - """Convert an ISO-8601 timestamp string to ``YYYY-MM-DD HH:MM:SS``. +@app.route("/api/status/all") +def api_status_all(): + """Return aggregated status from all subsystems. - Args: - value: ISO timestamp string (may end with ``Z``). + Proxies the daemon's ``/status/all`` endpoint for SPA consumption. Returns: - Formatted date string, or original value on parse failure. - """ - if not value: - return "" - try: - dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) - return dt.strftime("%Y-%m-%d %H:%M:%S") - except (ValueError, TypeError): - return str(value) - - -@app.template_filter("bytes") -def bytes_filter(value): - """Convert a byte count to a human-readable size string (B/KB/MB…). - - Args: - value: Numeric byte count. - - Returns: - Formatted size string, or original value on parse failure. + JSON response with state data for all subsystems. """ try: - num = float(value) - except (ValueError, TypeError): - return str(value) - if num < 0: - return "0 B" - for unit in ("B", "KB", "MB", "GB", "TB"): - if abs(num) < 1024: - return f"{num:.1f} {unit}" - num /= 1024 - return f"{num:.1f} PB" - - -@app.template_filter("duration") -def duration_filter(value): - """Convert a duration in seconds to a human-readable string. - - Args: - value: Duration in seconds. - - Returns: - Formatted string (e.g. ``3d 2h 15m 30s``), or original value on failure. - """ - try: - total = int(float(value)) - except (ValueError, TypeError): - return str(value) - if total < 0: - return "0s" - parts = [] - days, remainder = divmod(total, 86400) - hours, remainder = divmod(remainder, 3600) - minutes, seconds = divmod(remainder, 60) - if days: - parts.append(f"{days}d") - if hours: - parts.append(f"{hours}h") - if minutes: - parts.append(f"{minutes}m") - parts.append(f"{seconds}s") - return " ".join(parts) - - -@app.template_filter("json_pretty") -def json_pretty_filter(value): - """Serialize *value* as indented JSON for template display. - - Args: - value: Any JSON-serializable object. - - Returns: - Pretty-printed JSON string with 2-space indent. - """ - import json - - try: - return json.dumps(value, indent=2, default=str) - except (TypeError, ValueError): - return str(value) - - -# --------------------------------------------------------------------------- -# Page routes -# --------------------------------------------------------------------------- - - -def _safely(fn, default=None): - """Call *fn* and return *default* on any exception. - - Args: - fn: Zero-argument callable to execute. - default: Fallback value returned when *fn* raises. - - Returns: - The result of ``fn()``, or *default* if an exception occurred. - """ - try: - return fn() + return {"ok": True, "data": get(GET_STATUS_ALL)} except Exception as exc: - logger.warning("WebUI data load failed: %s", exc) - return default + logger.warning("Status all failed: %s", exc) + return {"ok": False, "error": str(exc)}, 500 -def _get_service_status(dnsmasq_info, wg_info): - """Build a service status dict for the dashboard template. +# --------------------------------------------------------------------------- +# SPA catch-all +# --------------------------------------------------------------------------- - Args: - dnsmasq_info: Dnsmasq status payload from the daemon. - wg_info: WireGuard status payload from the daemon. - - Returns: - Dict mapping service names to ``{running: bool}``. - """ - services = {} - if dnsmasq_info: - services["Dnsmasq"] = { - "running": dnsmasq_info.get("service_active", False), - } - if wg_info: - services["WireGuard"] = { - "running": wg_info.get("up", False), - } - return services - - -def _fw_config_get() -> dict[str, Any]: - """Read the current firewall config from the daemon.""" - return get("/firewall/config") - - -def _load_status_all() -> dict[str, Any]: - """Load all subsystem status from the daemon in a single call.""" - return get("/status/all") +SPA_DIR = STATIC_DIR @app.route("/") -def root_redirect(): - """Redirect root URL to the dashboard. +@app.route("/") +def spa_page(path=""): + """Single-page application catch-all. - GET / - - Returns: - Redirect response to the dashboard page. + Serves ``index.html`` (rendered as a Jinja2 template) for all non-API, + non-static paths. The client-side router handles navigation and defaults + to ``#dashboard``. """ - from flask import redirect, url_for - - return redirect(url_for("dashboard")) - - -@app.route("/dashboard") -def dashboard(): - """Render the main dashboard overview page. - - GET / - - Template context: - active_zones (dict): Active firewalld zones and bound interfaces. - interfaces (list): Available network interfaces with zone bindings. - dnsmasq (dict): Dnsmasq status information. - domains (list): Configured proxy domains. - certs (list): ACME certificate inventory. - wg_status (dict): WireGuard tunnel status. - services (dict): Service running indicators (Dnsmasq, WireGuard). - firewall_config (dict): Declarative firewall JSON config. - firewall_pending (dict): Pending firewall rules awaiting apply. - """ - all_status = _safely(_load_status_all, {}) - fw_state = all_status.get("firewall", {}) or {} - dm_state = all_status.get("dnsmasq", {}) or {} - ng_state = all_status.get("nginx", {}) or {} - ac_state = all_status.get("acme", {}) or {} - wg_state = all_status.get("wireguard", {}) or {} - - active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()} - interfaces = fw_state.get("interfaces", []) - dnsmasq = dm_state.get("status", {}) - domains = ng_state.get("domains", []) - certs = ac_state.get("certs", []) - wg = wg_state.get("status", {}) - - return render_template( - "dashboard.html", - active_zones=active_zones, - interfaces=interfaces, - dnsmasq=dnsmasq, - domains=domains, - certs=certs, - wg_status=wg, - services=_get_service_status(dnsmasq, wg), - firewall_config=_safely(_fw_config_get, {}), - firewall_pending=fw_state.get("pending", {}), - ) - - -@app.route("/interfaces") -def interfaces_page(): - """Render the network interfaces management page. - - GET /interfaces - - Template context: - interfaces (list): Available network interfaces. - zones (list): Zone names bound to interfaces. - firewall_config (dict): Declarative firewall JSON config. - firewall_pending (dict): Pending firewall rules awaiting apply. - """ - all_status = _safely(_load_status_all, {}) - fw_state = all_status.get("firewall", {}) or {} - network_config = _safely(get_config, {}) - return render_template( - "interfaces.html", - interfaces=fw_state.get("interfaces", []), - network_config=network_config, - zones=fw_state.get("active_zones", {}).keys() or [], - firewall_config=_safely(_fw_config_get, {}), - firewall_pending=fw_state.get("pending", {}), - ) - - -@app.route("/zones") -def zones_page(): - """Render the firewall zones management page. - - GET /zones - - Template context: - zones (list): All zone configurations. - services (list): Available service identifiers for zone policies. - firewall_config (dict): Declarative firewall JSON config. - firewall_pending (dict): Pending firewall rules awaiting apply. - """ - all_status = _safely(_load_status_all, {}) - fw_state = all_status.get("firewall", {}) or {} - return render_template( - "zones.html", - zones=list(fw_state.get("zones", {}).values()), - services=fw_state.get("available_services", []), - firewall_config=_safely(_fw_config_get, {}), - firewall_pending=fw_state.get("pending", {}), - ) - - -@app.route("/rules") -def rules_page(): - """Render the firewall rich-rules editor page. - - GET /rules - - Template context: - zones (list): Zone names containing rich rules. - rules (dict | None): Zone name → rich rule mappings (``None`` if empty). - """ - all_status = _safely(_load_status_all, {}) - fw_state = all_status.get("firewall", {}) or {} - zones = list(fw_state.get("zones", {}).keys()) - rules: dict[str, list[str]] = {} - for zname, zcfg in fw_state.get("zones", {}).items(): - rr = zcfg.get("rich-rules", []) - if rr: - rules[zname] = rr - return render_template("rules.html", zones=zones, rules=rules or None) - - -@app.route("/nat") -def nat_page(): - """Render the NAT rules management page. - - GET /nat - - Template context: - zones (list): Zone configurations containing NAT rules. - """ - all_status = _safely(_load_status_all, {}) - fw_state = all_status.get("firewall", {}) or {} - return render_template("nat.html", zones=list(fw_state.get("zones", {}).values())) - - -@app.route("/dhcp") -def dhcp_page(): - """Render the DHCP/Dnsmasq configuration page. - - GET /dhcp - - Template context: - config (dict): Dnsmasq configuration settings. - status (dict): Dnsmasq runtime status. - leases (list): Current DHCP lease table. - """ - all_status = _safely(_load_status_all, {}) - dm_state = all_status.get("dnsmasq", {}) or {} - fw_state = all_status.get("firewall", {}) or {} - return render_template( - "dhcp.html", - config=dm_state.get("config", {}), - status=dm_state.get("status", {}), - leases=dm_state.get("leases", []), - interfaces=fw_state.get("interfaces", []), - ) - - -@app.route("/proxy") -def proxy_page(): - """Render the reverse proxy / SSL termination management page. - - GET /proxy - - Template context: - domains (list): Configured proxy domains with upstream targets. - config (dict): Nginx configuration settings. - """ - all_status = _safely(_load_status_all, {}) - ng_state = all_status.get("nginx", {}) or {} - return render_template( - "proxy.html", - domains=ng_state.get("domains", []), - config=ng_state.get("config", {}), - ) - - -@app.route("/certs") -def certs_page(): - """Render the SSL certificate management page. - - GET /certs - - Template context: - certs (list): ACME certificate inventory. - email (str): Configured ACME registration email. - """ - all_status = _safely(_load_status_all, {}) - ac_state = all_status.get("acme", {}) or {} - return render_template( - "certs.html", - certs=ac_state.get("certs", []), - email=ac_state.get("email", ""), - ) - - -@app.route("/wireguard") -def wireguard_page(): - """Render the WireGuard VPN management page. - - GET /wireguard - - Template context: - config (dict): WireGuard tunnel configuration. - status (dict): WireGuard runtime status. - """ - all_status = _safely(_load_status_all, {}) - wg_state = all_status.get("wireguard", {}) or {} - return render_template( - "wireguard.html", - config=wg_state.get("config", {}), - status=wg_state.get("status", {}), - ) - - -@app.route("/logs") -def logs_page(): - """Render the system logs viewer page. - - GET /logs - """ - return render_template("logs.html") + if path.startswith("api/") or path.startswith("static/"): + abort(404) + scheme = "wss" if request.is_secure else "ws" + ws_url = f"{scheme}://{request.host}/ws" + html = (SPA_DIR / "index.html").read_text() + return html.replace("__WS_URL__", ws_url) if __name__ == "__main__": diff --git a/webui/static/app.js b/webui/static/app.js index 94691c7..f426c9e 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -1,578 +1,105 @@ -// Toast notifications -const showToast = (message, type, duration = 4000) => { - const container = document.getElementById('toast-container'); - if (!container) return; - const toast = document.createElement('div'); - toast.className = 'toast toast-' + type; - toast.textContent = message; - container.appendChild(toast); - requestAnimationFrame(() => toast.classList.add('show')); - setTimeout(() => { - toast.classList.remove('show'); - setTimeout(() => toast.remove(), 300); - }, duration); +import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=4'; + +import DashboardPage from '/static/pages/dashboard.js?v=4'; +import InterfacesPage from '/static/pages/interfaces.js?v=4'; +import ZonesPage from '/static/pages/zones.js?v=4'; +import RulesPage from '/static/pages/rules.js?v=4'; +import NatPage from '/static/pages/nat.js?v=4'; +import DhcpPage from '/static/pages/dhcp.js?v=4'; +import ProxyPage from '/static/pages/proxy.js?v=4'; +import CertsPage from '/static/pages/certs.js?v=4'; +import WireguardPage from '/static/pages/wireguard.js?v=4'; +import LogsPage from '/static/pages/logs.js?v=4'; +import NotFoundPage from '/static/pages/notfound.js?v=4'; + +/* ── Navigation items ──────────────────────────────────────── */ +const Nav = [ + { path: '/dashboard', label: 'Dashboard' }, + { path: '/interfaces', label: 'Interfaces' }, + { path: '/zones', label: 'Zones' }, + { path: '/rules', label: 'Rules' }, + { path: '/nat', label: 'NAT' }, + { path: '/dhcp', label: 'DHCP' }, + { path: '/proxy', label: 'Proxy' }, + { path: '/certs', label: 'Certs' }, + { path: '/wireguard', label: 'WireGuard' }, + { path: '/logs', label: 'Logs' }, +]; + +/* ── Page map ──────────────────────────────────────────────── */ +const Pages = { + dashboard: DashboardPage, + interfaces: InterfacesPage, + zones: ZonesPage, + rules: RulesPage, + nat: NatPage, + dhcp: DhcpPage, + proxy: ProxyPage, + certs: CertsPage, + wireguard: WireguardPage, + logs: LogsPage, }; -const showSuccessToast = (msg) => showToast(msg, 'success'); - -const showErrorToast = (msg) => showToast(msg, 'error'); - -const showWarningToast = (msg) => showToast(msg, 'warning'); - -// Modal helpers -const openModal = (id) => { - const el = document.getElementById(id); - if (el) el.classList.add('active'); +/* ── Router ────────────────────────────────────────────────── */ +const router = { + state: reactive({ path: location.hash.slice(1) || '/dashboard' }), + component() { + const name = this.state.path.replace(/^\//, ''); + const page = Pages[name] || NotFoundPage; + return hComp(page, this.state.path); + }, }; -const closeModal = (id) => { - const el = document.getElementById(id); - if (el) el.classList.remove('active'); -}; - -// Tab switching -let switchTab = (tabName) => { - document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); - document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')); - document.getElementById('tab-' + tabName).classList.add('active'); - const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]'); - if (clickedTab) clickedTab.classList.add('active'); -}; - -// Refresh a container from a JSON GET endpoint using a renderer callback -const refreshTable = (url, container, renderer) => { - fetch(url) - .then(r => r.json()) - .then(data => { - const json = data.ok ? data.data : data; - container.innerHTML = renderer(json); - htmx.process(container); - }) - .catch(() => {}); -}; - -// HTMX event handlers -document.body.addEventListener('htmx:afterSwap', (evt) => { - const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast'); - if (toastHeader) { - const parts = toastHeader.split(':'); - const msg = parts.slice(1).join(':').trim(); - showToast(msg, parts[0]?.trim() || 'info'); - } +window.location.hash || (window.location.hash = router.state.path); +window.addEventListener('hashchange', () => { + router.state.path = location.hash.slice(1) || '/dashboard'; }); -document.body.addEventListener('htmx:responseError', (evt) => { - const status = evt.detail.xhr?.status || 0; - const json = evt.detail.xhr?.response; - let msg = 'Request failed (' + status + ')'; - try { - const parsed = JSON.parse(json); - if (parsed.error) msg = parsed.error; - } catch (e) {} - showToast(msg, 'error'); -}); - -document.body.addEventListener('htmx:beforeRequest', (evt) => { - const btn = evt.target.closest('.btn'); - if (btn) { - btn.dataset.originalText = btn.textContent; - btn.disabled = true; - btn.textContent = 'Loading...'; - } -}); - -document.body.addEventListener('htmx:afterRequest', (evt) => { - const btn = evt.target.closest('.btn'); - if (btn && btn.dataset.originalText !== undefined) { - btn.disabled = false; - btn.textContent = btn.dataset.originalText; - delete btn.dataset.originalText; - } -}); - -// Keyboard: Escape closes all modals -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active')); - } -}); - -// -------- Renderer helpers for htmx-driven DOM updates -------- - -const renderZones = (data) => { - const active = Array.isArray(data) ? data : (data.active || []); - if (!active.length) return '
No zones configured. Create a zone to get started.
'; - return active.map(zone => - '
' + - '
' + - '

' + escHtml(zone.name) + '

' + - '
' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '
' + - '
Interfaces
' + - (zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '' + escHtml(i) + '').join('') : 'None') + - '
Services
' + - (zone.services && zone.services.length ? zone.services.map(s => '' + escHtml(s) + '').join('') : 'None') + - '
' + - '
' + - '
' - ).join(''); -}; - -const renderRules = (data) => { - let html = ''; - let zoneRules = {}; - const cfgZones = data && data.zones ? data.zones : null; - if (cfgZones) { - Object.keys(cfgZones).forEach(zname => { - const rr = cfgZones[zname].rich_rules || []; - if (rr.length) zoneRules[zname] = rr; - }); - } else { - zoneRules = data || {}; - } - Object.keys(zoneRules).forEach(zone => { - let entries = zoneRules[zone]; - if (!Array.isArray(entries)) entries = []; - html += '

Zone: ' + escHtml(zone || '(default)') + '

'; - if (entries.length) { - html += ''; - entries.forEach((entry, i) => { - let ruleId, ruleText; - if (typeof entry === 'object' && entry.rule) { - ruleId = entry.id; - ruleText = entry.rule; - } else { - ruleId = null; - ruleText = String(entry); - } - html += '' + - '' + - ''; - }); - html += '
#RuleAction
' + (i + 1) + '' + escHtml(ruleText) + '
' + - '
'; - } else { - html += '
No rich rules configured for this zone.
'; - } - html += '
'; - }); - return html || '
No rules loaded.
'; -}; - -const renderForwards = (forwards) => { - if (!forwards.length) return 'No port forwarding rules configured'; - return forwards.map(fwd => { - const proto = fwd['proxy-protocol'] || fwd.proto; - return '' + escHtml(fwd.zone) + '' + - '' + escHtml(proto) + '' + - '' + fwd.port + '' + escHtml(fwd['to-addr'] || fwd.toaddr) + '' + - '' + (fwd['to-port'] || fwd.toport || '-') + '' + - '
' + - '
'; - }).join(''); -}; - -const renderForwardsFromConfig = (data) => { - const zones = data.zones || {}; - const forwards = []; - Object.keys(zones).forEach(name => { - zones[name].forward_ports = zones[name].forward_ports || []; - zones[name].forward_ports.forEach(fwd => { - forwards.push({ - zone: name, - 'proxy-protocol': fwd['proxy-protocol'] || fwd.proto, - port: fwd.port, - 'to-addr': fwd['to-addr'] || fwd.toaddr, - 'to-port': fwd['to-port'] || fwd.toport - }); - }); - }); - return renderForwards(forwards); -}; - -const renderRanges = (ranges) => { - if (!ranges.length) return 'No DHCP ranges configured'; - return ranges.map(rng => - '' + escHtml(rng.interface || '(global)') + '' + - '' + escHtml(rng.start) + '' + escHtml(rng.end) + '' + - '' + escHtml(rng.lease_time || '1h') + '' + - '
' + - '
' - ).join(''); -}; - -const renderStaticLeases = (leases) => { - if (!leases.length) return 'No static leases configured'; - return leases.map(lease => - '' + escHtml(lease.mac) + '' + escHtml(lease.ip) + '' + - '' + escHtml(lease.hostname || '-') + '' + - '
' + - '
' - ).join(''); -}; - -const renderDnsRecords = (records) => { - if (!records.length) return 'No custom DNS records'; - return records.map(rec => - '' + escHtml(rec.name || 'unnamed') + '' + - '' + escHtml(rec.address || '-') + '' + - '
' + - '
' - ).join(''); -}; - -const renderDomains = (domains) => { - if (!domains.length) return 'No proxy domains configured. Add a domain to start terminating SSL.'; - return domains.map(d => { - let certHtml = '' + (d.cert_status || 'No cert') + ''; - if (d.cert_status === 'expired') certHtml = 'Expired'; - else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = 'Valid'; - else if (typeof d.days_remaining === 'number') { - if (d.days_remaining <= 0) certHtml = 'Expired'; - else if (d.days_remaining <= 30) certHtml = '' + d.days_remaining + 'd'; - else certHtml = 'Valid'; - } - return '' + escHtml(d.domain) + '' + - '' + escHtml(d.backend_host || '-') + '' + - '' + (d.backend_port || '-') + '' + - '' + escHtml(d.protocol || 'http') + '' + - '' + certHtml + '' + - '
' + - '' + - '
' + - '
'; - }).join(''); -}; - -const renderPeers = (peers) => { - if (!peers.length) return 'No peers configured. Add a peer above.'; - return peers.map(peer => - '' + - '' + escHtml(peer.name || 'unnamed') + '' + - '' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...' + - '' + escHtml(peer.allowed_ips || '-') + '' + - '' + escHtml(peer.endpoint || '-') + '' + - '' + escHtml(peer.latest_handshake || 'Never') + '' + - '
Recv: ' + escHtml(peer.transfer_recv || '0') + '
Sent: ' + escHtml(peer.transfer_sent || '0') + '
' + - '
' + - '' + - '
' + - '
' - ).join(''); -}; - -const renderCerts = (certs) => { - if (!certs.length) return 'No certificates found. Issue a certificate to get started.'; - return certs.map(cert => { - const days = cert.days_remaining; - let badgeHtml; - if (cert.expired || (days !== undefined && days <= 0)) { - badgeHtml = 'Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + ''; - } else if (days !== undefined && days <= 30) { - badgeHtml = '' + days + ' days'; - } else { - badgeHtml = '' + (days !== undefined ? days + ' days' : 'N/A') + ''; - } - return '' + escHtml(cert.domain || 'unknown') + '' + - '' + escHtml(cert.issuer || '-') + '' + - '' + escHtml(cert.expiry || 'N/A') + '' + - '' + badgeHtml + '' + - '
' + - '
'; - }).join(''); -}; - -const renderInterfaces = (interfaces) => { - if (!interfaces.length) return 'No interfaces found'; - return interfaces.map(iface => { - const zoneOptions = (iface.zones || []).map(z => - '' - ).join(''); - return '' + escHtml(iface.name) + '' + - '' + escHtml(iface.mac || 'N/A') + '' + - '' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '' + - '' + - (iface.state === 'up' ? 'Up' : 'Down') + '' + - ''; - }).join(''); -}; - -const assignZone = (ifaceName, selectEl) => { - fetch('/api/firewall/zones/' + encodeURIComponent(selectEl.value) + '/interfaces', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ interfaces: [ifaceName] }) - }) - .then(r => { - if (r.ok) { - showSuccessToast(ifaceName + ' assigned to ' + selectEl.value); - refreshTable('/api/firewall/interfaces', document.getElementById('interface-list'), renderInterfaces); - } - else return r.json().then(j => { throw new Error(j.error || r.statusText); }); - }) - .catch(e => { showErrorToast(e.message); }); -}; - -const escHtml = (s) => { - const div = document.createElement('div'); - div.appendChild(document.createTextNode(s)); - return div.innerHTML; -}; - -const escAttr = (s) => { - return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(//g,'>'); -}; - -// ─── Certificate Issue Wizard ──────────────────────────────────────── - -let _issuePollHandle = null; -let _issueRequestId = null; - -function closeIssueWizard() { - if (_issuePollHandle) { - clearInterval(_issuePollHandle); - _issuePollHandle = null; - } - _issueRequestId = null; - resetIssueWizard(); - closeModal('issue-cert-modal'); +/* ── Sidebar component ─────────────────────────────────────── */ +function Sidebar() { + const current = router.state.path; + return h('div', { class: 'sidebar' }, + h('div', { class: 'logo' }, 'Vacuum Wall'), + h('nav', null, + Nav.map(item => + Link({ + path: item.path, + class: current === item.path ? 'active' : '', + children: [item.label], + }), + ), + ), + ); } -function resetIssueWizard() { - document.getElementById('cert-wizard-input').style.display = ''; - document.getElementById('cert-wizard-progress').style.display = 'none'; - document.getElementById('cert-check-results').style.display = 'none'; - document.getElementById('cert-check-btn').style.display = ''; - document.getElementById('cert-issue-btn').style.display = 'none'; - document.getElementById('cert-close-progress').style.display = 'none'; +/* ── Route component wrapper ───────────────────────────────── */ +function RouteComponent() { + return router.component(); } -function validateCertIssue() { - const domain = document.getElementById('cert-domain').value.trim(); - if (!domain) { - showErrorToast('Domain is required'); - return; +/* ── App layout ────────────────────────────────────────────── */ +function AppLayout() { + return [ + h('div', { class: 'layout' }, + Sidebar(), + h('div', { class: 'main' }, + RouteComponent(), + ), + ), + ToastContainer(), + ]; +} + +/* ── Init ──────────────────────────────────────────────────── */ +export function initApp() { + const appEl = document.getElementById('app'); + if (appEl) { + render(appEl, AppLayout); } - const email = document.getElementById('cert-email').value.trim() || undefined; - - const checkBtn = document.getElementById('cert-check-btn'); - checkBtn.disabled = true; - checkBtn.textContent = 'Checking...'; - - fetch('/api/certs/validate', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ domain, email }) - }) - .then(r => r.json()) - .then(data => { - checkBtn.disabled = false; - checkBtn.textContent = 'Check'; - - const result = data.ok ? data.data : data; - renderChecks(result.checks); - - if (result.ready) { - document.getElementById('cert-check-btn').style.display = 'none'; - document.getElementById('cert-issue-btn').style.display = ''; - } else { - document.getElementById('cert-issue-btn').style.display = 'none'; - } - }) - .catch(e => { - checkBtn.disabled = false; - checkBtn.textContent = 'Check'; - showErrorToast('Validation failed: ' + e.message); - }); + // Defer connect() after the first render microtask settles to prevent + // the initial requestUpdate() from triggering a second commit while + // the vnode tree is still being finalized. + setTimeout(connect, 0); } -function renderChecks(checks) { - const container = document.getElementById('cert-checks-list'); - const resultsDiv = document.getElementById('cert-check-results'); - resultsDiv.style.display = ''; - - container.innerHTML = checks.map(c => { - let icon, badge; - if (c.passed) { - icon = '✓'; - badge = c.blocking ? 'badge-success' : 'badge-info'; - } else { - icon = '✗'; - badge = 'badge-danger'; - } - return '
' + - '' + icon + '' + - '' + escHtml(c.name).replace(/_/g, ' ') + '' + - '' + escHtml(c.message || '') + '' + - '
'; - }).join(''); -} - -function startCertIssue() { - const domain = document.getElementById('cert-domain').value.trim(); - const email = document.getElementById('cert-email').value.trim() || undefined; - - document.getElementById('cert-wizard-input').style.display = 'none'; - document.getElementById('cert-wizard-progress').style.display = ''; - document.getElementById('cert-steps-list').innerHTML = '
Starting certificate issuance…
'; - - fetch('/api/certs/issue/start', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ domain, email }) - }) - .then(r => r.json()) - .then(data => { - const result = data.ok ? data.data : data; - _issueRequestId = result.request_id; - if (!result.request_id) throw new Error('No request_id returned'); - - // If issuance already exists for this domain, follow the existing request - startIssuePoll(result.request_id); - }) - .catch(e => { - showErrorToast('Failed to start issuance: ' + e.message); - // Fall back to input phase - document.getElementById('cert-wizard-input').style.display = ''; - document.getElementById('cert-wizard-progress').style.display = 'none'; - }); -} - -function startIssuePoll(requestId) { - _issueRequestId = requestId; - _issuePollHandle = setInterval(() => pollIssueStatus(requestId), 2000); - // Also poll immediately - pollIssueStatus(requestId); -} - -function pollIssueStatus(requestId) { - fetch('/api/certs/issue/' + encodeURIComponent(requestId)) - .then(r => r.json()) - .then(data => { - const result = data.ok ? data.data : data; - renderIssueSteps(result.steps, result.status); - - if (result.status === 'completed') { - clearInterval(_issuePollHandle); - _issuePollHandle = null; - document.getElementById('cert-close-progress').style.display = ''; - showSuccessToast('Certificate issued for ' + result.domain); - } else if (result.status === 'failed') { - clearInterval(_issuePollHandle); - _issuePollHandle = null; - // Show failed — user can see which step failed - document.getElementById('cert-close-progress').style.display = ''; - showErrorToast('Certificate issuance failed for ' + result.domain); - } - }) - .catch(e => { - // Don't poll on error — but keep trying since request might still be running - }); -} - -function renderIssueSteps(steps, status) { - const container = document.getElementById('cert-steps-list'); - if (!steps || !steps.length) { - container.innerHTML = '
Pending…
'; - return; - } - container.innerHTML = steps.map(s => { - let icon; - if (s.status === 'done') icon = ''; - else if (s.status === 'running') icon = ''; - else if (s.status === 'error') icon = ''; - else icon = ''; - - return '
' + - icon + - '' + escHtml(s.label) + '' + - (s.status === 'running' ? '(in progress…)' : - s.status === 'error' ? '' + escHtml(s.message || 'failed') + '' : - 'done') + - '
'; - }).join(''); - - if (status === 'completed') { - container.innerHTML += '
✓ Certificate issued
'; - } -} - -// ─── Network Interface Config helpers ───────────────────────────── - -const saveInterfaceConfig = (ifaceName) => { - const addrs = (document.getElementById('addrs-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean); - const gateway = (document.getElementById('gw-' + ifaceName)?.value || '').trim(); - const dns = (document.getElementById('dns-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean); - const routesContainer = document.getElementById('routes-' + ifaceName); - let routes = []; - if (routesContainer) { - routes = Array.from(routesContainer.querySelectorAll('.route-row')).map(row => { - const dest = (row.querySelector('.route-dest')?.value || '').trim(); - const gw = (row.querySelector('.route-gw')?.value || '').trim(); - if (dest || gw) return { destination: dest, gateway: gw }; - return null; - }).filter(Boolean); - } - fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ addresses: addrs, gateway: gateway || undefined, dns: dns, routes: routes }) - }) - .then(r => r.json()) - .then(data => { - if (data.ok && data.data && data.data.applied === false) { - showWarningToast('Config saved for ' + ifaceName + ' (system deploy skipped — not running as privileged)'); - } else if (data.ok) { - showSuccessToast('Config saved for ' + ifaceName); - } else { - showErrorToast(data.error || 'Failed to save config'); - } - }) - .catch(e => { showErrorToast('Failed to save config: ' + e.message); }); -}; - -const reloadNetworkd = (ifaceName) => { - fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName) + '/reload', { method: 'POST' }) - .then(r => r.json()) - .then(data => { - if (data.ok) { - showSuccessToast('Network reload triggered for ' + ifaceName); - } else { - showErrorToast(data.error || 'Reload failed'); - } - }) - .catch(e => { showErrorToast('Reload failed: ' + e.message); }); -}; - -const toggleRoutes = (ifaceName) => { - const panel = document.getElementById('routes-panel-' + ifaceName); - if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; -}; - -const addRoute = (ifaceName) => { - const container = document.getElementById('routes-' + ifaceName); - if (!container) return; - const row = document.createElement('div'); - row.className = 'route-row'; - row.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:4px;'; - row.innerHTML = '' + - '' + - ''; - container.appendChild(row); -}; - -const renderNetworkRoutes = (routes, containerId) => { - const container = document.getElementById(containerId); - if (!container) return; - const safe = (s) => escHtml(String(s || '')); - container.innerHTML = (routes || []) - .map((r, i) => - '
' + - '' + - '' + - '
' - ).join('') || '
No static routes
'; -}; - - +document.addEventListener('DOMContentLoaded', initApp); diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js new file mode 100644 index 0000000..32b4f6e --- /dev/null +++ b/webui/static/hoover/api.js @@ -0,0 +1,95 @@ +/** + * Hoover — api.js + * + * JSON-friendly fetch wrapper with automatic header management. + * Toast notification system with auto-dismiss. + * ToastContainer component for rendering queued toasts. + */ + +import { h } from './vdom.js'; + +/** + * JSON-friendly fetch wrapper. + * + * Automatically sets Content-Type for object bodies, parses JSON + * responses, and normalises the result to { ok, data, error, status }. + * + * @param {string} url – Target URL + * @param {object} [options] – Fetch options (method, body, headers, …) + * @returns {Promise<{ok, data, error, status}>} + */ +export async function apiFetch(url, options = {}) { + const { method = 'GET', body, ...opts } = options; + const headers = { 'Accept': 'application/json', ...opts.headers }; + + if (body && typeof body === 'object' && !(body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(body); + } + + try { + const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); + if (res.status === 401) { + window.location.reload(); + return { ok: false, data: null, error: 'Session expired', status: 401 }; + } + const json = await res.json(); + + if (!res.ok) { + return { ok: false, data: null, error: json.error || `HTTP ${res.status}`, status: res.status }; + } + return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: res.status }; + } catch (e) { + return { ok: false, data: null, error: e.message || 'Network error', status: 0 }; + } +} + +/** ─── Toast notifications ────────────────────────────────── */ + +/** Toast notification queue. Exported for ToastContainer component. */ +export const _toasts = []; +const _toastIds = { next: 1 }; + +/** + * Show a toast notification. Auto-dismisses after `duration` ms. + * + * @param {string} message – Toast text + * @param {string} [type] – 'info' | 'success' | 'error' | 'warning' + * @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite) + * @returns {number} id + */ +export function toast(message, type = 'info', duration = 4000) { + const id = _toastIds.next++; + _toasts.push({ id, message, type, createdAt: Date.now(), duration }); + + if (duration > 0) setTimeout(() => dismissToast(id), duration); + return id; +} + +/** + * Dismiss a toast by id. + */ +export function dismissToast(id) { + const idx = _toasts.findIndex(t => t.id === id); + if (idx !== -1) _toasts.splice(idx, 1); +} + +/** + * Render the queued toast notifications. + * + * @returns {VNode} – Toast container (empty text node when no toasts) + */ +export function ToastContainer() { + if (!_toasts.length) return h('#text', ''); + + const clsMap = { info: 'toast-info', success: 'toast-success', error: 'toast-error', warning: 'toast-warning' }; + + return h('div', { class: 'toast-container' }, + ..._toasts.map(t => + h('div', { class: `toast ${clsMap[t.type] || clsMap.info}`, 'on:click': () => dismissToast(t.id) }, + h('span', null, t.message), + h('button', { class: 'toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); } }, '\u00d7'), + ), + ), + ); +} diff --git a/webui/static/hoover/component.js b/webui/static/hoover/component.js new file mode 100644 index 0000000..056b1ed --- /dev/null +++ b/webui/static/hoover/component.js @@ -0,0 +1,142 @@ +/** + * Hoover — component.js + * + * Component wrapper: definePage, lifecycle hooks, state caching. + * + * definePage wraps a page definition into a renderer function compatible + * with hoover's render engine. Handles reactive state creation, WS + * subscription registration on mount, and cleanup on unmount. + * + * Usage: + * export default definePage({ + * init() { return { data: null, loading: true, error: null }; }, + * subscribe: ['*'], // WS topics to subscribe to + * async load(state) { ... }, // called on mount + * render(state) { return [vnodes], + * }); + */ + +import { reactive } from './reactivity.js'; +import { h } from './vdom.js'; +import { _compExpandedCache } from './render.js'; + +/** + * Registry of mounted components: key → { state, subscriptions, loadAbort, entry } + */ +const _mounted = new Map(); + +/** + * External subscribe function from websocket.js. + * Set via setSubscribeFn() when the websocket module initializes. + */ +let _subscribeFn = null; + +export function setSubscribeFn(fn) { + _subscribeFn = fn; +} + +/** + * Define a page component. + * + * @param {object} def — Page definition + * @param {function} def.init — Return initial state object + * @param {string[]} [def.subscribe] — WS topics to subscribe to on mount + * @param {function} def.load — Async function to load data into state + * @param {function} def.render — Render function that returns vnodes + * @returns {object} — Component renderer compatible with h('#comp', ...) + */ +export function definePage(def) { + const state = reactive(def.init()); + + const renderer = () => { + return def.render(state); + }; + + renderer._pageDef = { + state, + subscribe: def.subscribe || [], + load: def.load || null, + onUnmount: def.onUnmount || null, + }; + + return renderer; +} + +/** + * Mount a page component. Called by the render engine when a #comp vnode + * enters the tree for the first time. + */ +export function mountComponent(key, renderer) { + // Prevent duplicate mounts when normalization loses #comp tracking + if (_mounted.has(key)) return; + + const pd = renderer._pageDef; + if (!pd) return; + + const entry = { + state: pd.state, + subscriptions: [], + loadAbort: null, + }; + + _mounted.set(key, entry); + + // Fire load + if (pd.load) { + const abortController = new AbortController(); + entry.loadAbort = abortController; + pd.load(pd.state, abortController); + } + + // Register WS subscriptions + if (_subscribeFn && pd.subscribe.length) { + for (const topic of pd.subscribe) { + const unsub = _subscribeFn(renderer, topic, pd.load, pd.state); + if (unsub) entry.subscriptions.push(unsub); + } + } +} + +/** + * Unmount a page component. Called by the render engine when a #comp vnode + * is removed from the tree. + */ +export function unmountComponent(key, renderer) { + const entry = _mounted.get(key); + if (!entry) return; + + const pd = renderer._pageDef; + + // Cancel load + if (entry.loadAbort) { + entry.loadAbort.abort(); + } + + // Unsubscribe from WS + for (const unsub of entry.subscriptions) { + try { unsub(); } catch (_) {} + } + + // Fire custom onUnmount + if (pd.onUnmount) { + try { pd.onUnmount(entry.state); } catch (_) {} + } + + _compExpandedCache.delete(key); + _mounted.delete(key); +} + +/** + * Get the state of a mounted component. + */ +export function getComponentState(key) { + const entry = _mounted.get(key); + return entry ? entry.state : null; +} + +/** + * Create a component vnode that the render engine will wire up to lifecycle. + */ +export function hComp(renderer, key) { + return h('#comp', { component: renderer, key }, []); +} diff --git a/webui/static/hoover/components/data.js b/webui/static/hoover/components/data.js new file mode 100644 index 0000000..a4610fd --- /dev/null +++ b/webui/static/hoover/components/data.js @@ -0,0 +1,59 @@ +/** + * Hoover — components/data.js + * + * Data display components: Badge, StatusDot, Empty, Card. + */ + +import { h } from '../vdom.js'; + +/** + * Colored badge/span. + * + * @param {object} props + * @param {string} props.text – Badge text + * @param {string} [props.variant] – 'info' | 'success' | 'warning' | 'danger' + */ +export function Badge(props = {}) { + return h('span', { class: `badge badge-${props.variant || 'info'}` }, String(props.text || '')); +} + +/** + * Status indicator dot. + * + * @param {object} props + * @param {string} props.status – 'success' | 'up' | 'danger' | 'down' | 'pending' + */ +export function StatusDot(props = {}) { + const v = ['success', 'up'].includes(props.status) ? 'up' : + ['danger', 'down'].includes(props.status) ? 'down' : 'pending'; + return h('span', { class: `status-dot status-${v}` }); +} + +/** + * Empty-state placeholder card. + * + * @param {object} props + * @param {string} [props.text] + */ +export function Empty(props = {}) { + return h('div', { class: 'card' }, + h('div', { class: 'text-muted text-sm' }, props.text || 'No data available'), + ); +} + +/** + * Card wrapper with optional header and body content. + * + * @param {object} props + * @param {string} [props.header] + * @param {VNode[]} [props.children] + */ +export function Card(props = {}) { + if (props.header) { + return h('div', { class: 'card' }, + h('div', { class: 'card-header' }, props.header), + h('div', { class: 'card-body' }, props.children || []), + ); + } + return h('div', { class: 'card' }, props.children || []); +} diff --git a/webui/static/hoover/components/layout.js b/webui/static/hoover/components/layout.js new file mode 100644 index 0000000..b5caecb --- /dev/null +++ b/webui/static/hoover/components/layout.js @@ -0,0 +1,26 @@ +/** + * Hoover — components/layout.js + * + * Layout components: PageHeader for page titles with optional subtitles + * and action buttons. + */ + +import { h } from '../vdom.js'; + +/** + * Page header with title, optional subtitle, and action buttons. + * + * @param {object} props + * @param {string} props.title + * @param {string} [props.subtitle] + * @param {VNode} [props.actions] + */ +export function PageHeader(props = {}) { + return h('div', { class: 'page-header' }, + h('div', null, + h('h1', null, props.title || ''), + props.subtitle ? h('div', { class: 'subtitle' }, props.subtitle) : null, + ), + props.actions ? h('div', { class: 'page-actions' }, props.actions) : null, + ); +} diff --git a/webui/static/hoover/components/modal.js b/webui/static/hoover/components/modal.js new file mode 100644 index 0000000..7f90e46 --- /dev/null +++ b/webui/static/hoover/components/modal.js @@ -0,0 +1,103 @@ +/** + * Hoover — components/modal.js + * + * Modal overlay system: openModal, closeModal, closeAllModals, formModal. + * Renders directly into #modal-root using DOM manipulation (not vdom) to + * avoid fighting with the main render cycle. + */ + +import { esc } from '../helpers.js'; +import { att_esc } from '../helpers.js'; + +const _modalQueue = []; + +function _renderModals() { + const root = document.getElementById('modal-root'); + if (!root) return; + root.innerHTML = ''; + _modalQueue.forEach((m, idx) => { + const wrap = document.createElement('div'); + wrap.className = 'modal-overlay active'; + wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); }; + const content = document.createElement('div'); + content.className = 'modal'; + content.onclick = (e) => e.stopPropagation(); + if (m.renderFn) { + try { m.renderFn(content, idx); } + catch (err) { content.textContent = err.message; } + } + wrap.appendChild(content); + root.appendChild(wrap); + }); +} + +/** + * Open a modal dialog. + * + * @param {function} renderFn – (contentEl, idx) => void, renders into contentEl + */ +export function openModal(renderFn) { + _modalQueue.push({ renderFn, id: _modalQueue.length }); + _renderModals(); +} + +/** + * Close a modal by index. Closes the topmost modal if index is omitted. + * + * @param {number} [idx] + */ +export function closeModal(idx) { + if (idx === undefined) idx = _modalQueue.length - 1; + if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1); + _renderModals(); +} + +/** + * Close all open modals. + */ +export function closeAllModals() { + _modalQueue.length = 0; + _renderModals(); +} + +/** + * Render a standard modal layout: title, form fields, action buttons. + * + * @param {HTMLElement} inner – Modal content element to fill + * @param {string} title – Modal title + * @param {object[]} fields – Form field descriptors + * @param {object[]} actions – Action button descriptors + * + * Field shape: + * { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] } + * + * Action shape: + * { label, cls, action, handler } + */ +export function formModal(inner, title, fields, actions) { + inner.innerHTML = ''; + + actions.forEach(a => { + const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]'); + if (btn) btn.addEventListener('click', a.handler); + }); +} diff --git a/webui/static/hoover/components/toast.js b/webui/static/hoover/components/toast.js new file mode 100644 index 0000000..ac85be4 --- /dev/null +++ b/webui/static/hoover/components/toast.js @@ -0,0 +1,40 @@ +/** + * Hoover — components/toast.js + * + * ToastContainer component that renders queued toast notifications. + * Uses the toast/dismissToast state from api.js. + */ + +import { h } from '../vdom.js'; +import { _toasts, dismissToast } from '../api.js'; + +/** + * Render all pending toast notifications. + * + * @returns {VNode} + */ +export function ToastContainer() { + if (!_toasts.length) return h('#text', ''); + + const clsMap = { + info: 'toast-info', + success: 'toast-success', + error: 'toast-error', + warning: 'toast-warning', + }; + + return h('div', { class: 'toast-container' }, + ..._toasts.map(t => + h('div', { + class: `toast ${clsMap[t.type] || clsMap.info}`, + 'on:click': () => dismissToast(t.id), + }, + h('span', null, t.message), + h('button', { + class: 'toast-close', + 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); }, + }, '\u00d7'), + ), + ), + ); +} diff --git a/webui/static/hoover/helpers.js b/webui/static/hoover/helpers.js new file mode 100644 index 0000000..508ac99 --- /dev/null +++ b/webui/static/hoover/helpers.js @@ -0,0 +1,51 @@ +/** + * Hoover — helpers.js + * + * Shared utilities: text escaping, attribute escaping, DOM value helpers, + * zone parsing, form utilities. + */ + +/** + * Escape text for safe HTML output. + * Appends the string to a temporary div and reads innerHTML, + * which safely escapes all HTML special characters. + */ +export function esc(s) { + const d = document.createElement('div'); + d.append(String(s ?? '')); + return d.innerHTML; +} + +/** + * Escape a string for safe use in HTML attributes. + */ +export function att_esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} + +/** + * URL-encode a string. + */ +export const enc = encodeURIComponent; + +/** + * Get the value of a DOM element by ID. + */ +export function $val(id) { + return document.getElementById(id)?.value; +} + +/** + * Parse zone data from various API response shapes into a flat string array. + */ +export function parseZones(data) { + let z = data?.active || data?.zones || []; + if (typeof z === 'object' && !Array.isArray(z)) + z = Object.values(z).map(i => i?.name || i); + return Array.isArray(z) ? z : []; +} diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js new file mode 100644 index 0000000..db860d8 --- /dev/null +++ b/webui/static/hoover/index.js @@ -0,0 +1,41 @@ +/** + * Hoover — index.js + * + * Barrel export of all public Hoover APIs. + */ + +/* ── Reactivity ──────────────────────────────────────────────── */ +export { reactive, requestUpdate } from './reactivity.js'; + +/* ── VDOM ────────────────────────────────────────────────────── */ +export { h } from './vdom.js'; + +/* ── Render ──────────────────────────────────────────────────── */ +export { render } from './render.js'; + +/* ── Component ───────────────────────────────────────────────── */ +export { definePage, hComp } from './component.js'; + +/* ── Router ──────────────────────────────────────────────────── */ +export { createRouter, Link } from './router.js'; + +/* ── WebSocket ───────────────────────────────────────────────── */ +export { connect, onMessage } from './websocket.js'; + +/* ── API & Toast ─────────────────────────────────────────────── */ +export { apiFetch, toast, dismissToast } from './api.js'; + +/* ── Helpers ─────────────────────────────────────────────────── */ +export { esc, att_esc, enc, $val, parseZones } from './helpers.js'; + +/* ── UI Components: Layout ───────────────────────────────────── */ +export { PageHeader } from './components/layout.js'; + +/* ── UI Components: Data ─────────────────────────────────────── */ +export { Badge, StatusDot, Empty, Card } from './components/data.js'; + +/* ── UI Components: Modal ────────────────────────────────────── */ +export { openModal, closeModal, closeAllModals, formModal } from './components/modal.js'; + +/* ── UI Components: Toast ────────────────────────────────────── */ +export { ToastContainer } from './components/toast.js'; diff --git a/webui/static/hoover/reactivity.js b/webui/static/hoover/reactivity.js new file mode 100644 index 0000000..679ab92 --- /dev/null +++ b/webui/static/hoover/reactivity.js @@ -0,0 +1,59 @@ +/** + * Hoover — reactivity.js + * + * Reactive Proxy state + batched render requests via queueMicrotask. + * Multiple property mutations in the same microtask tick produce a single + * render cycle across all registered render roots. + */ + +/** + * Global flag to prevent duplicate microtask scheduling. + */ +let _scheduled = false; + +/** + * Callback invoked by render.js to perform the actual batched re-render. + * Set via setCommitFn() during render engine initialization. + */ +let _commitFn = null; + +/** + * Register the commit callback that performs batched re-renders. + * Called by render.js during initialization. + */ +export function setCommitFn(fn) { + _commitFn = fn; +} + +/** + * Schedule a single batched re-render for all active render roots. + * Multiple reactive property mutations in the same tick produce one diff pass. + */ +export function requestUpdate() { + if (_scheduled) return; + _scheduled = true; + queueMicrotask(() => { + _scheduled = false; + if (_commitFn) _commitFn(); + }); +} + +/** + * Wrap an object in a reactive Proxy. + * Any property *assignment* that changes the value automatically triggers + * a batched re-render via requestUpdate(). + */ +export function reactive(obj = {}) { + return new Proxy(obj, { + set(target, key, value, receiver) { + const old = target[key]; + const ok = Reflect.set(target, key, value, receiver); + if (ok && !Object.is(old, value)) { + requestUpdate(); + } + return ok; + } + }); +} + + diff --git a/webui/static/hoover/render.js b/webui/static/hoover/render.js new file mode 100644 index 0000000..8bb0cf4 --- /dev/null +++ b/webui/static/hoover/render.js @@ -0,0 +1,225 @@ +/** + * Hoover — render.js + * + * Render engine: render(container, fn), container-level diffing, + * batched re-render loop integration with reactivity.js. + */ + +import { requestUpdate, setCommitFn } from './reactivity.js'; +import { + _vnodeDom, createDom, getDom, patchNode, + setMountFn, setUnmountFn, +} from './vdom.js'; +import { mountComponent, unmountComponent } from './component.js'; + +/** Container → previous root vnodes */ +export const _renderSlots = new Map(); + +/** Container → render function */ +export const _renderFns = new Map(); + +/** Component key → last normalized #comp output (for _vnodeDom preservation) */ +export const _compExpandedCache = new Map(); + +/** + * Set up lifecycle callback hooks from vdom.js. + * Called once during render initialization. + */ +setMountFn((el) => { + // Reserved for future DOM-level mount hooks +}); + +setUnmountFn((el) => { + // Called during sweepDom for cleanup +}); + +/** + * Commit callback: re-renders all registered containers in batch. + * Set as the callback for reactivity.js's requestUpdate(). + */ +function commitAll() { + for (const container of _renderFns.keys()) { + commit(container); + } +} + +setCommitFn(commitAll); + +/** + * Mount a render function onto a DOM container. + * - First call: create DOM from scratch, append to container + * - Subsequent calls: diff against previous VNodes, patch in place + */ +export function render(container, fn) { + _renderFns.set(container, fn); + commit(container); +} + +/** + * Evaluate render function, diff vs previous, commit to _renderSlots. + */ +function commit(container) { + const fn = _renderFns.get(container); + if (!fn) return; + + let result = fn(); + if (typeof result === 'function') result = result(); + const prev = _renderSlots.get(container); + + // Normalize: expand #comp vnodes and track lifecycle + const vnodes = normalizeVNodesWithLifecycle(result, prev); + + if (!prev) { + for (const v of vnodes) { + const d = createDom(v); + _vnodeDom.set(v, d); + container.appendChild(d); + } + } else { + diffContainer(container, prev, vnodes); + } + + _renderSlots.set(container, vnodes); +} + +/** + * Normalize render output: filter nulls, expand #comp vnodes, + * and manage component lifecycle based on key changes. + */ +function normalizeVNodesWithLifecycle(result, prevVnodes) { + const oldEntries = prevVnodes ? collectCompEntries(prevVnodes, []) : []; + const oldKeyMap = new Map(oldEntries.map(e => [e.key, e])); + const newEntries = []; + + const normalized = normalizeRecursive(result, oldKeyMap, newEntries); + + for (const entry of oldEntries) { + if (!newEntries.some(e => e.key === entry.key)) { + unmountComponent(entry.key, entry.renderer); + } + } + for (const entry of newEntries) { + if (!oldKeyMap.has(entry.key)) { + mountComponent(entry.key, entry.renderer); + } + } + + return normalized; +} + +/** + * Recursively normalize a value to a flat VNode array, expanding + * #comp vnodes into their rendered content while tracking lifecycle. + * + * When prevCh is provided, preserves _vnodeDom entries so that diff + * can locate existing DOM after normalization creates new vnode objects. + */ +function normalizeRecursive(result, oldKeyMap, newEntries, prevCh) { + if (result == null) return []; + if (Array.isArray(result)) { + const flat = []; + let idx = 0; + for (const item of result) { + flat.push(...normalizeRecursive(item, oldKeyMap, newEntries, prevCh && prevCh[idx])); + idx++; + } + return flat; + } + + const vnode = result; + if (typeof vnode !== 'object') return [{ tag: '#text', text: String(vnode) }]; + if (vnode.tag === '#text') return [vnode]; + + if (vnode.tag === '#comp') { + const renderer = vnode.props?.component; + const key = vnode.props?.key; + if (key !== undefined) { + const existing = newEntries.find(e => e.key === key); + if (!existing) newEntries.push({ key, renderer }); + } + if (renderer && typeof renderer === 'function') { + const content = renderer(); + const prevExpanded = key !== undefined ? _compExpandedCache.get(key) : null; + const result = normalizeRecursive(content, oldKeyMap, newEntries, prevExpanded); + if (key !== undefined) _compExpandedCache.set(key, result); + return result; + } + return normalizeRecursive([...(vnode.ch || [])], oldKeyMap, newEntries, prevCh); + } + + const rawChildren = vnode.ch || []; + const prevChildren = prevCh && prevCh.ch ? prevCh.ch : null; + const children = []; + for (let i = 0; i < rawChildren.length; i++) { + const normalized = normalizeRecursive(rawChildren[i], oldKeyMap, newEntries, prevChildren && prevChildren[i]); + children.push(...normalized); + } + + const newVNode = { tag: vnode.tag, props: vnode.props, ch: children }; + + // Preserve _vnodeDom entry: if the old vnode at this position had a + // DOM association, transfer it to the new normalized vnode so diff + // can locate existing DOM without creating duplicates. + if (prevCh && _vnodeDom.has(prevCh)) { + _vnodeDom.set(newVNode, _vnodeDom.get(prevCh)); + } + + return [newVNode]; +} + +/** Collect all #comp entries {key, renderer} from a vnode tree. */ +function collectCompEntries(vnodes, entries) { + for (const v of vnodes || []) { + if (!v) continue; + if (v.tag === '#comp') { + const key = v.props?.key; + const renderer = v.props?.component; + if (key !== undefined) entries.push({ key, renderer }); + } + if (v.ch) collectCompEntries(v.ch, entries); + } + return entries; +} + +/** + * Diff two VNode arrays inside a container, patching in place. + * + * Fix: anchor tracking ensures correct DOM insertion order. + * Fix: _vnodeDom updated after every patch. + */ +function diffContainer(container, prev, vnodes) { + const maxLen = Math.max(vnodes.length, prev.length); + let lastDom = null; + + for (let i = 0; i < maxLen; i++) { + const oldV = prev[i], newV = vnodes[i]; + + if (!newV && oldV) { + const d = getDom(oldV); + if (d?.parentNode) { + if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); + d.parentNode.removeChild(d); + } + continue; + } + if (newV && !oldV) { + const d = createDom(newV); + _vnodeDom.set(newV, d); + container.insertBefore(d, lastDom ? lastDom.nextSibling : null); + lastDom = d; + continue; + } + + const oldDom = getDom(oldV); + if (oldDom && oldV.tag === newV.tag) { + patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null); + lastDom = getDom(newV); + } else { + if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom); + const nd = createDom(newV); + _vnodeDom.set(newV, nd); + if (oldDom?.parentNode) oldDom.parentNode.replaceChild(nd, oldDom); + lastDom = nd; + } + } +} diff --git a/webui/static/hoover/router.js b/webui/static/hoover/router.js new file mode 100644 index 0000000..e34321a --- /dev/null +++ b/webui/static/hoover/router.js @@ -0,0 +1,61 @@ +/** + * Hoover — router.js + * + * Hash-based SPA router with reactive state (triggers re-render on + * navigation). Link component for client-side navigation. + */ + +import { reactive } from './reactivity.js'; +import { h } from './vdom.js'; + +/** + * Hash-based router. + * + * const router = createRouter({ + * '/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []), + * '/interfaces': () => h('#comp', { component: InterfacesPage, key: '/interfaces' }, []), + * '*': () => h('#comp', { component: NotFoundPage, key: '*' }, []), + * }); + * + * Reactive `router.state.path` updates trigger re-renders automatically. + */ +export function createRouter(routes) { + const initialPath = location.hash.slice(1) || '/dashboard'; + if (!location.hash) location.hash = initialPath; + + const state = reactive({ path: initialPath }); + + window.addEventListener('hashchange', () => { + state.path = location.hash.slice(1) || '/dashboard'; + }); + + const component = () => { + const handler = routes[state.path] || routes['*']; + if (!handler) { + return h('div', { class: 'card' }, + h('div', { class: 'text-muted' }, `404 — Not found: ${state.path}`)); + } + try { + return handler(); + } catch (e) { + return h('div', { class: 'card' }, + h('div', { class: 'text-muted' }, `Error: ${e.message || String(e)}`)); + } + }; + + return { state, navigate: (p) => { location.hash = p; }, component }; +} + +/** + * Client-side navigation link component. + * Sets `location.hash` without full page navigation. + */ +export function Link(props) { + const { path, class: cls, children, ...rest } = props || {}; + return h('a', { + href: '#' + path, + class: cls || '', + 'on:click': (e) => { e.preventDefault(); location.hash = path; }, + ...rest, + }, children || []); +} diff --git a/webui/static/hoover/vdom.js b/webui/static/hoover/vdom.js new file mode 100644 index 0000000..09026b7 --- /dev/null +++ b/webui/static/hoover/vdom.js @@ -0,0 +1,317 @@ +/** + * Hoover — vdom.js + * + * Virtual DOM: h() factory, vnode creation, diffing, patching. + * Maintains _vnodeDom WeakMap for vnode ↔ DOM element resolution. + * + * Critical fixes vs. reactive-dom.js: + * - _vnodeDom updated after EVERY vnode→dom assignment + * - Keyed diff with proper element reordering + * - Unkeyed diff with anchor tracking + * - Proper unmountTree for cleanup (fires registered onUnmount hooks) + */ + +// Exported so render.js can access it +export const _vnodeDom = new WeakMap(); + +// Lifecycle hooks registry (component.js populates this) +export const _mountFn = { fn: null }; +export const _unmountFn = { fn: null }; + +export function setMountFn(fn) { _mountFn.fn = fn; } +export function setUnmountFn(fn) { _unmountFn.fn = fn; } + +/** + * Build a VNode. Three forms: + * h('div', { class: 'x' }, h('span', null, 'hi')) — element + * h(ComponentFn, { prop: 1 }, child1, child2) — component (fn called) + * h('#text', 'some text') — text node + */ +export function h(tag, props, ...children) { + if (typeof tag === 'function') { + const base = typeof props === 'object' && props !== null ? props : {}; + if (!base.children && children.length) + base.children = flatten(children); + return tag(base); + } + if (tag === '#text') + return { tag: '#text', text: String(props) }; + if (tag === '#comp') { + return { tag: '#comp', props: props || {}, ch: flatten(children) }; + } + return { tag, props: props || {}, ch: flatten(children) }; +} + +/** Flatten nested arrays / primitives → VNode array. */ +function flatten(arr) { + const out = []; + for (const c of arr.flat(Infinity)) { + if (c == null || typeof c === 'boolean') continue; + out.push( + typeof c === 'string' || typeof c === 'number' + ? { tag: '#text', text: String(c) } + : c, + ); + } + return out; +} + +/** + * Look up the real DOM element for a VNode via _vnodeDom. + */ +export function getDom(vnode) { + return vnode ? _vnodeDom.get(vnode) : null; +} + +/** + * Create a real DOM element (or subtree) from a VNode. + * Also registers _vnodeDom mapping for the created element and all descendants. + */ +export function createDom(vnode) { + if (!vnode) return document.createTextNode(''); + if (vnode.tag === '#text') { + const tn = document.createTextNode(vnode.text || ''); + _vnodeDom.set(vnode, tn); + return tn; + } + const el = document.createElement(vnode.tag); + applyProps(el, vnode.props); + _vnodeDom.set(vnode, el); + for (const c of vnode.ch || []) { + el.appendChild(createDom(c)); + } + return el; +} + +/** Apply every prop on an element (initial mount). */ +export function applyProps(el, props) { + for (const [k, v] of Object.entries(props)) setProp(el, k, v); +} + +/** Set a single prop (or event) on an element. */ +export function setProp(el, key, value) { + if (key === 'key' || key === 'ref') return; + if (key === 'html') { el.innerHTML = String(value); return; } + if (key === 'innerHTML') { el.innerHTML = String(value); return; } + if (key === 'textContent') { el.textContent = String(value); return; } + + if (key.startsWith('on:')) { + const ev = key.slice(3); + const map = el._evMap || {}; + if (map[ev]) el.removeEventListener(ev, map[ev]); + if (typeof value === 'function') { + el.addEventListener(ev, value); + map[ev] = value; + } else delete map[ev]; + el._evMap = map; + return; + } + + if (key === 'class' && typeof value === 'object' && value !== null) { + el.className = Object.keys(value).filter(k => value[k]).join(' '); + return; + } + if (key === 'style' && typeof value === 'object' && value !== null) { + for (const [sk, sv] of Object.entries(value)) el.style[sk] = sv; + return; + } + + const tag = el.tagName.toLowerCase(); + if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) { + el.value = value == null ? '' : String(value); return; + } + if (key === 'checked' && tag === 'input') { el.checked = !!value; return; } + if (key === 'disabled') { el.disabled = !!value; return; } + if (key === 'selected' && tag === 'option') { el.selected = !!value; return; } + + if (value == null || value === false || value === undefined) + el.removeAttribute(key); + else + el.setAttribute(key, value === true ? '' : String(value)); +} + +/** Remove a single prop from an element. */ +export function unsetProp(el, key) { + if (key === 'key' || key === 'ref') return; + if (key.startsWith('on:')) { + const ev = key.slice(3); + const map = el._evMap || {}; + if (map[ev]) { el.removeEventListener(ev, map[ev]); delete map[ev]; } + el._evMap = map; + return; + } + const tag = el.tagName.toLowerCase(); + if (key === 'value' && ['input', 'textarea', 'select'].includes(tag)) return; + if (key === 'checked' && tag === 'input') { el.checked = false; return; } + if (key === 'disabled') { el.disabled = false; return; } + if (key === 'selected' && tag === 'option') { el.selected = false; return; } + el.removeAttribute(key); +} + +/** Diff two props objects and patch the element in place. */ +export function patchProps(el, oldP = {}, newP = {}) { + for (const k of new Set([...Object.keys(oldP), ...Object.keys(newP)])) { + const hasOld = k in oldP, hasNew = k in newP; + if (hasOld && hasNew && Object.is(oldP[k], newP[k])) continue; + if (hasNew) setProp(el, k, newP[k]); + else unsetProp(el, k); + } +} + +/** Recursively clean up event listeners and child nodes. */ +export function sweepDom(el) { + if (_unmountFn.fn) _unmountFn.fn(el); + for (const ev of Object.keys(el._evMap || {})) el.removeEventListener(ev, el._evMap[ev]); + while (el.firstChild) { + const child = el.firstChild; + if (child.nodeType === Node.ELEMENT_NODE) sweepDom(child); + el.removeChild(child); + } +} + +/** + * Patch children of a parent element. + * Dispatches to keyed or unkeyed patching based on whether any vnode has a key. + */ +export function patchChildren(parent, oldCh, newCh) { + const hasKeys = (ch) => ch.some(v => v?.props?.key != null); + if (hasKeys(newCh) && hasKeys(oldCh)) + patchKeyed(parent, oldCh, newCh); + else + patchUnkeyed(parent, oldCh, newCh); +} + +/** + * Unkeyed (index-based) children diff. + * + * Fix: _vnodeDom updated after EVERY vnode→dom assignment. + */ +export function patchUnkeyed(parent, oldCh, newCh) { + const maxLen = Math.max(oldCh.length, newCh.length); + let lastDom = null; + + for (let i = 0; i < maxLen; i++) { + const oldV = oldCh[i], newV = newCh[i]; + + if (!newV && oldV) { + const d = getDom(oldV); + if (d?.parentNode) { + if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); + d.parentNode.removeChild(d); + } + continue; + } + if (newV && !oldV) { + const d = createDom(newV); + _vnodeDom.set(newV, d); + parent.insertBefore(d, lastDom ? lastDom.nextSibling : null); + lastDom = d; + continue; + } + + patchNode(parent, oldV, newV, null); + lastDom = getDom(newV); + } +} + +/** + * Keyed children diff — preserves order, reuses DOM by key. + * + * Fix: proper element reordering using lastDom anchor tracking. + */ +export function patchKeyed(parent, oldCh, newCh) { + const oldMap = new Map( + oldCh.filter(v => v?.props?.key != null).map(v => [v.props.key, v]) + ); + const toRemove = new Set(oldMap.keys()); + let lastDom = null; + + for (const newV of newCh) { + const key = newV.props?.key; + toRemove.delete(key); + const oldV = oldMap.get(key); + + if (oldV) { + patchNode(parent, oldV, newV, null); + const d = getDom(newV); + if (d) { + if (lastDom && d !== lastDom.nextSibling) { + parent.insertBefore(d, lastDom.nextSibling || null); + } + lastDom = d; + } + } else { + const d = createDom(newV); + _vnodeDom.set(newV, d); + parent.insertBefore(d, lastDom ? lastDom.nextSibling : null); + lastDom = d; + } + } + + for (const key of toRemove) { + const oldV = oldMap.get(key); + const d = getDom(oldV); + if (d?.parentNode) { + if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); + d.parentNode.removeChild(d); + } + } +} + +/** + * Patch one VNode against another inside parent. + * + * - no old → create + insert + * - no new → sweep + remove + * - tag match → patchProps + patchChildren + * - tag mismatch → replace + * + * Fix: _vnodeDom always set to the correct dom after patch. + */ +export function patchNode(parent, oldV, newV, anchor) { + if (!oldV && !newV) return; + + if (!oldV) { + const d = createDom(newV); + _vnodeDom.set(newV, d); + parent.insertBefore(d, anchor || null); + return; + } + if (!newV) { + const d = getDom(oldV); + if (d?.parentNode) { + if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d); + d.parentNode.removeChild(d); + } + return; + } + + const dom = getDom(oldV); + if (!dom || !dom.parentNode) { + const d = createDom(newV); + _vnodeDom.set(newV, d); + parent.insertBefore(d, anchor || null); + return; + } + + // Tag changed → full replace + if (oldV.tag !== newV.tag) { + if (dom.nodeType === Node.ELEMENT_NODE) sweepDom(dom); + const nd = createDom(newV); + _vnodeDom.set(newV, nd); + dom.parentNode.replaceChild(nd, dom); + return; + } + + // Text node — fast path + if (oldV.tag === '#text') { + if (oldV.text !== newV.text) dom.nodeValue = newV.text; + _vnodeDom.set(newV, dom); + return; + } + + // Element: patch in place + patchProps(dom, oldV.props || {}, newV.props || {}); + patchChildren(dom, oldV.ch || [], newV.ch || []); + _vnodeDom.set(newV, dom); +} diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js new file mode 100644 index 0000000..7049109 --- /dev/null +++ b/webui/static/hoover/websocket.js @@ -0,0 +1,134 @@ +/** + * Hoover — websocket.js + * + * WebSocket connection manager with auto-reconnect, subscribe/unsubscribe + * per component per topic, and version-track messages. + * + * The _wsSubs Map stores entries keyed by renderer function so that + * auto-refresh messages from the backend can trigger page reloads. + */ + +import { setSubscribeFn } from './component.js'; + +const _wsSubs = new Map(); +let _wsConn = null; +let _wsReconnectMs = 0; + +/** + * Build the WebSocket URL. Supports an override via `window.__WS_URL__` + * (useful for proxy setups). Falls back to port 9091 when the current + * origin has no port (nginx fronting the WS on a different port). + */ +function _wsUrl() { + if (window.__WS_URL__) return window.__WS_URL__; + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return proto + '//' + location.host + '/ws'; +} + +/** Attempt a WebSocket connection. */ +function _wsConnect() { + if (_wsConn && _wsConn.readyState <= 1) return; + + _wsConn = new WebSocket(_wsUrl()); + + _wsConn.onopen = () => { + _wsReconnectMs = 0; + }; + + _wsConn.onclose = () => { + _wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000); + setTimeout(_wsConnect, _wsReconnectMs); + }; + + _wsConn.onerror = () => { + _wsConn.close(); + }; + + _wsConn.onmessage = (ev) => { + try { + const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; + handleMessage(msg); + } catch (_) {} + }; +} + +/** + * Route an incoming WS message to subscribed components. + * + * Expected message shapes: + * { type: 'versions', updated: ['firewall', 'dnsmasq', …] } + * { type: 'notify', topic: 'firewall' } + * { type: 'status', topic: 'firewall', … } + * + * Components subscribed to wildcard ('*') match every topic. + */ +function handleMessage(msg) { + const topics = []; + + if (msg.type === 'versions' || msg.type === 'refresh') { + topics.push(...(msg.updated || msg.topics || [])); + } else if (msg.type === 'notify') { + topics.push(msg.topic); + } else if (msg.type === 'status') { + topics.push(msg.topic || '*'); + } + + for (const s of _wsSubs.values()) { + if (s.unsubscribed) continue; + if (s.topic === '*') { + s.loadFn(s.state); + } else if (topics.some(t => t === s.topic || t === '*')) { + s.loadFn(s.state); + } + } +} + +/** + * Subscribe a component to WS topics. + * + * Called by component.js on mount. Returns an unsubscribe function + * called by component.js on unmount. + * + * @param {function} componentFn – The page renderer function (used as map key) + * @param {string} topic – Topic to listen for ('*' = all) + * @param {function} loadFn – Function to call when topic updates + * @param {object} state – Reactive state passed to loadFn + * @returns {function} unsubscribe + */ +function subscribe(componentFn, topic, loadFn, state) { + const entry = { componentFn, topic, loadFn, state, unsubscribed: false }; + _wsSubs.set(componentFn, entry); + + return () => { + entry.unsubscribed = true; + _wsSubs.delete(componentFn); + }; +} + +/** Register the subscribe function with component.js and kick off connection. */ +setSubscribeFn(subscribe); + +/** Start the WebSocket connection. */ +export function connect() { + _wsConnect(); +} + +/** + * Public subscribe API for direct one-off usage (e.g. from page code). + * @param {string|string[]} topics + * @param {function} handler + * @returns {function} unsubscribe + */ +export function onMessage(topics, handler) { + const tArray = Array.isArray(topics) ? topics : [topics]; + const fns = []; + for (const t of tArray) { + const entry = { + componentFn: handler, topic: t, loadFn: handler, state: {}, + unsubscribed: false + }; + _wsSubs.set(handler + ':' + t, entry); + fns.push(() => { entry.unsubscribed = true; _wsSubs.delete(handler + ':' + t); }); + } + return () => fns.forEach(f => f()); +} diff --git a/webui/static/index.html b/webui/static/index.html new file mode 100644 index 0000000..8f766db --- /dev/null +++ b/webui/static/index.html @@ -0,0 +1,14 @@ + + + + + + Vacuum Wall + + + +
+ + + + diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js new file mode 100644 index 0000000..d654404 --- /dev/null +++ b/webui/static/pages/certs.js @@ -0,0 +1,157 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +function issueCertModal(state) { + openModal((inner, idx) => { + formModal(inner, 'Issue Certificate', + [ + { label: 'Domain', id: 'ic-domain', placeholder: 'example.com' }, + { label: 'Email (optional)', id: 'ic-email', placeholder: 'you@example.com' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => { + const domain = ($val('ic-domain') || '').trim(); + if (!domain) { toast('Domain is required', 'error'); return; } + const body = { + domain, + email: ($val('ic-email') || '').trim() || undefined, + }; + const resp = await apiFetch('/api/certs/issue/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (resp.ok) { + toast('Issuance started for ' + domain, 'success'); + closeModal(idx); + const rid = resp.data?.request_id; + if (rid) pollCertIssue(rid, state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function pollCertIssue(rid, state) { + let done = false; + const timer = setInterval(async () => { + if (done) return clearInterval(timer); + const r = await apiFetch('/api/certs/issue/' + enc(rid)); + if (r.ok && r.data) { + if (r.data.status === 'completed') { + done = true; + clearInterval(timer); + toast('Certificate issued for ' + (r.data.domain || rid), 'success'); + await load(state); + } else if (r.data.status === 'failed') { + done = true; + clearInterval(timer); + toast('Issuance failed: ' + (r.data.error || 'unknown'), 'error'); + } + } + }, 2000); +} + +async function load(state) { + try { + const r = await apiFetch('/api/certs/list'); + if (r.ok) state.certs = r.data || []; + else state.error = r.error; + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { certs: [], loading: true, error: null }; + }, + subscribe: ['acme'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'Certificates' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'Certificates' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const rows = state.certs.map(c => { + const days = c.days_remaining; + let badge; + if (c.expired || (days !== undefined && days <= 0)) { + badge = Badge({ text: 'Expired', variant: 'danger' }); + } else if (days !== undefined && days <= 30) { + badge = Badge({ text: days + 'd left', variant: 'warning' }); + } else { + badge = Badge({ text: days !== undefined ? days + 'd left' : 'N/A', variant: 'success' }); + } + + return h('tr', { key: c.domain }, + h('td', null, h('strong', null, esc(c.domain || 'unknown'))), + h('td', { class: 'text-sm' }, esc(c.issuer || '-')), + h('td', null, esc(c.expiry || 'N/A')), + h('td', null, badge), + h('td', null, + h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;', + 'on:click': async () => { + const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' }); + if (resp.ok) toast('Renewal started for ' + c.domain, 'success'); + else toast(resp.error || 'Failed', 'error'); + }}, 'Renew'), + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + if (!confirm('Remove certificate for ' + c.domain + '?')) return; + const resp = await apiFetch('/api/certs/' + enc(c.domain), { method: 'DELETE' }); + if (resp.ok) { + toast('Certificate removed', 'success'); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }}, 'Remove'), + ), + ); + }); + + return [ + PageHeader({ + title: 'Certificates', + subtitle: 'ACME certificate management', + actions: h('button', { class: 'btn btn-primary', + 'on:click': () => issueCertModal(state) }, 'Issue Certificate'), + }), + rows.length + ? h('div', { class: 'card' }, h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Domain'), + h('th', null, 'Issuer'), + h('th', null, 'Expiry'), + h('th', null, 'Status'), + h('th', { style: 'width:120px;' }, 'Actions'), + ), + ), + h('tbody', null, ...rows), + )) + : Empty({ text: 'No certificates found. Issue a certificate to get started.' }), + ]; + }, +}); diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js new file mode 100644 index 0000000..241af10 --- /dev/null +++ b/webui/static/pages/dashboard.js @@ -0,0 +1,104 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +export default definePage({ + init() { + return { data: null, loading: true, error: null }; + }, + subscribe: ['*'], + async load(state) { + try { + const res = await apiFetch('/api/status/all'); + if (res.ok) state.data = res.data; + else state.error = res.error; + } catch (e) { + state.error = String(e); + } + state.loading = false; + }, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const d = state.data; + if (!d) { + return [ + PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), + h('div', { class: 'card', key: 'no-data' }, + h('div', { class: 'card-body loading' }, 'No data available'), + ), + ]; + } + + const fwZones = (d.firewall?.zones) || {}; + const net = d.net || {}; + const nCount = Object.keys(net).length; + const upI = Object.values(net).filter(i => i.state === 'up'); + const upC = upI.length; + const certs = d.certs || []; + const certW = certs.filter(c => c.expired || c.days_remaining <= 30); + const dmsk = d.dnsmasq?.status || {}; + const dmskUp = dmsk.state === 'up'; + const wUp = (d.wg?.state || 'down') === 'up'; + const wP = (d.wg || {}).peers || []; + + return [ + PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), + h('div', { class: 'grid grid-4' }, + h('div', { class: 'stat-card' }, + h('div', { class: 'label' }, 'Active Zones'), + h('div', { class: 'value' }, Object.keys(fwZones).length), + h('div', { class: 'meta' }, Object.keys(fwZones).join(', ') || 'None'), + ), + h('div', { class: 'stat-card' }, + h('div', { class: 'label' }, 'Interfaces Up'), + h('div', { class: 'value' }, upC + '/' + nCount), + h('div', { class: 'meta' }, upI.map(i => i.name).join(', ') || 'None up'), + ), + h('div', { class: 'stat-card' }, + h('div', { class: 'label' }, 'WireGuard'), + h('div', { class: 'value' }, String(d.wg?.state || 'unknown')), + h('div', { class: 'meta' }, wP.length + ' peers'), + ), + h('div', { class: 'stat-card' }, + h('div', { class: 'label' }, 'Certificates'), + h('div', { class: 'value' }, certs.length), + h('div', { class: '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, + StatusDot({ status: dmskUp ? 'success' : 'danger' }), + ' Dnsmasq ', + Badge({ text: dmsk.state || 'down', variant: dmskUp ? 'success' : 'danger' }), + ), + h('li', null, + StatusDot({ status: wUp ? 'success' : 'danger' }), + ' WireGuard ', + Badge({ text: String(d.wg?.state || 'down'), variant: wUp ? 'success' : 'danger' }), + ), + ), + ), + ), + ), + ]; + }, +}); diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js new file mode 100644 index 0000000..caa3109 --- /dev/null +++ b/webui/static/pages/dhcp.js @@ -0,0 +1,326 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +function addRangeModal(state) { + openModal((inner, idx) => { + formModal(inner, 'Add DHCP Range', + [ + { label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' }, + { label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' }, + { label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' }, + { label: 'Lease Time', id: 'r-lease', placeholder: '12h' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + interface: ($val('r-iface') || '').trim() || undefined, + start: ($val('r-start') || '').trim(), + end: ($val('r-end') || '').trim(), + lease_time: ($val('r-lease') || '').trim() || '12h', + }; + if (!body.start || !body.end) { + toast('Start and end are required', 'error'); + return; + } + const resp = await apiFetch('/api/dhcp/ranges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (resp.ok) { + toast('Range added', 'success'); + closeModal(idx); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +function addLeaseModal(state) { + openModal((inner, idx) => { + formModal(inner, 'Add Static Lease', + [ + { label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' }, + { label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' }, + { label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + mac: ($val('l-mac') || '').trim(), + ip: ($val('l-ip') || '').trim(), + hostname: ($val('l-host') || '').trim() || undefined, + }; + if (!body.mac || !body.ip) { + toast('MAC and IP are required', 'error'); + return; + } + const resp = await apiFetch('/api/dhcp/static-lease', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (resp.ok) { + toast('Lease added', 'success'); + closeModal(idx); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +function addDnsModal(state) { + openModal((inner, idx) => { + formModal(inner, 'Add DNS Record', + [ + { label: 'Name', id: 'd-name', placeholder: 'host.local' }, + { label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + name: ($val('d-name') || '').trim(), + address: ($val('d-addr') || '').trim(), + }; + if (!body.name || !body.address) { + toast('Name and address are required', 'error'); + return; + } + const resp = await apiFetch('/api/dhcp/dns-record', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (resp.ok) { + toast('DNS record added', 'success'); + closeModal(idx); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function load(state) { + try { + const cfgR = await apiFetch('/api/dhcp/config'); + if (cfgR.ok) state.config = cfgR.data || {}; + const stR = await apiFetch('/api/dhcp/status'); + if (stR.ok) state.status = stR.data || {}; + const lsR = await apiFetch('/api/dhcp/leases'); + if (lsR.ok) state.leases = lsR.data || []; + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { config: {}, status: {}, leases: [], loading: true, error: null, activeTab: 'ranges' }; + }, + subscribe: ['dnsmasq'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'DHCP & DNS' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'DHCP & DNS' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const cfg = state.config || {}; + const ranges = cfg.ranges || []; + const staticLeases = cfg.static_leases || []; + const dnsRecords = cfg.dns_records || []; + const statusUp = state.status || {}; + const isUp = statusUp.state === 'up'; + + const rangesRows = ranges.map((r, i) => h('tr', { key: i }, + h('td', null, r.interface || '(global)'), + h('td', null, esc(r.start)), + h('td', null, esc(r.end)), + h('td', null, esc(r.lease_time || '12h')), + h('td', null, + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + if (!confirm('Remove range ' + r.start + ' - ' + r.end + '?')) return; + const resp = await apiFetch('/api/dhcp/ranges', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: r.interface || '', start: r.start, end: r.end }), + }); + if (resp.ok) { + toast('Range removed', 'success'); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }}, 'Remove'), + ), + )); + + const leaseRows = staticLeases.map((l, i) => h('tr', { key: i }, + h('td', null, esc(l.mac)), + h('td', null, esc(l.ip)), + h('td', null, l.hostname || '-'), + h('td', null, + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + if (!confirm('Remove lease ' + l.mac + '?')) return; + const resp = await apiFetch('/api/dhcp/static-lease/' + enc(l.mac), { method: 'DELETE' }); + if (resp.ok) { + toast('Lease removed', 'success'); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }}, 'Remove'), + ), + )); + + const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: i }, + h('td', null, h('strong', null, esc(rec.name || 'unnamed'))), + h('td', { class: 'text-sm' }, esc(rec.address || '-')), + h('td', null, + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + if (!confirm('Remove DNS record ' + (rec.name || 'unnamed') + '?')) return; + const resp = await apiFetch('/api/dhcp/dns-record/' + enc(rec.name || ''), { method: 'DELETE' }); + if (resp.ok) { + toast('Record removed', 'success'); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }}, 'Remove'), + ), + )); + + const tabNames = ['ranges', 'leases', 'dns', 'active']; + const actions = h('div', { style: 'display:flex;gap:8px;' }, + h('button', { class: 'btn btn-primary', 'on:click': () => addRangeModal(state) }, 'Add Range'), + h('button', { class: 'btn btn-outline', 'on:click': () => addLeaseModal(state) }, 'Static Lease'), + h('button', { class: 'btn btn-outline', 'on:click': () => addDnsModal(state) }, 'DNS Record'), + h('button', { class: 'btn btn-outline', + 'on:click': async () => { + const resp = await apiFetch('/api/dhcp/apply', { method: 'POST' }); + if (resp.ok) toast('dnsmasq applied', 'success'); + else toast(resp.error || 'Failed', 'error'); + }}, 'Apply'), + ); + + return [ + PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }), + h('div', null, + StatusDot({ status: isUp ? 'success' : 'danger' }), + ' Dnsmasq ', + Badge({ text: statusUp.state || 'unknown', variant: isUp ? 'success' : 'danger' }), + ), + h('div', { class: 'tabs' }, + tabNames.map(t => h('span', { + class: 'tab ' + (state.activeTab === t ? 'active' : ''), + 'on:click': () => { state.activeTab = t; }, + style: 'cursor:pointer;', + }, t.charAt(0).toUpperCase() + t.slice(1))), + ), + state.activeTab === 'ranges' + ? h('div', { class: 'card' }, h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Interface'), + h('th', null, 'Start'), + h('th', null, 'End'), + h('th', null, 'Lease'), + h('th', { style: 'width:80px;' }, 'Action'), + ), + ), + h('tbody', null, + ...(rangesRows.length ? rangesRows : [ + h('tr', null, h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No DHCP ranges')), + ]), + ), + )) : null, + state.activeTab === 'leases' + ? h('div', { class: 'card' }, h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'MAC'), + h('th', null, 'IP'), + h('th', null, 'Hostname'), + h('th', { style: 'width:80px;' }, 'Action'), + ), + ), + h('tbody', null, + ...(leaseRows.length ? leaseRows : [ + h('tr', null, h('td', { colspan: 4, class: 'text-muted text-sm' }, 'No static leases')), + ]), + ), + )) : null, + state.activeTab === 'dns' + ? h('div', { class: 'card' }, h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Name'), + h('th', null, 'Address'), + h('th', { style: 'width:80px;' }, 'Action'), + ), + ), + h('tbody', null, + ...(dnsRows.length ? dnsRows : [ + h('tr', null, h('td', { colspan: 3, class: 'text-muted text-sm' }, 'No custom DNS records')), + ]), + ), + )) : null, + state.activeTab === 'active' + ? h('div', { class: 'card' }, h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'MAC'), + h('th', null, 'IP'), + h('th', null, 'Hostname'), + h('th', null, 'Expires'), + ), + ), + h('tbody', null, + (state.leases || []).map((l, i) => h('tr', { key: i }, + h('td', null, esc(l.mac || '-')), + h('td', null, esc(l.ip || '-')), + h('td', null, esc(l.hostname || '-')), + h('td', null, esc(l.expires || '-')), + )), + ), + )) : null, + ]; + }, +}); diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js new file mode 100644 index 0000000..a8f12ef --- /dev/null +++ b/webui/static/pages/interfaces.js @@ -0,0 +1,159 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +async function changeZone(name, zone, state) { + const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interfaces: [name] }), + }); + if (r.ok) { + toast(name + ' \u2192 ' + zone, 'success'); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } +} + +function cfgModal(name, state) { + openModal((inner, idx) => { + formModal(inner, 'Config: ' + name, + [ + { label: 'Addresses (comma-separated)', id: 'cfg-addrs', placeholder: '192.168.1.1/24' }, + { label: 'Gateway', id: 'cfg-gw' }, + { label: 'DNS (comma-separated)', id: 'cfg-dns', placeholder: '1.1.1.1, 8.8.8.8' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean), + gateway: ($val('cfg-gw') || '').trim() || undefined, + dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean), + }; + const r = await apiFetch('/api/network/interfaces/' + enc(name), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (r.ok) { + toast('Config saved', 'success'); + closeModal(idx); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function load(state) { + try { + const [fw, net] = await Promise.all([ + apiFetch('/api/firewall/zones'), + apiFetch('/api/network/interfaces'), + ]); + // Extract zone names from available zones (for the dropdown) + state.zones = fw.ok ? (fw.data?.available || []) : []; + if (net.ok) { + // Build reverse zone map: interface name → zone name, from active zones + const ifaceZone = {}; + for (const [zoneName, ifaces] of Object.entries(fw.data?.active || {})) { + for (const name of (ifaces || [])) ifaceZone[name] = zoneName; + } + // Transform { interfaces: { name: { config, runtime } }, timestamp } + // → array of { name, mac, ips, state, zone } + const ifacesObj = net.data?.interfaces || {}; + state.ifaces = Object.entries(ifacesObj).map(([name, entry]) => ({ + name, + mac: entry?.runtime?.mac || null, + ips: [...(entry?.config?.addresses || []), ...(entry?.runtime?.addresses || [])], + state: (entry?.runtime?.state || '').startsWith('routable') || (entry?.runtime?.state || '').startsWith('carrier') ? 'up' : 'down', + zone: ifaceZone[name] || null, + })); + } else { + state.error = net.error; + } + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { ifaces: [], zones: [], loading: true, error: null }; + }, + subscribe: ['firewall', 'networkd'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'Interfaces' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'Interfaces' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const rows = state.ifaces.map(iface => { + return h('tr', { key: iface.name }, + h('td', null, h('strong', null, iface.name)), + h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')), + h('td', null, (iface.ips || []).join(', ') || 'N/A'), + h('td', null, + StatusDot({ status: iface.state }), + ' ' + (iface.state === 'up' ? 'Up' : 'Down'), + ), + h('td', null, + h('select', { + 'on:change': (e) => changeZone(iface.name, e.target.value, state), + }, state.zones.map(z => + h('option', { value: z, selected: z === iface.zone }, z), + )), + h('button', { + class: 'btn btn-sm btn-outline', + style: 'margin-left:8px', + 'on:click': () => cfgModal(iface.name, state), + }, 'Config'), + ), + ); + }); + + return [ + PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }), + h('div', { class: 'card' }, + h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Name'), + h('th', null, 'MAC'), + h('th', null, 'IPs'), + h('th', null, 'State'), + h('th', null, 'Zone / Actions'), + ), + ), + h('tbody', null, + ...(rows.length ? rows : [ + h('tr', null, + h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No interfaces found'), + ), + ]), + ), + ), + ), + ]; + }, +}); diff --git a/webui/static/pages/logs.js b/webui/static/pages/logs.js new file mode 100644 index 0000000..75b4738 --- /dev/null +++ b/webui/static/pages/logs.js @@ -0,0 +1,79 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +const logTabs = [ + { key: 'journal', label: 'Journal', url: '/api/logs/journal' }, + { key: 'nginx-access', label: 'Nginx Access', url: '/api/logs/nginx/access' }, + { key: 'nginx-error', label: 'Nginx Error', url: '/api/logs/nginx/error' }, + { key: 'dnsmasq', label: 'Dnsmasq', url: '/api/logs/dnsmasq' }, + { key: 'app', label: 'App', url: '/api/logs/app' }, +]; + + +async function fetchLog(state, url) { + state.loading = true; + state.error = null; + try { + const res = await fetch(url); + const text = await res.text(); + state.lines = text.split('\n').filter(l => l.length > 0); + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { activeTab: 'journal', lines: [], loading: false, error: null }; + }, + subscribe: [], + async load(state) { + const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0]; + await fetchLog(state, tab.url); + }, + onUnmount(state) { + state.lines = []; + }, + render(state) { + const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0]; + + const lineVnodes = state.lines.map((line, i) => + h('div', { class: 'log-line', key: i }, esc(line)) + ); + + return [ + PageHeader({ title: 'Logs', subtitle: 'System & service logs' }), + h('div', { class: 'tabs', key: 'log-tabs' }, + logTabs.map(t => h('span', { + class: 'tab ' + (state.activeTab === t.key ? 'active' : ''), + 'on:click': async () => { + state.activeTab = t.key; + await fetchLog(state, t.url); + }, + style: 'cursor:pointer;', + }, t.label)) + ), + h('div', { class: 'card', key: 'log-card' }, + h('div', { class: 'card-header' }, + h('span', null, tab.label), + h('button', { + class: 'btn btn-sm btn-outline', + style: 'float:right;', + 'on:click': async () => { + await fetchLog(state, tab.url); + }, + }, '\u21BB') + ), + h('div', { class: 'card-body log-body' }, + state.loading + ? h('div', { class: 'loading' }, 'Loading...') + : state.error + ? h('div', { class: 'error-msg' }, state.error) + : lineVnodes.length > 0 + ? h('pre', null, lineVnodes) + : h('div', { class: 'text-muted text-sm' }, 'No log lines available') + ) + ), + ]; + }, +}); diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js new file mode 100644 index 0000000..21cfe02 --- /dev/null +++ b/webui/static/pages/nat.js @@ -0,0 +1,186 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +function addFwdModal(zones, state) { + openModal((inner, idx) => { + formModal(inner, 'Add Port Forward', + [ + { label: 'Zone', id: 'fwd-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) }, + { label: 'Port', id: 'fwd-port', type: 'number' }, + { label: 'Protocol', id: 'fwd-proto', placeholder: 'tcp or udp' }, + { label: 'To Address', id: 'fwd-toaddr', placeholder: '192.168.1.100' }, + { label: 'To Port (optional)', id: 'fwd-toport', type: 'number' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + zone: $val('fwd-zone'), + port: parseInt($val('fwd-port')), + proto: ($val('fwd-proto') || 'tcp').trim(), + toaddr: ($val('fwd-toaddr') || '').trim() || undefined, + toport: $val('fwd-toport') ? parseInt($val('fwd-toport')) : undefined, + }; + if (!body.zone || !body.port || !body.proto) { + toast('Zone, port, and proto are required', 'error'); + return; + } + const r = await apiFetch('/api/firewall/forward-port', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (r.ok) { + toast('Forward rule added', 'success'); + closeModal(idx); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function load(state) { + try { + const r = await apiFetch('/api/firewall/config'); + if (r.ok) state.config = r.data || {}; + const zr = await apiFetch('/api/firewall/zones'); + if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {}); + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { config: {}, activeZones: [], loading: true, error: null }; + }, + subscribe: ['firewall'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'NAT' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const cfg = state.config || {}; + const zoneData = cfg.zones || {}; + + const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => { + const masq = !!zcfg.masquerade; + return h('tr', { key: 'm-' + zone }, + h('td', null, h('strong', null, zone)), + h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })), + h('td', null, + h('button', { class: 'btn btn-sm btn-outline', + 'on:click': async () => { + const r = await apiFetch('/api/firewall/masquerade', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ zone, enable: !masq }), + }); + if (r.ok) { + toast('Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone, 'success'); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }}, masq ? 'Disable' : 'Enable'), + ), + ); + }); + + const fwRows = []; + Object.entries(zoneData).forEach(([zone, zcfg]) => { + const forwards = zcfg.forward_ports || []; + forwards.forEach((fwd, i) => { + fwRows.push(h('tr', { key: 'f-' + zone + '-' + i }, + h('td', null, h('strong', null, zone)), + h('td', null, Badge({ text: fwd['proxy-protocol'] || fwd.proto || 'tcp', variant: 'info' })), + h('td', null, fwd.port), + h('td', null, fwd['to-addr'] || fwd.toaddr || '-'), + h('td', null, fwd['to-port'] || fwd.toport || '-'), + h('td', null, + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + const port = fwd.port, proto = fwd['proxy-protocol'] || fwd.proto; + if (!confirm('Remove forward ' + zone + ':' + port + '/' + proto + '?')) return; + const r = await apiFetch('/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto), { method: 'DELETE' }); + if (r.ok) { + toast('Rule removed', 'success'); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }}, 'Remove'), + ), + )); + }); + }); + + return [ + PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }), + h('h3', { class: 'section-title' }, 'Masquerade'), + h('div', { class: 'card' }, + h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Zone'), + h('th', null, 'Status'), + h('th', { style: 'width:100px;' }, 'Action'), + ), + ), + h('tbody', null, + ...(masqRows.length ? masqRows : [ + h('tr', null, h('td', { colspan: 3, class: 'text-muted' }, 'No zones')), + ]), + ), + ), + ), + h('h3', { class: 'section-title' }, 'Port Forwarding'), + h('div', { class: 'card' }, + h('div', { style: 'padding:0.75rem;', class: 'flex' }, + h('button', { class: 'btn btn-sm btn-primary', + 'on:click': () => addFwdModal(state.activeZones, state) }, 'Add Forward'), + ), + h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Zone'), + h('th', null, 'Proto'), + h('th', null, 'Port'), + h('th', null, 'To Addr'), + h('th', null, 'To Port'), + h('th', { style: 'width:80px;' }, 'Action'), + ), + ), + h('tbody', null, + ...(fwRows.length ? fwRows : [ + h('tr', null, + h('td', { colspan: 6, class: 'text-muted text-sm' }, 'No port forwarding rules'), + ), + ]), + ), + ), + ), + ]; + }, +}); diff --git a/webui/static/pages/notfound.js b/webui/static/pages/notfound.js new file mode 100644 index 0000000..36caef2 --- /dev/null +++ b/webui/static/pages/notfound.js @@ -0,0 +1,19 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +export default definePage({ + init() { + return { path: '' }; + }, + subscribe: [], + async load(state) { + state.path = location.hash.slice(1) || ''; + }, + render(state) { + return [ + PageHeader({ title: '404' }), + h('div', { class: 'card' }, + h('div', { class: 'card-body text-muted' }, 'Page not found: ' + state.path), + ), + ]; + }, +}); diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js new file mode 100644 index 0000000..f34fa2b --- /dev/null +++ b/webui/static/pages/proxy.js @@ -0,0 +1,187 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +function addDomainModal(state) { + openModal((inner, idx) => { + formModal(inner, 'Add Proxy Domain', + [ + { label: 'Domain', id: 'p-domain', placeholder: 'example.com' }, + { label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' }, + { label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' }, + { label: 'Protocol', id: 'p-proto', placeholder: 'http or https' }, + { label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + domain: ($val('p-domain') || '').trim(), + backend_host: ($val('p-host') || '').trim(), + backend_port: parseInt($val('p-port')), + backend_proto: ($val('p-proto') || 'http').trim() || 'http', + cert: ($val('p-cert') || '').trim() || undefined, + }; + if (!body.domain || !body.backend_host || !body.backend_port) { + toast('Domain, host, and port are required', 'error'); + return; + } + const resp = await apiFetch('/api/proxy/domains', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (resp.ok) { + toast('Domain added', 'success'); + closeModal(idx); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +function editDomainModal(domain, state) { + openModal((inner, idx) => { + formModal(inner, 'Edit: ' + domain.domain, + [ + { label: 'Backend Host', id: 'pe-host', value: domain.backend_host || '' }, + { label: 'Backend Port', id: 'pe-port', type: 'number', value: domain.backend_port || '' }, + { label: 'Protocol', id: 'pe-proto', value: domain.backend_proto || domain.protocol || 'http' }, + { label: 'Cert (optional)', id: 'pe-cert', value: domain.cert || '' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + backend_host: ($val('pe-host') || '').trim(), + backend_port: parseInt($val('pe-port')), + backend_proto: ($val('pe-proto') || 'http').trim(), + cert: ($val('pe-cert') || '').trim() || undefined, + }; + const resp = await apiFetch('/api/proxy/domains/' + enc(domain.domain), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (resp.ok) { + toast('Domain updated', 'success'); + closeModal(idx); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function load(state) { + try { + const domainsR = await apiFetch('/api/proxy/domains'); + if (domainsR.ok) state.domains = domainsR.data || []; + const certsR = await apiFetch('/api/certs/list'); + if (certsR.ok) state.certs = certsR.data || []; + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { domains: [], certs: [], loading: true, error: null }; + }, + subscribe: ['nginx', 'acme'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'Proxy' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'Proxy' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const rows = state.domains.map(d => { + let certBadge = Badge({ text: 'No cert', variant: 'info' }); + if (d.cert_status === 'valid' || d.cert_status === 'active') { + certBadge = Badge({ text: 'Valid', variant: 'success' }); + } else if (d.cert_status === 'expired' || (d.days_remaining !== undefined && d.days_remaining <= 0)) { + certBadge = Badge({ text: 'Expired', variant: 'danger' }); + } else if (d.days_remaining !== undefined && d.days_remaining <= 30) { + certBadge = Badge({ text: d.days_remaining + 'd', variant: 'warning' }); + } else if (d.days_remaining !== undefined) { + certBadge = Badge({ text: d.days_remaining + 'd', variant: 'success' }); + } + + return h('tr', { key: d.domain }, + h('td', null, h('strong', null, esc(d.domain))), + h('td', null, esc(d.backend_host || '-')), + h('td', null, d.backend_port || '-'), + h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })), + h('td', null, certBadge), + h('td', null, + h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;', + 'on:click': () => editDomainModal(d, state) }, 'Edit'), + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + if (!confirm('Remove proxy for ' + d.domain + '?')) return; + const r = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'DELETE' }); + if (r.ok) { + toast('Domain removed', 'success'); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }}, 'Delete'), + ), + ); + }); + + const actions = h('div', { style: 'display:flex;gap:8px;' }, + h('button', { class: 'btn btn-primary', 'on:click': () => addDomainModal(state) }, 'Add Domain'), + h('button', { class: 'btn btn-outline', + 'on:click': async () => { + const resp = await apiFetch('/api/proxy/apply', { method: 'POST' }); + if (resp.ok) toast('Nginx applied & reloaded', 'success'); + else toast(resp.error || 'Failed', 'error'); + }}, 'Apply'), + ); + + return [ + PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }), + rows.length + ? h('div', { class: 'card' }, h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Domain'), + h('th', null, 'Backend Host'), + h('th', null, 'Port'), + h('th', null, 'Proto'), + h('th', null, 'Cert'), + h('th', { style: 'width:140px;' }, 'Actions'), + ), + ), + h('tbody', null, ...rows), + )) + : Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }), + ]; + }, +}); diff --git a/webui/static/pages/rules.js b/webui/static/pages/rules.js new file mode 100644 index 0000000..f421542 --- /dev/null +++ b/webui/static/pages/rules.js @@ -0,0 +1,132 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +function addRuleModal(zones, state) { + openModal((inner, idx) => { + formModal(inner, 'Add Rich Rule', + [ + { label: 'Zone', id: 'rule-zone', tag: 'select', options: zones.map(z => [z, z === zones[0]]) }, + { label: 'Rule (XML)', id: 'rule-text', tag: 'textarea', placeholder: 'rule family="ipv4" source address="192.168.1.0/24" accept' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { + const zone = $val('rule-zone'); + const rule = ($val('rule-text') || '').trim(); + if (!zone || !rule) { toast('Zone and rule are required', 'error'); return; } + const r = await apiFetch('/api/firewall/rich-rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ zone, rule }), + }); + if (r.ok) { + toast('Rule added', 'success'); + closeModal(idx); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function load(state) { + try { + const r = await apiFetch('/api/firewall/config'); + if (r.ok) state.config = r.data || {}; + else state.error = r.error; + const zr = await apiFetch('/api/firewall/zones'); + if (zr.ok) state.zones = Object.keys(zr.data?.active || {}); + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { config: {}, loading: true, error: null, zones: [] }; + }, + subscribe: ['firewall'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'Rules' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const cfg = state.config || {}; + const zoneData = cfg.zones || {}; + const zoneRules = {}; + Object.entries(zoneData).forEach(([zname, zcfg]) => { + const rr = zcfg.rich_rules || []; + if (rr.length) zoneRules[zname] = rr; + }); + + const cards = Object.entries(zoneRules).map(([zone, rules]) => { + return h('div', { class: 'card', key: zone }, + h('div', { class: 'card-header' }, 'Zone: ' + esc(zone)), + h('div', { class: 'card-body' }, + h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, '#'), + h('th', null, 'Rule'), + h('th', { style: 'width:80px;' }, 'Action'), + ), + ), + h('tbody', null, + (Array.isArray(rules) ? rules : []).map((entry, i) => { + const ruleId = typeof entry === 'object' ? entry.id : null; + const ruleText = typeof entry === 'object' ? (entry.rule || '') : String(entry); + return h('tr', { key: i }, + h('td', { class: 'text-muted' }, i + 1), + h('td', { style: 'font-family:monospace;font-size:12px;word-break:break-all;' }, esc(ruleText)), + h('td', null, + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + if (!confirm('Remove rule: ' + ruleText.substring(0, 40) + '...?')) return; + const r = await apiFetch('/api/firewall/rich-rules/' + enc(zone) + '/' + enc(ruleId || ''), { method: 'DELETE' }); + if (r.ok) { + toast('Rule removed', 'success'); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }}, 'Remove'), + ), + ); + }), + ), + ), + ), + ); + }); + + return [ + PageHeader({ + title: 'Rules', + subtitle: 'Firewall rich rules', + actions: h('button', { class: 'btn btn-primary', + 'on:click': () => addRuleModal(state.zones, state) }, 'Add Rule'), + }), + ...(cards.length ? cards : [Empty({ text: 'No rich rules configured.' })]), + ]; + }, +}); diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js new file mode 100644 index 0000000..3cd88a5 --- /dev/null +++ b/webui/static/pages/wireguard.js @@ -0,0 +1,212 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +function addPeerModal(state) { + openModal((inner, idx) => { + formModal(inner, 'Add WireGuard Peer', + [ + { label: 'Name', id: 'wg-name', placeholder: 'client-name' }, + { label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' }, + { label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' }, + { label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { + const body = { + name: ($val('wg-name') || '').trim(), + endpoint: ($val('wg-endpoint') || '').trim() || undefined, + allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [], + persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined, + }; + if (!body.name) { toast('Name is required', 'error'); return; } + const resp = await apiFetch('/api/wireguard/peers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (resp.ok) { + toast('Peer added', 'success'); + closeModal(idx); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +function downloadConfigModal(peerName, config, state) { + openModal((inner, idx) => { + formModal(inner, 'Download Config for ' + peerName, + [{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Generate', cls: 'btn-primary', action: 's', handler: async () => { + const endpoint = ($val('wg-srv-endpoint') || '').trim(); + if (!endpoint) { toast('Server endpoint is required', 'error'); return; } + const resp = await apiFetch('/api/wireguard/generate-client', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: peerName, server_endpoint: endpoint }), + }); + if (resp.ok && resp.data?.config) { + const blob = new Blob([resp.data.config], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = peerName + '.conf'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast('Config downloaded', 'success'); + closeModal(idx); + } else { + toast(resp.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function load(state) { + try { + const stR = await apiFetch('/api/wireguard/status'); + if (stR.ok) state.status = stR.data || {}; + const pR = await apiFetch('/api/wireguard/peers'); + if (pR.ok) state.peers = pR.data || []; + const cfgR = await apiFetch('/api/wireguard/config'); + if (cfgR.ok) state.config = cfgR.data || {}; + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { status: {}, peers: [], config: {}, loading: true, error: null }; + }, + subscribe: ['wireguard'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'WireGuard' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'WireGuard' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const st = state.status || {}; + const isUp = st.state === 'up'; + const listenPort = (state.config?.interface || {}).listen_port || '-'; + + const peerRows = state.peers.map(p => { + const hasHandshake = !!p.latest_handshake; + return h('tr', { key: p.name }, + h('td', null, + StatusDot({ status: hasHandshake ? 'success' : 'danger' }), + h('strong', null, esc(p.name || 'unnamed')), + ), + h('td', { style: 'font-family:monospace;font-size:11px;' }, + esc((p.public_key || 'N/A').substring(0, 20)) + + (p.public_key && p.public_key.length > 20 ? '...' : ''), + ), + h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')), + h('td', { class: 'text-sm' }, esc(p.endpoint || '-')), + h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')), + h('td', { class: 'text-sm' }, + 'Recv: ' + esc(p.transfer_recv || '0'), + h('br'), + 'Sent: ' + esc(p.transfer_sent || '0'), + ), + h('td', null, + h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;', + 'on:click': () => downloadConfigModal(p.name, state.config, state) }, 'Config'), + h('button', { class: 'btn btn-sm btn-danger', + 'on:click': async () => { + if (!confirm('Remove peer ' + p.name + '?')) return; + const resp = await apiFetch('/api/wireguard/peers/' + enc(p.name), { method: 'DELETE' }); + if (resp.ok) { + toast('Peer removed', 'success'); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }}, 'Remove'), + ), + ); + }); + + const actions = h('div', { style: 'display:flex;gap:8px;' }, + h('button', { class: 'btn btn-primary', 'on:click': () => addPeerModal(state) }, 'Add Peer'), + h('button', { class: 'btn btn-outline', + 'on:click': async () => { + const resp = await apiFetch('/api/wireguard/' + (isUp ? 'down' : 'up'), { method: 'POST' }); + if (resp.ok) { + toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success'); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }}, isUp ? 'Stop' : 'Start'), + h('button', { class: 'btn btn-outline', + 'on:click': async () => { + const resp = await apiFetch('/api/wireguard/apply', { method: 'POST' }); + if (resp.ok) { + toast('Config applied', 'success'); + await load(state); + } else { + toast(resp.error || 'Failed', 'error'); + } + }}, 'Apply'), + ); + + return [ + PageHeader({ + title: 'WireGuard', + subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort, + actions, + }), + h('div', null, + StatusDot({ status: isUp ? 'success' : 'danger' }), + ' ', + Badge({ text: st.state || 'down', variant: isUp ? 'success' : 'danger' }), + ), + peerRows.length + ? h('div', { class: 'card' }, h('table', { class: 'table' }, + h('thead', null, + h('tr', null, + h('th', null, 'Peer'), + h('th', null, 'Public Key'), + h('th', null, 'Allowed IPs'), + h('th', null, 'Endpoint'), + h('th', null, 'Handshake'), + h('th', null, 'Transfer'), + h('th', { style: 'width:120px;' }, 'Actions'), + ), + ), + h('tbody', null, ...peerRows), + )) + : Empty({ text: 'No peers configured. Add a peer above.' }), + ]; + }, +}); diff --git a/webui/static/pages/zones.js b/webui/static/pages/zones.js new file mode 100644 index 0000000..aa7cb90 --- /dev/null +++ b/webui/static/pages/zones.js @@ -0,0 +1,233 @@ +import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; + +function addZoneModal(state) { + openModal((inner, idx) => { + formModal(inner, 'Add Zone', + [ + { label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' }, + { label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Create', cls: 'btn-primary', action: 's', handler: async () => { + const name = ($val('zone-name') || '').trim(); + if (!name) { toast('Zone name required', 'error'); return; } + const target = ($val('zone-target') || '').trim() || 'default'; + const r = await apiFetch('/api/firewall/zones', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, target }), + }); + if (r.ok) { + toast('Zone ' + name + ' created', 'success'); + closeModal(idx); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +function zoneIfaceModal(zoneName, state) { + const zdata = state.zones?.[zoneName] || {}; + const current = Array.isArray(zdata.interfaces) ? zdata.interfaces : []; + const allIfaces = Array.isArray(state.interfaces) ? state.interfaces : []; + openModal((inner, idx) => { + formModal(inner, 'Interfaces: ' + zoneName, + [ + { + label: 'Interfaces', id: 'z-iface-select', tag: 'select', + options: allIfaces.map(i => [i, current.includes(i)]), + }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { + const sel = document.getElementById('z-iface-select'); + const selected = Array.from(sel.selectedOptions).map(o => o.value); + const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/interfaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interfaces: selected }), + }); + if (r.ok) { + toast('Interfaces updated', 'success'); + closeModal(idx); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +function zoneSvcModal(zoneName, state) { + const zdata = state.zones?.[zoneName] || {}; + const current = Array.isArray(zdata.services) ? zdata.services : []; + const all = Array.isArray(state.services) ? state.services : []; + openModal((inner, idx) => { + formModal(inner, 'Services: ' + zoneName, + [ + { + label: 'Services', id: 'z-svc-select', tag: 'select', + options: all.map(s => [s, current.includes(s)]), + }, + ], + [ + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { + label: 'Save', cls: 'btn-primary', action: 's', handler: async () => { + const sel = document.getElementById('z-svc-select'); + const selected = Array.from(sel.selectedOptions).map(o => o.value); + const r = await apiFetch('/api/firewall/zones/' + enc(zoneName) + '/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ services: selected }), + }); + if (r.ok) { + toast('Services updated', 'success'); + closeModal(idx); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }, + }, + ], + ); + }); +} + +async function load(state) { + try { + const [zRes, svcRes, ifRes] = await Promise.all([ + apiFetch('/api/firewall/zones'), + apiFetch('/api/firewall/services'), + apiFetch('/api/firewall/interfaces'), + ]); + + if (zRes.ok) { + const data = zRes.data || {}; + const activeZones = data.active || {}; + const availableZones = data.available || []; + + const detailPromises = availableZones.map(name => + apiFetch('/api/firewall/zones/' + enc(name)).catch(() => null) + ); + const detailResults = await Promise.all(detailPromises); + + const zones = {}; + for (let i = 0; i < availableZones.length; i++) { + const name = availableZones[i]; + const detail = detailResults[i]; + if (detail && detail.ok) { + zones[name] = detail.data; + const activeIfaces = activeZones[name]; + if (Array.isArray(activeIfaces)) { + zones[name].interfaces = activeIfaces; + } + } + } + state.zones = zones; + } + + if (svcRes.ok) state.services = svcRes.data || []; + if (ifRes.ok) state.interfaces = ifRes.data || []; + } catch (e) { + state.error = String(e); + } + state.loading = false; +} + +export default definePage({ + init() { + return { zones: {}, services: [], interfaces: [], loading: true, error: null }; + }, + subscribe: ['firewall'], + load, + render(state) { + if (state.loading) { + return [ + PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, 'Loading...'), + ), + ]; + } + + if (state.error) { + return [ + PageHeader({ title: 'Zones' }), + h('div', { class: 'card', key: 'error' }, + h('div', { class: 'card-body error-msg' }, state.error), + ), + ]; + } + + const zoneCards = Object.entries(state.zones || []).map(([name, zdata]) => { + const z = typeof zdata === 'object' ? zdata : {}; + const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : []; + const svcsArr = Array.isArray(z.services) ? z.services : []; + return h('div', { class: 'card', key: name, style: 'position:relative;' }, + h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;' }, + h('div', null, + h('h3', { style: 'font-size:16px;color:var(--accent);' }, name), + h('div', { class: 'text-muted text-sm', style: 'margin-bottom:10px;' }, + z.target ? 'Target: ' + esc(z.target) : '', + ), + ), + ), + h('div', { class: 'text-sm mb-4' }, + h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Interfaces'), + ifacesArr.length + ? ifacesArr.map(i => Badge({ text: esc(i) })) + : h('span', { class: 'text-muted' }, 'None'), + ), + h('div', { class: 'text-sm mb-4' }, + h('div', { class: 'text-muted', style: 'margin-bottom:4px;' }, 'Services'), + svcsArr.length + ? svcsArr.map(s => Badge({ text: esc(s), variant: 'success' })) + : h('span', { class: 'text-muted' }, 'None'), + ), + h('div', { style: 'display:flex;gap:6px;' }, + h('button', { class: 'btn btn-sm btn-outline', + 'on:click': () => zoneIfaceModal(name, state) }, 'Interfaces'), + h('button', { class: 'btn btn-sm btn-outline', + 'on:click': () => zoneSvcModal(name, state) }, 'Services'), + h('button', { class: 'btn btn-sm btn-danger', style: 'margin-left:auto;', + 'on:click': async () => { + if (!confirm('Delete zone ' + name + '?')) return; + const r = await apiFetch('/api/firewall/zones/' + enc(name), { method: 'DELETE' }); + if (r.ok) { + toast('Zone ' + name + ' deleted', 'success'); + await load(state); + } else { + toast(r.error || 'Failed', 'error'); + } + }}, 'Delete'), + ), + ); + }); + + return [ + PageHeader({ + title: 'Zones', + subtitle: 'Firewall zones', + actions: h('button', { class: 'btn btn-primary', + 'on:click': () => addZoneModal(state) }, 'Add Zone'), + }), + zoneCards.length + ? h('div', { class: 'card-grid' }, ...zoneCards) + : Empty({ text: 'No zones configured. Add a zone to get started.' }), + ]; + }, +}); diff --git a/webui/static/style.css b/webui/static/style.css index e46fdd0..e28d903 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -301,10 +301,40 @@ body { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); animation: toastSlideIn 0.3s ease forwards; display: flex; - align-items: center; + align-items: flex-start; + justify-content: space-between; gap: 0.6rem; + word-break: break-word; } +.toast-message .toast-text { + flex: 1; + min-width: 0; + white-space: pre-wrap; +} + +.toast-message .toast-actions { + display: flex; + gap: 2px; + flex-shrink: 0; + align-items: center; + height: 1.4em; +} + +.toast-message .toast-btn { + background: none; + border: none; + cursor: pointer; + color: inherit; + font-size: 14px; + padding: 0 2px; + line-height: 1; + opacity: 0.6; + transition: opacity 0.15s; +} + +.toast-message .toast-btn:hover { opacity: 1; } + .toast-message.toast-success { background: var(--success); color: #fff; @@ -464,6 +494,204 @@ body { .mb-1 { margin-bottom: 0.5rem; } .mb-2 { margin-bottom: 1rem; } +/* Page header */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.page-header h1 { + font-size: 22px; + font-weight: 600; +} + +.page-header .subtitle { + color: var(--text-muted); + font-size: 13px; +} + +/* Stat cards */ +.stat-card { + background: var(--bg-card); + border-radius: 8px; + padding: 1rem 1.25rem; +} + +.stat-card .label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.stat-card .value { + font-size: 28px; + font-weight: 700; +} + +.stat-card .meta { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + +/* Status dot */ +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; +} + +.status-up { background: var(--success); } +.status-down { background: var(--danger); } +.status-pending { background: var(--warning); } + +/* Badge info */ +.badge-info { + background: rgba(0, 180, 216, 0.15); + color: var(--accent); +} + +/* Modal actions */ +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 1rem; +} + +/* Section title */ +.section-title { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 1rem; + margin-top: 1.5rem; +} + +.section-title:first-child { margin-top: 0; } + +/* Card grid */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1.25rem; + margin-bottom: 1.5rem; +} + +.card .card-grid { + padding: 1.25rem; +} + +/* Service list */ +.service-list { + list-style: none; + padding: 0; + margin: 0; +} + +.service-list li { + padding: 6px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.service-list .svc-name { + flex: 1; +} + +/* Tabs */ +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: 1rem; +} + +.tab { + padding: 0.75rem 1.25rem; + cursor: pointer; + color: var(--text-muted); + font-size: 14px; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} + +.tab:hover { + color: var(--text); +} + +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Logs area */ +.logs-area { + max-height: 60vh; + overflow-y: auto; + font-family: monospace; + font-size: 12px; + line-height: 1.5; + padding: 1rem; + background: #0d0d1a; + border-radius: 6px; + border: 1px solid var(--border); +} + +.log-line { + padding: 2px 0; + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +.log-line.error { color: var(--danger); } +.log-line.warn { color: var(--warning); } +.log-line.info { color: var(--text); } + +/* Auto-refresh indicator */ +.refresh-active::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + margin-right: 6px; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Loading / error */ +.loading { + color: var(--text-muted); + padding: 2rem; + text-align: center; +} + +.error-msg { + color: var(--danger); + padding: 2rem; + text-align: center; +} + /* Responsive */ @media (max-width: 768px) { .sidebar { diff --git a/webui/templates/base.html b/webui/templates/base.html deleted file mode 100644 index 5e87da1..0000000 --- a/webui/templates/base.html +++ /dev/null @@ -1,598 +0,0 @@ - - - - - - {% block title %}Vacuum Wall{% endblock %} - - - - - -
- {% block content %}{% endblock %} -
- -
- - - - - - diff --git a/webui/templates/certs.html b/webui/templates/certs.html deleted file mode 100644 index f172b3f..0000000 --- a/webui/templates/certs.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} -{% block title %}Certificates - Vacuum Wall{% endblock %} - -{% block content %} - - -
- - - - - - - - - - - - {% for cert in (certs or []) %} - - - - - - - - {% endfor %} - {% if not (certs or []) %} - - - - {% endif %} - -
DomainIssuerExpiry DateDays LeftActions
{{ cert.get('domain', 'unknown') }}{{ cert.get('issuer', '-') }}{{ cert.get('expiry', 'N/A') }} - {% set days = cert.get('days_remaining') %} - {% if cert.get('expired') or (days is not none and days <= 0) %} - Expired{% if days %} ({{ days }}d ago){% endif %} - {% elif days is not none and days <= 30 %} - {{ days }} days - {% else %} - {{ days }} days - {% endif %} - -
- -
-
No certificates found. Issue a certificate to get started.
-
- - - -{% endblock %} diff --git a/webui/templates/dashboard.html b/webui/templates/dashboard.html deleted file mode 100644 index 9718468..0000000 --- a/webui/templates/dashboard.html +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "base.html" %} -{% block title %}Dashboard - Vacuum Wall{% endblock %} - -{% block content %} - - -
-
-
Zones
-
{{ active_zones|default({})|length }}
-
Firewalld zones configured
-
-
-
Proxy Domains
-
{{ domains|default([])|length }}
-
SSL-terminated backends
-
-
-
Certificates
-
{{ certs|default([])|length }}
- {% set expired = certs|selectattr('days_until_expiry','lt',0)|list|default([])|length %} - {% set expiring = certs|rejectattr('days_until_expiry','lt',0)|selectattr('days_until_expiry','le',30)|list|default([])|length %} -
- {% if expired > 0 %}{{ expired }} expired. {% endif %} - {% if expiring > 0 %}{{ expiring }} expiring soon.{% endif %} - {% if expired == 0 and expiring == 0 %}All valid{% endif %} -
-
-
-
WireGuard
-
- - {{ 'UP' if (wg_status is defined and wg_status.get('up')) else 'DOWN' }} -
-
Tunnel state
-
-
-
Active Leases
-
{{ dnsmasq.get('leases', [])|length }}
-
DHCP clients connected
-
-
- -
Services
- -
- {% for svc_name, svc in (services or {}).items() %} -
-
{{ svc_name }}
-
- - {{ 'Running' if svc.get('running') else 'Stopped' }} -
-
- {% if svc.get('pid') %}PID {{ svc.pid }}{% endif %} - {% if svc.get('since') %} · {{ svc.since }}{% endif %} -
-
- {% endfor %} - {% if not (services or {}) %} -
-
Firewalld
-
- Running -
-
-
-
Dnsmasq
-
- Running -
-
-
-
Nginx
-
- Running -
-
-
-
wg0
-
- - {{ 'Up' if (wg_status is defined and wg_status.get('up')) else 'Down' }} -
-
- {% endif %} -
- -{% set warnings = [] %} -{% if certs is defined %} - {% for cert in certs %} - {% if cert.get('days_until_expiry') is not none and cert.days_until_expiry < 0 %} - {% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " has expired") %} - {% elif cert.get('days_until_expiry') is not none and cert.days_until_expiry <= 30 %} - {% set _ = warnings.append("Certificate for " + cert.get('domain', 'unknown') + " expires in " + cert.days_until_expiry|string + " days") %} - {% endif %} - {% endfor %} -{% endif %} - -{% if warnings|length > 0 or (services is defined) %} -
Warnings & Activity
- -
- {% if warnings|length > 0 %} -
    - {% for w in warnings %} -
  • - - {{ w }} -
  • - {% endfor %} -
- {% endif %} - {% if not warnings and not (services or {}) %} -
No warnings
- {% endif %} -
-{% endif %} -{% endblock %} diff --git a/webui/templates/dhcp.html b/webui/templates/dhcp.html deleted file mode 100644 index 8d2ef0d..0000000 --- a/webui/templates/dhcp.html +++ /dev/null @@ -1,214 +0,0 @@ -{% extends "base.html" %} -{% block title %}DHCP & DNS - Vacuum Wall{% endblock %} - -{% block content %} - - - -
DHCP Ranges
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
-
- - - - - - - - - - - - {% for rng in ((config or {}).get('dhcp_ranges', []) or []) %} - - - - - - - - {% endfor %} - {% if not ((config or {}).get('dhcp_ranges', []) or []) %} - - - - {% endif %} - -
InterfaceStartEndLease TimeAction
{{ rng.get('interface', '(global)') }}{{ rng.get('start', '') }}{{ rng.get('end', '') }}{{ rng.get('lease_time', '1h') }} -
- -
-
No DHCP ranges configured
-
-
- - -
Static Leases
- -
-
-
-
- - -
-
- - -
-
- - -
- -
-
-
- - - - - - - - - - - {% for lease in ((config or {}).get('static_leases', []) or []) %} - - - - - - - {% endfor %} - {% if not ((config or {}).get('static_leases', []) or []) %} - - - - {% endif %} - -
MACIPHostnameAction
{{ lease.get('mac', '') }}{{ lease.get('ip', '') }}{{ lease.get('hostname', '-') }} -
- -
-
No static leases configured
-
-
- - -
Custom DNS Records
- -
-
-
-
- - -
-
- - -
- -
-
-
- - - - - - - - - - {% for rec in ((config or {}).get('dns_records', []) or []) %} - - - - - - {% endfor %} - {% if not ((config or {}).get('dns_records', []) or []) %} - - - - {% endif %} - -
NameAddressAction
{{ rec.get('name', 'unnamed') }}{{ rec.get('address', '-') }} -
- -
-
No custom DNS records
-
-
- - -
Current DHCP Leases
- -
- - - - - - - - - - - - {% for lease in (leases or []) %} - - - - - - - - {% endfor %} - {% if not (leases or []) %} - - - - {% endif %} - -
ExpiresMACIPHostnameClient ID
{{ lease.get('expires', 'N/A') }}{{ lease.get('mac', 'N/A') }}{{ lease.get('ip', 'N/A') }}{{ lease.get('hostname', '*') or '*' }}{{ lease.get('client_id', 'N/A') }}
No active DHCP leases
-
- -
-
-{% endblock %} diff --git a/webui/templates/interfaces.html b/webui/templates/interfaces.html deleted file mode 100644 index a902fb9..0000000 --- a/webui/templates/interfaces.html +++ /dev/null @@ -1,111 +0,0 @@ -{% extends "base.html" %} -{% block title %}Interfaces - Vacuum Wall{% endblock %} - -{% block content %} - - -
- - - - - - - - - - - - - - {% for iface in (interfaces or []) %} - {% set entry = ((network_config or {}).get('interfaces') or {}).get(iface.get('name')) or {} %} - {% set addrs = (entry.get('addresses') or []) | join(', ') %} - {% set gw = entry.get('gateway') or '' %} - {% set dns_list = (entry.get('dns') or []) | join(', ') %} - {% set routes = entry.get('routes') or [] %} - - - - - - - - - - {% endfor %} - {% if not (interfaces or []) %} - - - - {% endif %} - -
InterfaceMAC AddressIP AddressStateZoneIP ConfigActions
{{ iface.get('name', 'unknown') }}{{ iface.get('mac', 'N/A') }} - {% for ip in iface.get('ips', []) %} - {{ ip }}{% if not loop.last %}, {% endif %} - {% endfor %} - {% if not iface.get('ips') %}N/A{% endif %} - - - {{ 'Up' if iface.get('state') == 'UP' else 'Down' }} - - {% if zones %} - - {% else %} - No zones configured - {% endif %} - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
No interfaces found
-
-{% endblock %} \ No newline at end of file diff --git a/webui/templates/logs.html b/webui/templates/logs.html deleted file mode 100644 index 8cb8d6d..0000000 --- a/webui/templates/logs.html +++ /dev/null @@ -1,151 +0,0 @@ -{% extends "base.html" %} -{% block title %}Logs - Vacuum Wall{% endblock %} - -{% block content %} - - -
- - - - - -
- -
-
-
- Loading journal entries... -
-
-
- -
-
-
- Loading Nginx access log... -
-
-
- -
-
-
- Loading Nginx error log... -
-
-
- -
-
-
- Loading dnsmasq log... -
-
-
- -
-
-
- Loading app log... -
-
-
- - -{% endblock %} diff --git a/webui/templates/nat.html b/webui/templates/nat.html deleted file mode 100644 index 6fc9c7b..0000000 --- a/webui/templates/nat.html +++ /dev/null @@ -1,131 +0,0 @@ -{% extends "base.html" %} -{% block title %}NAT - Vacuum Wall{% endblock %} - -{% block content %} - - -
Masquerade (Source NAT)
- -
- - - - - - - - - {% for zone in (zones or []) %} - - - - - {% endfor %} - {% if not (zones or []) %} - - - - {% endif %} - -
ZoneMasquerade
{{ zone.get('name', 'unnamed') }} -
- - -
-
No zones configured
-
- -
Port Forwarding (DNAT)
- -
-

Add Forward Rule

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
-
- -
- - - - - - - - - - - - - {% set all_forwards = [] %} - {% for zone in (zones or []) %} - {% for fwd in zone.get('forward_ports', []) %} - {% set _ = all_forwards.append({'zone': zone.get('name'), 'proto': fwd.get('proto'), 'port': fwd.get('port'), 'toaddr': fwd.get('toaddr'), 'toport': fwd.get('toport')}) %} - {% endfor %} - {% endfor %} - {% for fwd in all_forwards %} - - - - - - - - - {% endfor %} - {% if not all_forwards %} - - - - {% endif %} - -
ZoneProtoPortTargetTgt PortAction
{{ fwd.zone }}{{ fwd.proto }}{{ fwd.port }}{{ fwd.toaddr }}{{ fwd.toport }} -
- -
-
No port forwarding rules configured
-
- -{% endblock %} diff --git a/webui/templates/proxy.html b/webui/templates/proxy.html deleted file mode 100644 index f9fd9ab..0000000 --- a/webui/templates/proxy.html +++ /dev/null @@ -1,151 +0,0 @@ -{% extends "base.html" %} -{% block title %}Proxy - Vacuum Wall{% endblock %} - -{% block content %} - - -
- - - - - - - - - - - - - {% for domain in (domains or []) %} - - - - - - - - - {% endfor %} - {% if not (domains or []) %} - - - - {% endif %} - -
DomainBackend HostBackend PortProtocolCertificateActions
{{ domain.get('domain', 'unknown') }}{{ domain.get('backend_host', '-') }}{{ domain.get('backend_port', '-') }}{{ domain.get('protocol', 'http') }} - {% set matched_cert = None %} - {% if certs %} - {% for cert in certs %} - {% if cert.get('domain') == domain.get('domain') %} - {% set matched_cert = cert %} - {% endif %} - {% endfor %} - {% endif %} - {% if matched_cert %} - {% if matched_cert.get('expired') %} - Expired - {% elif matched_cert.get('days_remaining') is not none and matched_cert.days_remaining <= 30 %} - {{ matched_cert.days_remaining }}d - {% else %} - Valid - {% endif %} - {% else %} - No cert - {% endif %} - -
- -
- -
-
-
No proxy domains configured. Add a domain to start terminating SSL.
-
- - - - - - - - -{% endblock %} diff --git a/webui/templates/rules.html b/webui/templates/rules.html deleted file mode 100644 index c031ad3..0000000 --- a/webui/templates/rules.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends "base.html" %} -{% block title %}Rules - Vacuum Wall{% endblock %} - -{% block content %} - - -
-

Add Rule

-
-
-
- - -
-
- - -
- -
-
- -
- -
-{% if rules or False %} -{% for zone_name, zone_rules in rules.items() %} -
-

Zone: {{ zone_name or '(default)' }}

- {% if zone_rules %} - - - - - - - - - - {% for rule in zone_rules %} - {% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %} - - - - - - {% endfor %} - -
#RuleAction
{{ loop.index }}{{ rule_obj.rule }} -
- -
-
- {% else %} -
No rich rules configured for this zone.
- {% endif %} -
-{% endfor %} -{% else %} -
-
No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.
-
-{% endif %} -
- -{% if not (zones or []) %} -
-
No zones configured. Create a zone first before adding rich rules.
-
-{% endif %} -{% endblock %} diff --git a/webui/templates/wireguard.html b/webui/templates/wireguard.html deleted file mode 100644 index b7f6f1f..0000000 --- a/webui/templates/wireguard.html +++ /dev/null @@ -1,123 +0,0 @@ -{% extends "base.html" %} -{% block title %}WireGuard - Vacuum Wall{% endblock %} - -{% block content %} - - - -
-
-
-

- - Tunnel State: {{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }} -

-
-
- {% if config %} - Listen Port: {{ config.get('listen_port', 'N/A') }}  |  - Public Key: {{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...  |  - Address: {{ config.get('address', 'N/A') }} - {% endif %} -
-
-
- - -
Peers
- -
-

Add Peer

-
-
-
- - -
-
- - -
-
- - -
- -
-
-
- - -
- - - - - - - - - - - - - - {% for peer in (peers or []) %} - - - - - - - - - - {% endfor %} - {% if not (peers or []) %} - - - - {% endif %} - -
NamePublic KeyAllowed IPsEndpointLatest HandshakeTransferActions
- - {{ peer.get('name', 'unnamed') }} - {{ peer.get('public_key', 'N/A')[:20] }}...{{ peer.get('allowed_ips', '-') }}{{ peer.get('endpoint', '-') }}{{ peer.get('latest_handshake', 'Never') or 'Never' }} -
Recv: {{ peer.get('transfer_recv', '0') or '0' }}
-
Sent: {{ peer.get('transfer_sent', '0') or '0' }}
-
-
- -
- -
-
-
No peers configured. Add a peer above.
-
- - -{% endblock %} diff --git a/webui/templates/zones.html b/webui/templates/zones.html deleted file mode 100644 index ac30be1..0000000 --- a/webui/templates/zones.html +++ /dev/null @@ -1,90 +0,0 @@ -{% extends "base.html" %} -{% block title %}Zones - Vacuum Wall{% endblock %} - -{% block content %} - - -
- {% for zone in (zones or []) %} -
-
-
-

{{ zone.get('name', 'unnamed') }}

-
- {% if zone.get('target') %}Target: {{ zone.target }}{% endif %} -
-
-
- -
-
Interfaces
- {% if zone.get('interfaces') %} - {% for iface in zone.interfaces %} - {{ iface }} - {% endfor %} - {% else %} - None - {% endif %} -
- -
-
Services
- {% if zone.get('services') %} - {% for svc in zone.services %} - {{ svc }} - {% endfor %} - {% else %} - None - {% endif %} -
- -
-
- -
-
-
- {% endfor %} - {% if not (zones or []) %} -
-
No zones configured. Create a zone to get started.
-
- {% endif %} -
- - - -{% endblock %}