refactor: replace Jinja templates with static frontend pages

This commit is contained in:
2026-06-16 03:35:41 +00:00
parent 2874680ffa
commit 593dece92b
39 changed files with 3532 additions and 2801 deletions
+50 -365
View File
@@ -12,15 +12,14 @@ import os
import signal import signal
import sys import sys
import time import time
from datetime import datetime
from pathlib import Path 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.client import get
from daemon.iface import GET_STATUS_ALL
from lib.logging import setup_logging from lib.logging import setup_logging
from lib.network import get_config
from webui.api.certs import bp as certs_bp from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp from webui.api.firewall import bp as firewall_bp
@@ -79,6 +78,15 @@ signal.signal(signal.SIGHUP, _sighup_handler)
app = Flask(__name__) app = Flask(__name__)
app.config["SECRET_KEY"] = os.urandom(32).hex() 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(firewall_bp, url_prefix="/api/firewall")
app.register_blueprint(network_bp, url_prefix="/api/network") app.register_blueprint(network_bp, url_prefix="/api/network")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp") app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
@@ -100,7 +108,6 @@ BLUEPRINTS = [
for name, _ in BLUEPRINTS: for name, _ in BLUEPRINTS:
logger.info("Registered blueprint '%s' at /api/%s", name, name) logger.info("Registered blueprint '%s' at /api/%s", name, name)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request logging # Request logging
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -132,387 +139,65 @@ def _log_request_finish(response):
response.status_code, response.status_code,
elapsed_ms, 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 return response
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jinja2 custom filters # API proxy routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.template_filter("timestamp") @app.route("/api/status/all")
def timestamp_filter(value): def api_status_all():
"""Convert an ISO-8601 timestamp string to ``YYYY-MM-DD HH:MM:SS``. """Return aggregated status from all subsystems.
Args: Proxies the daemon's ``/status/all`` endpoint for SPA consumption.
value: ISO timestamp string (may end with ``Z``).
Returns: Returns:
Formatted date string, or original value on parse failure. JSON response with state data for all subsystems.
"""
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.
""" """
try: try:
num = float(value) return {"ok": True, "data": get(GET_STATUS_ALL)}
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()
except Exception as exc: except Exception as exc:
logger.warning("WebUI data load failed: %s", exc) logger.warning("Status all failed: %s", exc)
return default 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: SPA_DIR = STATIC_DIR
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")
@app.route("/") @app.route("/")
def root_redirect(): @app.route("/<path:path>")
"""Redirect root URL to the dashboard. def spa_page(path=""):
"""Single-page application catch-all.
GET / Serves ``index.html`` (rendered as a Jinja2 template) for all non-API,
non-static paths. The client-side router handles navigation and defaults
Returns: to ``#dashboard``.
Redirect response to the dashboard page.
""" """
from flask import redirect, url_for if path.startswith("api/") or path.startswith("static/"):
abort(404)
return redirect(url_for("dashboard")) scheme = "wss" if request.is_secure else "ws"
ws_url = f"{scheme}://{request.host}/ws"
html = (SPA_DIR / "index.html").read_text()
@app.route("/dashboard") return html.replace("__WS_URL__", ws_url)
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 __name__ == "__main__": if __name__ == "__main__":
+92 -565
View File
@@ -1,578 +1,105 @@
// Toast notifications import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=4';
const showToast = (message, type, duration = 4000) => {
const container = document.getElementById('toast-container'); import DashboardPage from '/static/pages/dashboard.js?v=4';
if (!container) return; import InterfacesPage from '/static/pages/interfaces.js?v=4';
const toast = document.createElement('div'); import ZonesPage from '/static/pages/zones.js?v=4';
toast.className = 'toast toast-' + type; import RulesPage from '/static/pages/rules.js?v=4';
toast.textContent = message; import NatPage from '/static/pages/nat.js?v=4';
container.appendChild(toast); import DhcpPage from '/static/pages/dhcp.js?v=4';
requestAnimationFrame(() => toast.classList.add('show')); import ProxyPage from '/static/pages/proxy.js?v=4';
setTimeout(() => { import CertsPage from '/static/pages/certs.js?v=4';
toast.classList.remove('show'); import WireguardPage from '/static/pages/wireguard.js?v=4';
setTimeout(() => toast.remove(), 300); import LogsPage from '/static/pages/logs.js?v=4';
}, duration); 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'); /* ── Router ────────────────────────────────────────────────── */
const router = {
const showErrorToast = (msg) => showToast(msg, 'error'); state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const showWarningToast = (msg) => showToast(msg, 'warning'); const name = this.state.path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
// Modal helpers return hComp(page, this.state.path);
const openModal = (id) => { },
const el = document.getElementById(id);
if (el) el.classList.add('active');
}; };
const closeModal = (id) => { window.location.hash || (window.location.hash = router.state.path);
const el = document.getElementById(id); window.addEventListener('hashchange', () => {
if (el) el.classList.remove('active'); router.state.path = location.hash.slice(1) || '/dashboard';
};
// 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');
}
}); });
document.body.addEventListener('htmx:responseError', (evt) => { /* ── Sidebar component ─────────────────────────────────────── */
const status = evt.detail.xhr?.status || 0; function Sidebar() {
const json = evt.detail.xhr?.response; const current = router.state.path;
let msg = 'Request failed (' + status + ')'; return h('div', { class: 'sidebar' },
try { h('div', { class: 'logo' }, 'Vacuum Wall'),
const parsed = JSON.parse(json); h('nav', null,
if (parsed.error) msg = parsed.error; Nav.map(item =>
} catch (e) {} Link({
showToast(msg, 'error'); path: item.path,
}); class: current === item.path ? 'active' : '',
children: [item.label],
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 '<div class="card"><div class="text-muted text-sm">No zones configured. Create a zone to get started.</div></div>';
return active.map(zone =>
'<div class="card" style="position:relative;">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;">' +
'<div><h3 style="font-size:16px;color:var(--accent);">' + escHtml(zone.name) + '</h3>' +
'<div class="text-muted text-sm" style="margin-bottom:10px;">' + (zone.target ? 'Target: ' + escHtml(zone.target) : '') + '</div></div></div>' +
'<div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Interfaces</div>' +
(zone.interfaces && zone.interfaces.length ? zone.interfaces.map(i => '<span class="badge badge-info">' + escHtml(i) + '</span>').join('') : '<span class="text-muted">None</span>') +
'</div><div class="text-sm mb-4"><div class="text-muted" style="margin-bottom:4px;">Services</div>' +
(zone.services && zone.services.length ? zone.services.map(s => '<span class="badge badge-success">' + escHtml(s) + '</span>').join('') : '<span class="text-muted">None</span>') +
'</div><div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">' +
'<form hx-delete="/api/firewall/zones/' + escAttr(zone.name) + '" hx-swap="none" hx-confirm="Delete zone ' + escHtml(zone.name) + '? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/zones\', document.getElementById(\'zone-grid\'), renderZones); showSuccessToast(\'Zone deleted\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></div>'
).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 += '<div class="card"><h3>Zone: <span style="color:var(--accent);">' + escHtml(zone || '(default)') + '</span></h3>';
if (entries.length) {
html += '<table><thead><tr><th>#</th><th>Rule</th><th style="width:80px;">Action</th></tr></thead><tbody>';
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 += '<tr><td class="text-muted">' + (i + 1) + '</td>' +
'<td style="font-family:monospace;font-size:12px;word-break:break-all;" hx-disable>' + escHtml(ruleText) + '</td>' +
'<td><form hx-delete="/api/firewall/rich-rules/' + escAttr(zone) + (ruleId ? '/' + encodeURIComponent(ruleId) : '') + '"' +
(ruleId ? '' : ' hx-encoding="json" hx-vals=\'{"rule": ' + JSON.stringify(ruleText) + ' }\'') +
' hx-swap="none" hx-confirm="Remove rule ' + escHtml(ruleText.substring(0, 40)) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'rules-container\'), renderRules); showSuccessToast(\'Rule removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
});
html += '</tbody></table>';
} else {
html += '<div class="text-muted text-sm">No rich rules configured for this zone.</div>';
}
html += '</div>';
});
return html || '<div class="card"><div class="text-muted text-sm">No rules loaded.</div></div>';
};
const renderForwards = (forwards) => {
if (!forwards.length) return '<tr><td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td></tr>';
return forwards.map(fwd => {
const proto = fwd['proxy-protocol'] || fwd.proto;
return '<tr><td><strong>' + escHtml(fwd.zone) + '</strong></td>' +
'<td><span class="badge badge-info">' + escHtml(proto) + '</span></td>' +
'<td>' + fwd.port + '</td><td>' + escHtml(fwd['to-addr'] || fwd.toaddr) + '</td>' +
'<td>' + (fwd['to-port'] || fwd.toport || '-') + '</td>' +
'<td><form hx-delete="/api/firewall/forward-port/' + encodeURIComponent(fwd.zone) + '/' + fwd.port + '/' + encodeURIComponent(proto) + '" hx-swap="none" hx-confirm="Remove forward rule ' + fwd.port + '/' + proto + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/firewall/config\', document.getElementById(\'forward-rows\'), renderForwardsFromConfig); showSuccessToast(\'Rule removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>';
}).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 '<tr><td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td></tr>';
return ranges.map(rng =>
'<tr><td>' + escHtml(rng.interface || '(global)') + '</td>' +
'<td>' + escHtml(rng.start) + '</td><td>' + escHtml(rng.end) + '</td>' +
'<td>' + escHtml(rng.lease_time || '1h') + '</td>' +
'<td><form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals=\'{"interface": "' + escAttr(rng.interface || '') + '", "start": "' + escAttr(rng.start) + '", "end": "' + escAttr(rng.end) + '"}\' hx-swap="none" hx-confirm="Remove DHCP range ' + escHtml(rng.start) + ' - ' + escHtml(rng.end) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'range-rows\'), renderRanges); showSuccessToast(\'Range removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderStaticLeases = (leases) => {
if (!leases.length) return '<tr><td colspan="4" class="text-muted text-sm">No static leases configured</td></tr>';
return leases.map(lease =>
'<tr><td>' + escHtml(lease.mac) + '</td><td>' + escHtml(lease.ip) + '</td>' +
'<td>' + escHtml(lease.hostname || '-') + '</td>' +
'<td><form hx-delete="/api/dhcp/static-lease/' + encodeURIComponent(lease.mac) + '" hx-swap="none" hx-confirm="Remove lease ' + escHtml(lease.mac) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'lease-rows\'), renderStaticLeases); showSuccessToast(\'Lease removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderDnsRecords = (records) => {
if (!records.length) return '<tr><td colspan="3" class="text-muted text-sm">No custom DNS records</td></tr>';
return records.map(rec =>
'<tr><td><strong>' + escHtml(rec.name || 'unnamed') + '</strong></td>' +
'<td class="text-sm">' + escHtml(rec.address || '-') + '</td>' +
'<td><form hx-delete="/api/dhcp/dns-record/' + encodeURIComponent(rec.name) + '" hx-swap="none" hx-confirm="Remove DNS record ' + escHtml(rec.name || 'unnamed') + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/dhcp/config\', document.getElementById(\'dns-rows\'), renderDnsRecords); showSuccessToast(\'Record removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></td></tr>'
).join('');
};
const renderDomains = (domains) => {
if (!domains.length) return '<tr><td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td></tr>';
return domains.map(d => {
let certHtml = '<span class="badge badge-danger">' + (d.cert_status || 'No cert') + '</span>';
if (d.cert_status === 'expired') certHtml = '<span class="badge badge-danger">Expired</span>';
else if (d.cert_status === 'valid' || d.cert_status === 'active') certHtml = '<span class="badge badge-success">Valid</span>';
else if (typeof d.days_remaining === 'number') {
if (d.days_remaining <= 0) certHtml = '<span class="badge badge-danger">Expired</span>';
else if (d.days_remaining <= 30) certHtml = '<span class="badge badge-warning">' + d.days_remaining + 'd</span>';
else certHtml = '<span class="badge badge-success">Valid</span>';
}
return '<tr><td><strong>' + escHtml(d.domain) + '</strong></td>' +
'<td>' + escHtml(d.backend_host || '-') + '</td>' +
'<td>' + (d.backend_port || '-') + '</td>' +
'<td><span class="badge badge-info">' + escHtml(d.protocol || 'http') + '</span></td>' +
'<td>' + certHtml + '</td>' +
'<td><div class="flex gap-2">' +
'<button class="btn btn-sm btn-outline" onclick="openEditDomainModal(\'' + escAttr(d.domain) + '\', ' + JSON.stringify(d) + ')">Edit</button>' +
'<form hx-delete="/api/proxy/domains/' + escAttr(d.domain) + '" hx-swap="none" hx-confirm="Remove proxy for ' + escHtml(d.domain) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/proxy/domains\', document.getElementById(\'domain-rows\'), renderDomains); showSuccessToast(\'Domain removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Delete</button></form></div></td></tr>';
}).join('');
};
const renderPeers = (peers) => {
if (!peers.length) return '<tr><td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td></tr>';
return peers.map(peer =>
'<tr><td><span class="status-dot ' + (peer.latest_handshake ? 'status-up' : 'status-down') + '"></span>' +
'<strong>' + escHtml(peer.name || 'unnamed') + '</strong></td>' +
'<td style="font-family:monospace;font-size:11px;">' + escHtml((peer.public_key || 'N/A').substring(0, 20)) + '...</td>' +
'<td class="text-sm">' + escHtml(peer.allowed_ips || '-') + '</td>' +
'<td class="text-sm">' + escHtml(peer.endpoint || '-') + '</td>' +
'<td class="text-sm">' + escHtml(peer.latest_handshake || 'Never') + '</td>' +
'<td class="text-sm"><div>Recv: ' + escHtml(peer.transfer_recv || '0') + '</div><div>Sent: ' + escHtml(peer.transfer_sent || '0') + '</div></td>' +
'<td><div class="flex gap-2">' +
'<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig(\'' + escAttr(peer.name) + '\')">Config</button>' +
'<form hx-delete="/api/wireguard/peers/' + encodeURIComponent(peer.name) + '" hx-swap="none" hx-confirm="Remove peer ' + escHtml(peer.name) + '?" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/wireguard/peers\', document.getElementById(\'peer-rows\'), renderPeers); showSuccessToast(\'Peer removed\'); }">' +
'<button type="submit" class="btn btn-sm btn-danger">Remove</button></form></div></td></tr>'
).join('');
};
const renderCerts = (certs) => {
if (!certs.length) return '<tr><td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td></tr>';
return certs.map(cert => {
const days = cert.days_remaining;
let badgeHtml;
if (cert.expired || (days !== undefined && days <= 0)) {
badgeHtml = '<span class="badge badge-danger">Expired' + (days !== undefined && Math.abs(days) ? ' (' + Math.abs(days) + 'd ago)' : '') + '</span>';
} else if (days !== undefined && days <= 30) {
badgeHtml = '<span class="badge badge-warning">' + days + ' days</span>';
} else {
badgeHtml = '<span class="badge badge-success">' + (days !== undefined ? days + ' days' : 'N/A') + '</span>';
}
return '<tr><td><strong>' + escHtml(cert.domain || 'unknown') + '</strong></td>' +
'<td class="text-sm">' + escHtml(cert.issuer || '-') + '</td>' +
'<td>' + escHtml(cert.expiry || 'N/A') + '</td>' +
'<td>' + badgeHtml + '</td>' +
'<td><form hx-post="/api/certs/' + escAttr(cert.domain) + '/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable(\'/api/certs/list\', document.getElementById(\'cert-rows\'), renderCerts); showSuccessToast(\'Renewal started for ' + escHtml(cert.domain) + '\'); }">' +
'<button type="submit" class="btn btn-sm btn-outline">Renew</button></form></td></tr>';
}).join('');
};
const renderInterfaces = (interfaces) => {
if (!interfaces.length) return '<tr><td colspan="5" class="text-muted text-sm">No interfaces found</td></tr>';
return interfaces.map(iface => {
const zoneOptions = (iface.zones || []).map(z =>
'<option value="' + escAttr(z) + '"' + (z === iface.zone ? ' selected' : '') + '>' + escHtml(z) + '</option>'
).join('');
return '<tr><td><strong>' + escHtml(iface.name) + '</strong></td>' +
'<td class="text-muted">' + escHtml(iface.mac || 'N/A') + '</td>' +
'<td>' + (iface.ips && iface.ips.length ? iface.ips.map(ip => escHtml(ip)).join(', ') : 'N/A') + '</td>' +
'<td><span class="status-dot ' + (iface.state === 'up' ? 'status-up' : 'status-down') + '"></span>' +
(iface.state === 'up' ? 'Up' : 'Down') + '</td>' +
'<td><select hx-on::change="assignZone(\'' + escAttr(iface.name) + '\', this)">' + zoneOptions + '</select></td></tr>';
}).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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
};
// ─── Certificate Issue Wizard ────────────────────────────────────────
let _issuePollHandle = null;
let _issueRequestId = null;
function closeIssueWizard() {
if (_issuePollHandle) {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
}
_issueRequestId = null;
resetIssueWizard();
closeModal('issue-cert-modal');
} }
function resetIssueWizard() { /* ── Route component wrapper ───────────────────────────────── */
document.getElementById('cert-wizard-input').style.display = ''; function RouteComponent() {
document.getElementById('cert-wizard-progress').style.display = 'none'; return router.component();
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';
} }
function validateCertIssue() { /* ── App layout ────────────────────────────────────────────── */
const domain = document.getElementById('cert-domain').value.trim(); function AppLayout() {
if (!domain) { return [
showErrorToast('Domain is required'); h('div', { class: 'layout' },
return; 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; // Defer connect() after the first render microtask settles to prevent
// the initial requestUpdate() from triggering a second commit while
const checkBtn = document.getElementById('cert-check-btn'); // the vnode tree is still being finalized.
checkBtn.disabled = true; setTimeout(connect, 0);
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);
});
} }
function renderChecks(checks) { document.addEventListener('DOMContentLoaded', initApp);
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 '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;">' +
'<span class="badge ' + badge + '">' + icon + '</span>' +
'<span>' + escHtml(c.name).replace(/_/g, ' ') + '</span>' +
'<span class="text-muted" style="flex:1;text-align:right;">' + escHtml(c.message || '') + '</span>' +
'</div>';
}).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 = '<div class="text-muted text-sm" style="margin:16px 0;">Starting certificate issuance…</div>';
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 = '<div class="text-muted text-sm">Pending…</div>';
return;
}
container.innerHTML = steps.map(s => {
let icon;
if (s.status === 'done') icon = '<span class="status-dot status-up"></span>';
else if (s.status === 'running') icon = '<span class="status-dot status-pending"></span>';
else if (s.status === 'error') icon = '<span class="status-dot status-down"></span>';
else icon = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border);margin-right:6px;"></span>';
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">' +
icon +
'<span>' + escHtml(s.label) + '</span>' +
(s.status === 'running' ? '<span class="text-muted text-sm">(in progress…)</span>' :
s.status === 'error' ? '<span class="badge badge-danger" style="margin-left:auto;">' + escHtml(s.message || 'failed') + '</span>' :
'<span class="badge badge-success" style="margin-left:auto;">done</span>') +
'</div>';
}).join('');
if (status === 'completed') {
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
}
}
// ─── 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 = '<input type="text" class="route-dest" placeholder="Destination CIDR" style="flex:1;" />' +
'<input type="text" class="route-gw" placeholder="Gateway" style="flex:1;" />' +
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button>';
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) =>
'<div class="route-row" style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">' +
'<input type="text" class="route-dest" value="' + safe(r.destination) + '" placeholder="Destination CIDR" style="flex:1;" />' +
'<input type="text" class="route-gw" value="' + safe(r.gateway) + '" placeholder="Gateway" style="flex:1;" />' +
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button></div>'
).join('') || '<div class="text-muted text-sm">No static routes</div>';
};
+95
View File
@@ -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'),
),
),
);
}
+142
View File
@@ -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 }, []);
}
+59
View File
@@ -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 || []);
}
+26
View File
@@ -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,
);
}
+103
View File
@@ -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 = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
+ fields.map(f => {
if (f.tag === 'select')
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '">'
+ (f.options || []).map(o =>
typeof o === 'string'
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
: '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + esc(o[1]) + '</option>',
).join('') + '</select></div>';
const tag = f.tag || 'input';
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
+ '></' + tag + '></div>';
}).join('') + '</div><div class="modal-actions">'
+ actions.map(a =>
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
).join('') + '</div>';
actions.forEach(a => {
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
if (btn) btn.addEventListener('click', a.handler);
});
}
+40
View File
@@ -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'),
),
),
);
}
+51
View File
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* 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 : [];
}
+41
View File
@@ -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';
+59
View File
@@ -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;
}
});
}
+225
View File
@@ -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;
}
}
}
+61
View File
@@ -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 || []);
}
+317
View File
@@ -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);
}
+134
View File
@@ -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());
}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vacuum Wall</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="app"></div>
<script>window.__WS_URL__ = "__WS_URL__"</script>
<script type="module" src="/static/app.js?v=4"></script>
</body>
</html>
+157
View File
@@ -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.' }),
];
},
});
+104
View File
@@ -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' }),
),
),
),
),
),
];
},
});
+326
View File
@@ -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,
];
},
});
+159
View File
@@ -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'),
),
]),
),
),
),
];
},
});
+79
View File
@@ -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')
)
),
];
},
});
+186
View File
@@ -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'),
),
]),
),
),
),
];
},
});
+19
View File
@@ -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),
),
];
},
});
+187
View File
@@ -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.' }),
];
},
});
+132
View File
@@ -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.' })]),
];
},
});
+212
View File
@@ -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.' }),
];
},
});
+233
View File
@@ -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.' }),
];
},
});
+229 -1
View File
@@ -301,10 +301,40 @@ body {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
animation: toastSlideIn 0.3s ease forwards; animation: toastSlideIn 0.3s ease forwards;
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between;
gap: 0.6rem; 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 { .toast-message.toast-success {
background: var(--success); background: var(--success);
color: #fff; color: #fff;
@@ -464,6 +494,204 @@ body {
.mb-1 { margin-bottom: 0.5rem; } .mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; } .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 */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
-598
View File
@@ -1,598 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vacuum Wall{% endblock %}</title>
<style>
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--bg-card-hover: #0f3460d0;
--accent: #00b4d8;
--accent-hover: #0096c7;
--text: #e0e0e0;
--text-muted: #888;
--danger: #e63946;
--danger-hover: #c62828;
--success: #2ecc71;
--warning: #f1c40f;
--border: #1a1a3e;
--input-bg: #0d1b2a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text);
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 0;
flex-shrink: 0;
position: fixed;
top: 0;
left: 0;
bottom: 0;
overflow-y: auto;
z-index: 100;
}
.sidebar-header {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: 700;
color: var(--accent);
letter-spacing: 1px;
}
.sidebar-header span {
color: var(--text-muted);
font-weight: 400;
font-size: 11px;
display: block;
margin-top: 2px;
letter-spacing: 0;
}
.sidebar nav {
padding: 8px 0;
}
.sidebar nav a {
display: flex;
align-items: center;
padding: 10px 20px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.sidebar nav a:hover {
color: var(--text);
background: rgba(0, 180, 216, 0.05);
}
.sidebar nav a.active {
color: var(--accent);
background: rgba(0, 180, 216, 0.08);
border-left-color: var(--accent);
}
/* Main */
.main {
margin-left: 220px;
flex: 1;
padding: 24px 32px;
min-height: 100vh;
width: calc(100vw - 220px);
}
.page-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h1 {
font-size: 22px;
font-weight: 600;
}
.page-header .subtitle {
color: var(--text-muted);
font-size: 13px;
margin-top: 4px;
}
/* Cards */
.card {
background: var(--bg-card);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border);
}
.card h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border-radius: 8px;
padding: 18px 20px;
border: 1px solid var(--border);
}
.stat-card .label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
margin-top: 6px;
color: var(--accent);
}
.stat-card .meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: var(--danger-hover);
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
.btn-outline {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
.btn-outline:hover {
background: rgba(0, 180, 216, 0.1);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
th {
color: var(--text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tr:hover td {
background: rgba(0, 180, 216, 0.03);
}
/* Forms */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 5px;
font-weight: 500;
}
input[type="text"],
input[type="number"],
input[type="email"],
input[type="password"],
input[type="url"],
select,
textarea {
width: 100%;
padding: 8px 12px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
}
textarea {
resize: vertical;
min-height: 80px;
}
select {
cursor: pointer;
}
/* Badges */
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: rgba(46, 204, 113, 0.15); color: var(--success); }
.badge-warning { background: rgba(241, 196, 15, 0.15); color: var(--warning); }
.badge-danger { background: rgba(230, 57, 70, 0.15); color: var(--danger); }
.badge-info { background: rgba(0, 180, 216, 0.15); color: var(--accent); }
/* Status indicator */
.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); }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
font-size: 16px;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
/* Toast */
.toast-container {
position: fixed;
top: 16px;
right: 16px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 18px;
border-radius: 8px;
font-size: 13px;
min-width: 250px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateX(20px);
transition: all 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateX(0);
}
.toast-success { background: #0d3b2e; border: 1px solid var(--success); color: var(--success); }
.toast-error { background: #3b0d0d; border: 1px solid var(--danger); color: var(--danger); }
.toast-warning { background: #3b3408; border: 1px solid var(--warning); color: var(--warning); }
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
gap: 0;
}
.tab {
padding: 10px 18px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: none;
border-top: none;
border-left: none;
border-right: none;
transition: all 0.15s;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Scrollable log */
.log-viewer {
background: #0a0a14;
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
max-height: 600px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text);
}
/* Toggle switch */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch .slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--border);
border-radius: 22px;
transition: 0.2s;
}
.switch .slider:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background: var(--text);
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + .slider {
background: var(--accent);
}
.switch input:checked + .slider:before {
transform: translateX(18px);
}
/* Flex utils */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; }
.gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.mb-4 { margin-bottom: 16px; }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.text-right { text-align: right; }
.w-full { width: 100%; }
/* Service status list */
.service-list {
list-style: none;
}
.service-list li {
padding: 6px 0;
display: flex;
align-items: center;
font-size: 13px;
}
.service-list .svc-name {
flex: 1;
}
/* Inline form row */
.inline-form {
display: flex;
gap: 8px;
align-items: flex-end;
flex-wrap: wrap;
}
.inline-form .form-group {
margin-bottom: 0;
}
/* Section titles */
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
margin-top: 24px;
}
.section-title:first-child { margin-top: 0; }
/* Auto-refresh indicator */
.htmx-indicator {
opacity: 0;
transition: opacity 0.3s;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main {
margin-left: 0;
width: 100vw;
padding: 16px;
}
.card-grid {
grid-template-columns: 1fr;
}
.inline-form {
flex-direction: column;
align-items: stretch;
}
}
</style>
</head>
<body hx-ext="json-enc">
<aside class="sidebar">
<div class="sidebar-header">
VACUUM WALL
<span>Firewall Management</span>
</div>
<nav>
<a href="/dashboard" class="{{ 'active' if request.path == '/dashboard' or request.path == '/' else '' }}">Dashboard</a>
<a href="/interfaces" class="{{ 'active' if request.path == '/interfaces' else '' }}">Interfaces</a>
<a href="/zones" class="{{ 'active' if request.path == '/zones' else '' }}">Zones</a>
<a href="/rules" class="{{ 'active' if request.path == '/rules' else '' }}">Rules</a>
<a href="/nat" class="{{ 'active' if request.path == '/nat' else '' }}">NAT</a>
<a href="/dhcp" class="{{ 'active' if request.path == '/dhcp' else '' }}">DHCP & DNS</a>
<a href="/proxy" class="{{ 'active' if request.path == '/proxy' else '' }}">Proxy</a>
<a href="/certs" class="{{ 'active' if request.path == '/certs' else '' }}">Certificates</a>
<a href="/wireguard" class="{{ 'active' if request.path == '/wireguard' else '' }}">WireGuard</a>
<a href="/logs" class="{{ 'active' if request.path == '/logs' else '' }}">Logs</a>
</nav>
</aside>
<main class="main">
{% block content %}{% endblock %}
</main>
<div class="toast-container" id="toast-container"></div>
<script src="/static/htmx.min.js"></script>
<script src="/static/json-enc.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
-94
View File
@@ -1,94 +0,0 @@
{% extends "base.html" %}
{% block title %}Certificates - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Certificates</h1>
<div class="subtitle">SSL/TLS certificate management</div>
</div>
<button class="btn btn-primary" onclick="resetIssueWizard(); openModal('issue-cert-modal')">+ Issue New Certificate</button>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Issuer</th>
<th>Expiry Date</th>
<th>Days Left</th>
<th style="width:120px;">Actions</th>
</tr>
</thead>
<tbody id="cert-rows">
{% for cert in (certs or []) %}
<tr>
<td><strong>{{ cert.get('domain', 'unknown') }}</strong></td>
<td class="text-sm">{{ cert.get('issuer', '-') }}</td>
<td>{{ cert.get('expiry', 'N/A') }}</td>
<td>
{% set days = cert.get('days_remaining') %}
{% if cert.get('expired') or (days is not none and days <= 0) %}
<span class="badge badge-danger">Expired{% if days %} ({{ days }}d ago){% endif %}</span>
{% elif days is not none and days <= 30 %}
<span class="badge badge-warning">{{ days }} days</span>
{% else %}
<span class="badge badge-success">{{ days }} days</span>
{% endif %}
</td>
<td>
<form hx-post="/api/certs/{{ cert.get('domain', '') }}/renew" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Renewal started for {{ cert.domain }}'); }">
<button type="submit" class="btn btn-sm btn-outline">Renew</button>
</form>
</td>
</tr>
{% endfor %}
{% if not (certs or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No certificates found. Issue a certificate to get started.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Issue Certificate Modal — Phase 1: Validate -->
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeIssueWizard()">
<div class="modal" style="min-width:480px;">
<h2>Issue New Certificate</h2>
<!-- Phase 1: Input + Pre-flight Checks -->
<div id="cert-wizard-input">
<div class="form-group">
<label for="cert-domain">Domain</label>
<input type="text" id="cert-domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="cert-email">Contact Email</label>
<input type="email" id="cert-email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
</div>
<!-- Pre-flight validation results (shown after Check) -->
<div id="cert-check-results" style="display:none;">
<div class="section-title" style="margin-top:16px;">Pre-flight Checks</div>
<div id="cert-checks-list"></div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeIssueWizard()">Cancel</button>
<button type="button" id="cert-check-btn" class="btn btn-primary" onclick="validateCertIssue()">Check</button>
<button type="button" id="cert-issue-btn" class="btn btn-primary" style="display:none;" onclick="startCertIssue()">Issue</button>
</div>
</div>
<!-- Phase 2: Step progress -->
<div id="cert-wizard-progress" style="display:none;">
<div id="cert-steps-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" id="cert-close-progress" style="display:none;" onclick="closeIssueWizard(); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts);">Done</button>
</div>
</div>
</div>
</div>
{% endblock %}
-124
View File
@@ -1,124 +0,0 @@
{% extends "base.html" %}
{% block title %}Dashboard - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Dashboard</h1>
<div class="subtitle">System overview and status</div>
</div>
</div>
<div class="card-grid">
<div class="stat-card">
<div class="label">Zones</div>
<div class="value">{{ active_zones|default({})|length }}</div>
<div class="meta">Firewalld zones configured</div>
</div>
<div class="stat-card">
<div class="label">Proxy Domains</div>
<div class="value">{{ domains|default([])|length }}</div>
<div class="meta">SSL-terminated backends</div>
</div>
<div class="stat-card">
<div class="label">Certificates</div>
<div class="value">{{ certs|default([])|length }}</div>
{% 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 %}
<div class="meta">
{% if expired > 0 %}<span style="color:var(--danger)">{{ expired }} expired</span>. {% endif %}
{% if expiring > 0 %}<span style="color:var(--warning)">{{ expiring }} expiring soon</span>.{% endif %}
{% if expired == 0 and expiring == 0 %}All valid{% endif %}
</div>
</div>
<div class="stat-card">
<div class="label">WireGuard</div>
<div class="value" style="font-size:20px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
{{ 'UP' if (wg_status is defined and wg_status.get('up')) else 'DOWN' }}
</div>
<div class="meta">Tunnel state</div>
</div>
<div class="stat-card">
<div class="label">Active Leases</div>
<div class="value">{{ dnsmasq.get('leases', [])|length }}</div>
<div class="meta">DHCP clients connected</div>
</div>
</div>
<div class="section-title">Services</div>
<div class="card-grid">
{% for svc_name, svc in (services or {}).items() %}
<div class="stat-card">
<div class="label">{{ svc_name }}</div>
<div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if svc.get('running') else 'status-down' }}"></span>
{{ 'Running' if svc.get('running') else 'Stopped' }}
</div>
<div class="meta">
{% if svc.get('pid') %}PID {{ svc.pid }}{% endif %}
{% if svc.get('since') %} · {{ svc.since }}{% endif %}
</div>
</div>
{% endfor %}
{% if not (services or {}) %}
<div class="stat-card">
<div class="label">Firewalld</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">Dnsmasq</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">Nginx</div>
<div class="value" style="font-size:16px;">
<span class="status-dot status-up"></span>Running
</div>
</div>
<div class="stat-card">
<div class="label">wg0</div>
<div class="value" style="font-size:16px;">
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('up')) else 'status-down' }}"></span>
{{ 'Up' if (wg_status is defined and wg_status.get('up')) else 'Down' }}
</div>
</div>
{% endif %}
</div>
{% 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) %}
<div class="section-title">Warnings &amp; Activity</div>
<div class="card">
{% if warnings|length > 0 %}
<ul class="service-list">
{% for w in warnings %}
<li>
<span class="status-dot status-pending"></span>
<span class="svc-name">{{ w }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% if not warnings and not (services or {}) %}
<div class="text-muted text-sm">No warnings</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
-214
View File
@@ -1,214 +0,0 @@
{% extends "base.html" %}
{% block title %}DHCP & DNS - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>DHCP &amp; DNS</h1>
<div class="subtitle">Dnsmasq configuration and lease management</div>
</div>
</div>
<!-- DHCP Ranges -->
<div class="section-title">DHCP Ranges</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/ranges" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('DHCP range added'); }">
<div class="inline-form">
<div class="form-group">
<label for="range-interface">Interface</label>
<select id="range-interface" name="interface">
<option value="">— Global —</option>
{% for iface in (interfaces or []) %}
<option value="{{ iface.get('name', '') }}">{{ iface.get('name', '') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="range-start">Start IP</label>
<input type="text" id="range-start" name="start" placeholder="192.168.1.100" required style="width:160px;">
</div>
<div class="form-group">
<label for="range-end">End IP</label>
<input type="text" id="range-end" name="end" placeholder="192.168.1.200" required style="width:160px;">
</div>
<div class="form-group">
<label for="range-lease">Lease Time</label>
<input type="text" id="range-lease" name="lease_time" placeholder="1h" value="{{ (config or {}).get('dhcp_lease_time', '1h') }}" style="width:80px;">
</div>
<button type="submit" class="btn btn-primary">Add Range</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>Interface</th>
<th>Start</th>
<th>End</th>
<th>Lease Time</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="range-rows">
{% for rng in ((config or {}).get('dhcp_ranges', []) or []) %}
<tr>
<td>{{ rng.get('interface', '(global)') }}</td>
<td>{{ rng.get('start', '') }}</td>
<td>{{ rng.get('end', '') }}</td>
<td>{{ rng.get('lease_time', '1h') }}</td>
<td>
<form hx-delete="/api/dhcp/ranges" hx-encoding="json" hx-vals='{"interface": "{{ rng.get("interface", "") }}", "start": "{{ rng.get("start", "") }}", "end": "{{ rng.get("end", "") }}" }' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('range-rows'), renderRanges); showSuccessToast('Range removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DHCP range {{ rng.get('start', '') }} - {{ rng.get('end', '') }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('dhcp_ranges', []) or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No DHCP ranges configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Static Leases -->
<div class="section-title">Static Leases</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/static-lease" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Static lease added'); }">
<div class="inline-form">
<div class="form-group">
<label for="lease-mac">MAC Address</label>
<input type="text" id="lease-mac" name="mac" placeholder="aa:bb:cc:dd:ee:ff" required style="width:180px;">
</div>
<div class="form-group">
<label for="lease-ip">IP Address</label>
<input type="text" id="lease-ip" name="ip" placeholder="192.168.1.50" required style="width:160px;">
</div>
<div class="form-group">
<label for="lease-host">Hostname</label>
<input type="text" id="lease-host" name="hostname" placeholder="myhost" style="width:140px;">
</div>
<button type="submit" class="btn btn-primary">Add Lease</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>MAC</th>
<th>IP</th>
<th>Hostname</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="lease-rows">
{% for lease in ((config or {}).get('static_leases', []) or []) %}
<tr>
<td>{{ lease.get('mac', '') }}</td>
<td>{{ lease.get('ip', '') }}</td>
<td>{{ lease.get('hostname', '-') }}</td>
<td>
<form hx-delete="/api/dhcp/static-lease/{{ lease.get('mac', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('lease-rows'), renderStaticLeases); showSuccessToast('Lease removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove lease {{ lease.get('mac', '') }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('static_leases', []) or []) %}
<tr>
<td colspan="4" class="text-muted text-sm">No static leases configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Custom DNS Records -->
<div class="section-title">Custom DNS Records</div>
<div class="card mb-4">
<form hx-post="/api/dhcp/dns-record" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('DNS record added'); }">
<div class="inline-form">
<div class="form-group">
<label for="dns-ip">IP Address</label>
<input type="text" id="dns-ip" name="address" placeholder="192.168.1.10" required style="width:160px;">
</div>
<div class="form-group">
<label for="dns-hostname">Hostname / Domain</label>
<input type="text" id="dns-hostname" name="name" placeholder="host.local" required style="width:200px;">
</div>
<button type="submit" class="btn btn-primary">Add Record</button>
</div>
</form>
<div class="mt-4">
<table>
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="dns-rows">
{% for rec in ((config or {}).get('dns_records', []) or []) %}
<tr>
<td><strong>{{ rec.get('name', 'unnamed') }}</strong></td>
<td class="text-sm">{{ rec.get('address', '-') }}</td>
<td>
<form hx-delete="/api/dhcp/dns-record/{{ rec.get('name', '') }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/dhcp/config', document.getElementById('dns-rows'), renderDnsRecords); showSuccessToast('Record removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove DNS record {{ rec.get('name', '') }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not ((config or {}).get('dns_records', []) or []) %}
<tr>
<td colspan="3" class="text-muted text-sm">No custom DNS records</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Current DHCP Leases -->
<div class="section-title">Current DHCP Leases</div>
<div class="card">
<table>
<thead>
<tr>
<th>Expires</th>
<th>MAC</th>
<th>IP</th>
<th>Hostname</th>
<th>Client ID</th>
</tr>
</thead>
<tbody>
{% for lease in (leases or []) %}
<tr>
<td>{{ lease.get('expires', 'N/A') }}</td>
<td>{{ lease.get('mac', 'N/A') }}</td>
<td>{{ lease.get('ip', 'N/A') }}</td>
<td>{{ lease.get('hostname', '*') or '*' }}</td>
<td class="text-muted text-sm">{{ lease.get('client_id', 'N/A') }}</td>
</tr>
{% endfor %}
{% if not (leases or []) %}
<tr>
<td colspan="5" class="text-muted text-sm">No active DHCP leases</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="mt-2 text-right">
<button class="btn btn-sm btn-outline" hx-post="/api/dhcp/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Dnsmasq configuration reloaded'); }">Apply &amp; Restart Dnsmasq</button>
</div>
</div>
{% endblock %}
-111
View File
@@ -1,111 +0,0 @@
{% extends "base.html" %}
{% block title %}Interfaces - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Interfaces</h1>
<div class="subtitle">Network interface to zone bindings</div>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Interface</th>
<th>MAC Address</th>
<th>IP Address</th>
<th>State</th>
<th>Zone</th>
<th>IP Config</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="interface-list">
{% 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 [] %}
<tr data-iface="{{ iface.get('name', '') }}">
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
<td>
{% for ip in iface.get('ips', []) %}
{{ ip }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% if not iface.get('ips') %}N/A{% endif %}
</td>
<td>
<span class="status-dot {{ 'status-up' if iface.get('state') == 'UP' else 'status-down' }}"></span>
{{ 'Up' if iface.get('state') == 'UP' else 'Down' }}
</td>
<td>
{% if zones %}
<select
hx-on::change="assignZone('{{ iface.get('name', '') }}', this)"
>
{% for zname in zones %}
<option value="{{ zname }}" {% if zname == iface.get('zone') %}selected{% endif %}>{{ zname }}</option>
{% endfor %}
</select>
{% else %}
<span class="text-muted">No zones configured</span>
{% endif %}
</td>
<td style="min-width: 280px;">
<div style="display:flex;flex-direction:column;gap:6px;">
<div style="margin-bottom:4px;">
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">Addresses</label>
<input type="text" id="addrs-{{ iface.get('name', '') }}" value="{{ addrs }}" placeholder="192.168.1.1/24" />
</div>
<div style="margin-bottom:4px;">
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">Gateway</label>
<input type="text" id="gw-{{ iface.get('name', '') }}" value="{{ gw }}" placeholder="e.g. 192.168.1.254" />
</div>
<div style="margin-bottom:4px;">
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">DNS</label>
<input type="text" id="dns-{{ iface.get('name', '') }}" value="{{ dns_list }}" placeholder="1.1.1.1, 8.8.8.8" />
</div>
<div>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleRoutes('{{ iface.get('name', '') }}')" style="font-size:11px;width:100%;justify-content:center;">
▼ Routes
</button>
<div id="routes-panel-{{ iface.get('name', '') }}" style="display:none;margin-top:6px;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);">
<div id="routes-{{ iface.get('name', '') }}">
{% if routes %}
{% for route in routes %}
<div class="route-row" style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">
<input type="text" class="route-dest" value="{{ route.get('destination', '') }}" placeholder="Destination CIDR" style="flex:1;" />
<input type="text" class="route-gw" value="{{ route.get('gateway', '') }}" placeholder="Gateway" style="flex:1;" />
<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();"></button>
</div>
{% endfor %}
{% else %}
<div class="text-muted text-sm">No static routes</div>
{% endif %}
</div>
<button type="button" class="btn btn-sm btn-outline" onclick="addRoute('{{ iface.get('name', '') }}')" style="margin-top:4px;font-size:11px;">+ Add Route</button>
</div>
</div>
</div>
</td>
<td>
<div class="flex" style="flex-direction:column;gap:6px;">
<button class="btn btn-sm btn-primary" onclick="saveInterfaceConfig('{{ iface.get('name', '') }}')">Save</button>
<button class="btn btn-sm btn-outline" onclick="reloadNetworkd('{{ iface.get('name', '') }}')">Reload</button>
</div>
</td>
</tr>
{% endfor %}
{% if not (interfaces or []) %}
<tr>
<td colspan="7" class="text-muted text-sm">No interfaces found</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
-151
View File
@@ -1,151 +0,0 @@
{% extends "base.html" %}
{% block title %}Logs - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>System Logs</h1>
<div class="subtitle">Service logs and journal output</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted">Auto-refresh</span>
<label class="switch">
<input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh()">
<span class="slider"></span>
</label>
<span class="htmx-indicator text-sm" style="color:var(--accent);">Refreshing...</span>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="journal" onclick="switchTab('journal')">Journal</button>
<button class="tab" data-tab="nginx-access" onclick="switchTab('nginx-access')">Nginx Access</button>
<button class="tab" data-tab="nginx-error" onclick="switchTab('nginx-error')">Nginx Error</button>
<button class="tab" data-tab="dnsmasq" onclick="switchTab('dnsmasq')">Dnsmasq</button>
<button class="tab" data-tab="app" onclick="switchTab('app')">App</button>
</div>
<div id="tab-journal" class="tab-content active">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-journal"
hx-get="/api/logs/journal"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading journal entries...
</div>
</div>
</div>
<div id="tab-nginx-access" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-access"
hx-get="/api/logs/nginx/access"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading Nginx access log...
</div>
</div>
</div>
<div id="tab-nginx-error" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-nginx-error"
hx-get="/api/logs/nginx/error"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading Nginx error log...
</div>
</div>
</div>
<div id="tab-dnsmasq" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-dnsmasq"
hx-get="/api/logs/dnsmasq"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading dnsmasq log...
</div>
</div>
</div>
<div id="tab-app" class="tab-content">
<div class="card" style="padding:0;overflow:hidden;">
<div class="log-viewer" id="log-app"
hx-get="/api/logs/app"
hx-trigger="none"
hx-swap="innerHTML"
hx-indicator=".page-header .htmx-indicator">
Loading app log...
</div>
</div>
</div>
<script>
var refreshInterval = {{ (refresh_interval | default(15)) }};
var currentTab = 'journal';
function loadTabEl(el) {
var url = el.getAttribute('hx-get');
if (!url) return;
el.textContent = 'Loading...';
fetch(url).then(function(r) { return r.text(); })
.then(function(html) { el.innerHTML = html; })
.catch(function() { el.innerHTML = '<div class="log-line">(failed to load log)</div>'; });
}
function setActivePolling() {
document.querySelectorAll('.log-viewer').forEach(function(el) {
if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
}
if (typeof htmx !== 'undefined') htmx.process(document.body);
}
function loadActiveTab() {
var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) {
loadTabEl(activeEl);
}
}
function toggleAutoRefresh() {
var toggle = document.getElementById('auto-refresh-toggle');
if (toggle.checked) {
setActivePolling();
loadActiveTab();
} else {
document.querySelectorAll('.log-viewer').forEach(function(el) {
if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none');
});
}
}
document.addEventListener('DOMContentLoaded', function() {
loadActiveTab();
});
window.addEventListener('load', function() {
var origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
switchTab = function(tabName) {
currentTab = tabName;
if (origSwitchTab) {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
});
</script>
{% endblock %}
-131
View File
@@ -1,131 +0,0 @@
{% extends "base.html" %}
{% block title %}NAT - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>NAT &amp; Port Forwarding</h1>
<div class="subtitle">Masquerading and destination NAT rules</div>
</div>
</div>
<div class="section-title">Masquerade (Source NAT)</div>
<div class="card">
<table>
<thead>
<tr>
<th>Zone</th>
<th style="width:120px;">Masquerade</th>
</tr>
</thead>
<tbody>
{% for zone in (zones or []) %}
<tr>
<td><strong>{{ zone.get('name', 'unnamed') }}</strong></td>
<td>
<form hx-post="/api/firewall/masquerade" hx-encoding="json" hx-vals='{"zone": "{{ zone.get('name', '') }}", "enable": JSON.stringify(this.checked)}' hx-swap="none" hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Masquerade '+(this.checked?'enabled':'disabled')+' for {{ zone.get('name', '') }}') } else { this.checked=!this.checked; }">
<label class="switch">
<input type="checkbox"
{% if zone.get('masquerade') %}checked{% endif %}
id="masq-{{ zone.get('name', '') }}"
hx-trigger="change from:#masq-{{ zone.get('name', '') }}"
disabled>
<span class="slider"></span>
</label>
<button type="submit" style="display:none"></button>
</form>
</td>
</tr>
{% endfor %}
{% if not (zones or []) %}
<tr>
<td colspan="2" class="text-muted text-sm">No zones configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="section-title">Port Forwarding (DNAT)</div>
<div class="card mb-4">
<h3>Add Forward Rule</h3>
<form hx-post="/api/firewall/forward-port" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Forward rule added'); }">
<div class="inline-form">
<div class="form-group">
<label for="fw-zone">Zone</label>
<select id="fw-zone" name="zone" required>
<option value="">— Select —</option>
{% for zone in (zones or []) %}
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="fw-protocol">Protocol</label>
<select id="fw-protocol" name="proto">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="form-group">
<label for="fw-port">Port</label>
<input type="number" id="fw-port" name="port" placeholder="80" min="1" max="65535" required style="width:80px;">
</div>
<div class="form-group">
<label for="fw-target">Target Address</label>
<input type="text" id="fw-target" name="toaddr" placeholder="192.168.1.100" required style="width:160px;">
</div>
<div class="form-group">
<label for="fw-target-port">Target Port</label>
<input type="number" id="fw-target-port" name="toport" placeholder="80" min="1" max="65535" style="width:90px;">
</div>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Zone</th>
<th>Proto</th>
<th>Port</th>
<th>Target</th>
<th>Tgt Port</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="forward-rows">
{% 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 %}
<tr>
<td><strong>{{ fwd.zone }}</strong></td>
<td><span class="badge badge-info">{{ fwd.proto }}</span></td>
<td>{{ fwd.port }}</td>
<td>{{ fwd.toaddr }}</td>
<td>{{ fwd.toport }}</td>
<td>
<form hx-delete="/api/firewall/forward-port/{{ fwd.zone }}/{{ fwd.port }}/{{ fwd.proto }}" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('forward-rows'), renderForwardsFromConfig); showSuccessToast('Rule removed'); }">
<button type="submit" class="btn btn-sm btn-danger" hx-confirm="Remove forward rule {{ fwd.port }}/{{ fwd.proto }} → {{ fwd.toaddr }}:{{ fwd.toport }}?">Remove</button>
</form>
</td>
</tr>
{% endfor %}
{% if not all_forwards %}
<tr>
<td colspan="6" class="text-muted text-sm">No port forwarding rules configured</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
-151
View File
@@ -1,151 +0,0 @@
{% extends "base.html" %}
{% block title %}Proxy - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>SSL Proxy Domains</h1>
<div class="subtitle">Reverse proxy and SSL termination managed by Nginx</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick="openModal('add-domain-modal')">+ Add Domain</button>
<button class="btn btn-outline" hx-post="/api/proxy/ssl-apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('SSL settings applied')">Apply SSL Settings</button>
<button class="btn btn-outline" hx-post="/api/proxy/apply" hx-swap="none" hx-on::after-request="if(evt.detail.successful) showSuccessToast('Nginx reloaded')">Apply Changes (Reload Nginx)</button>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Backend Host</th>
<th>Backend Port</th>
<th>Protocol</th>
<th>Certificate</th>
<th style="width:140px;">Actions</th>
</tr>
</thead>
<tbody id="domain-rows">
{% for domain in (domains or []) %}
<tr>
<td><strong>{{ domain.get('domain', 'unknown') }}</strong></td>
<td>{{ domain.get('backend_host', '-') }}</td>
<td>{{ domain.get('backend_port', '-') }}</td>
<td><span class="badge badge-info">{{ domain.get('protocol', 'http') }}</span></td>
<td>
{% 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') %}
<span class="badge badge-danger">Expired</span>
{% elif matched_cert.get('days_remaining') is not none and matched_cert.days_remaining <= 30 %}
<span class="badge badge-warning">{{ matched_cert.days_remaining }}d</span>
{% else %}
<span class="badge badge-success">Valid</span>
{% endif %}
{% else %}
<span class="badge badge-danger">No cert</span>
{% endif %}
</td>
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick='openEditDomainModal('{{ domain.get("domain", "") }}', {{ domain | tojson | safe }})'>Edit</button>
<form hx-delete="/api/proxy/domains/{{ domain.get('domain', '') }}" hx-swap="none" hx-confirm="Remove proxy for {{ domain.domain }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not (domains or []) %}
<tr>
<td colspan="6" class="text-muted text-sm">No proxy domains configured. Add a domain to start terminating SSL.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Add Domain Modal -->
<div class="modal-overlay" id="add-domain-modal" onclick="if(event.target===this) closeModal('add-domain-modal')">
<div class="modal">
<h2>Add Proxy Domain</h2>
<form hx-post="/api/proxy/domains" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('add-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain added'); }">
<div class="form-group">
<label for="new-domain">Domain</label>
<input type="text" id="new-domain" name="domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="new-backend-host">Backend Host</label>
<input type="text" id="new-backend-host" name="backend_host" placeholder="127.0.0.1" required>
</div>
<div class="form-group">
<label for="new-backend-port">Backend Port</label>
<input type="number" id="new-backend-port" name="backend_port" placeholder="8080" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="new-protocol">Backend Protocol</label>
<select id="new-protocol" name="protocol">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('add-domain-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Add Domain</button>
</div>
</form>
</div>
</div>
<!-- Edit Domain Modal -->
<div class="modal-overlay" id="edit-domain-modal" onclick="if(event.target===this) closeModal('edit-domain-modal')">
<div class="modal">
<h2>Edit Proxy Domain</h2>
<form id="edit-domain-form" hx-post="/api/proxy/domains" hx-swap="none" hx-encoding="json" hx-on::after-request="if(evt.detail.successful){ closeModal('edit-domain-modal'); refreshTable('/api/proxy/domains', document.getElementById('domain-rows'), renderDomains); showSuccessToast('Domain updated'); }">
<input type="hidden" id="edit-original-domain" name="original_domain">
<div class="form-group">
<label for="edit-domain">Domain</label>
<input type="text" id="edit-domain" name="domain" required>
</div>
<div class="form-group">
<label for="edit-backend-host">Backend Host</label>
<input type="text" id="edit-backend-host" name="backend_host" required>
</div>
<div class="form-group">
<label for="edit-backend-port">Backend Port</label>
<input type="number" id="edit-backend-port" name="backend_port" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="edit-protocol">Backend Protocol</label>
<select id="edit-protocol" name="protocol">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('edit-domain-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function openEditDomainModal(domainName, d) {
document.getElementById('edit-original-domain').value = d.domain;
document.getElementById('edit-domain').value = d.domain;
document.getElementById('edit-backend-host').value = d.backend_host || '';
document.getElementById('edit-backend-port').value = d.backend_port || '';
document.getElementById('edit-protocol').value = d.protocol || 'http';
openModal('edit-domain-modal');
}
</script>
{% endblock %}
-83
View File
@@ -1,83 +0,0 @@
{% extends "base.html" %}
{% block title %}Rules - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Rich Rules</h1>
<div class="subtitle">Firewalld rich firewall rules per zone</div>
</div>
</div>
<div class="card mb-4">
<h3>Add Rule</h3>
<form hx-post="/api/firewall/rich-rules" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule added'); }">
<div class="inline-form">
<div class="form-group">
<label for="rule-zone">Zone</label>
<select id="rule-zone" name="zone" required>
<option value="">— Select zone —</option>
{% for zone in (zones or []) %}
<option value="{{ zone }}">{{ zone }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="rule-text">Rule Expression</label>
<input type="text" id="rule-text" name="rule" placeholder="e.g., rule family=ipv4 source address=192.168.1.0/24 accept" required style="min-width:420px;">
</div>
<button type="submit" class="btn btn-primary">Add Rule</button>
</div>
</form>
<div class="text-muted text-sm mt-2">
Reference: <a href="https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html" target="_blank" style="color:var(--accent);">firewalld rich language syntax</a>
</div>
</div>
<div id="rules-container">
{% if rules or False %}
{% for zone_name, zone_rules in rules.items() %}
<div class="card">
<h3>Zone: <span style="color:var(--accent);">{{ zone_name or '(default)' }}</span></h3>
{% if zone_rules %}
<table>
<thead>
<tr>
<th>#</th>
<th>Rule</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody>
{% for rule in zone_rules %}
{% set rule_obj = rule if rule is mapping else {'id': None, 'rule': rule} %}
<tr>
<td class="text-muted">{{ loop.index }}</td>
<td hx-disable style="font-family:monospace;font-size:12px;word-break:break-all;">{{ rule_obj.rule }}</td>
<td>
<form hx-delete="/api/firewall/rich-rules/{{ zone_name | urlencode }}/{{ rule_obj.id }}" hx-swap="none" hx-confirm="Remove rule {{ rule_obj.rule[:50] }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/config', document.getElementById('rules-container'), renderRules); showSuccessToast('Rule removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-muted text-sm">No rich rules configured for this zone.</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="text-muted text-sm">No rules loaded. Add rules using the form above, or ensure the zones API is providing rule data.</div>
</div>
{% endif %}
</div>
{% if not (zones or []) %}
<div class="card" style="border-color:var(--warning);">
<div class="text-muted text-sm" style="color:var(--warning);">No zones configured. Create a zone first before adding rich rules.</div>
</div>
{% endif %}
{% endblock %}
-123
View File
@@ -1,123 +0,0 @@
{% extends "base.html" %}
{% block title %}WireGuard - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>WireGuard</h1>
<div class="subtitle">VPN tunnel management</div>
</div>
<div class="flex gap-2">
<button class="btn {{ 'btn-outline' if (wg_status is defined and wg_status.get('state') == 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/down"
hx-swap="none"
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel stopped'); }">
Stop Tunnel
</button>
<button class="btn {{ 'btn-outline' if (wg_status is not defined or wg_status.get('state') != 'up') else 'btn-primary' }}"
hx-post="/api/wireguard/apply"
hx-swap="none"
hx-on::after-request="if(evt.detail.successful){ showSuccessToast('Tunnel started'); }">
Start Tunnel
</button>
</div>
</div>
<!-- Tunnel Status -->
<div class="card mb-4">
<div class="flex items-center justify-between">
<div>
<h3>
<span class="status-dot {{ 'status-up' if (wg_status is defined and wg_status.get('state') == 'up') else 'status-down' }}"></span>
Tunnel State: <strong>{{ 'UP' if (wg_status is defined and wg_status.get('state') == 'up') else 'DOWN' }}</strong>
</h3>
</div>
<div class="text-sm text-muted">
{% if config %}
Listen Port: <strong>{{ config.get('listen_port', 'N/A') }}</strong> &nbsp;|&nbsp;
Public Key: <strong>{{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...</strong> &nbsp;|&nbsp;
Address: <strong>{{ config.get('address', 'N/A') }}</strong>
{% endif %}
</div>
</div>
</div>
<!-- Add Peer Form -->
<div class="section-title">Peers</div>
<div class="card mb-4">
<h3>Add Peer</h3>
<form hx-post="/api/wireguard/peers" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer added'); }">
<div class="inline-form">
<div class="form-group">
<label for="peer-name">Name</label>
<input type="text" id="peer-name" name="name" placeholder="client-1" required style="width:140px;">
</div>
<div class="form-group">
<label for="peer-pubkey">Public Key</label>
<input type="text" id="peer-pubkey" name="public_key" placeholder="Base64 public key (48 chars)" required style="width:260px;">
</div>
<div class="form-group">
<label for="peer-allowed">Allowed IPs</label>
<input type="text" id="peer-allowed" name="allowed_ips" placeholder="10.8.0.2/32" value="10.8.0.{% set next = (peers or []|length + 2) %}{{ next }}/32" required style="width:160px;">
</div>
<button type="submit" class="btn btn-primary">Add Peer</button>
</div>
</form>
</div>
<!-- Peers Table -->
<div class="card">
<table>
<thead>
<tr>
<th>Name</th>
<th>Public Key</th>
<th>Allowed IPs</th>
<th>Endpoint</th>
<th>Latest Handshake</th>
<th>Transfer</th>
<th style="width:160px;">Actions</th>
</tr>
</thead>
<tbody id="peer-rows">
{% for peer in (peers or []) %}
<tr>
<td>
<span class="status-dot {{ 'status-up' if peer.get('latest_handshake') else 'status-down' }}"></span>
<strong>{{ peer.get('name', 'unnamed') }}</strong>
</td>
<td style="font-family:monospace;font-size:11px;">{{ peer.get('public_key', 'N/A')[:20] }}...</td>
<td class="text-sm">{{ peer.get('allowed_ips', '-') }}</td>
<td class="text-sm">{{ peer.get('endpoint', '-') }}</td>
<td class="text-sm">{{ peer.get('latest_handshake', 'Never') or 'Never' }}</td>
<td class="text-sm">
<div>Recv: {{ peer.get('transfer_recv', '0') or '0' }}</div>
<div>Sent: {{ peer.get('transfer_sent', '0') or '0' }}</div>
</td>
<td>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick="downloadPeerConfig('{{ peer.get('name', '') }}')">Config</button>
<form hx-delete="/api/wireguard/peers/{{ peer.get('name', '') }}" hx-swap="none" hx-confirm="Remove peer {{ peer.get('name', '') }}?" hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/wireguard/peers', document.getElementById('peer-rows'), renderPeers); showSuccessToast('Peer removed'); }">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not (peers or []) %}
<tr>
<td colspan="7" class="text-muted text-sm">No peers configured. Add a peer above.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<script>
function downloadPeerConfig(peerName) {
var url = '/api/wireguard/peers/' + encodeURIComponent(peerName) + '/config';
window.open(url, '_blank');
}
</script>
{% endblock %}
-90
View File
@@ -1,90 +0,0 @@
{% extends "base.html" %}
{% block title %}Zones - Vacuum Wall{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Zones</h1>
<div class="subtitle">Firewalld zone management</div>
</div>
<button class="btn btn-primary" onclick="openModal('create-zone-modal')">+ Create Zone</button>
</div>
<div id="zone-grid" class="card-grid">
{% for zone in (zones or []) %}
<div class="card" style="position:relative;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div>
<h3 style="font-size:16px;color:var(--accent);">{{ zone.get('name', 'unnamed') }}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px;">
{% if zone.get('target') %}Target: {{ zone.target }}{% endif %}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px;">Interfaces</div>
{% if zone.get('interfaces') %}
{% for iface in zone.interfaces %}
<span class="badge badge-info">{{ iface }}</span>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px;">Services</div>
{% if zone.get('services') %}
{% for svc in zone.services %}
<span class="badge badge-success">{{ svc }}</span>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div style="display:flex;justify-content:flex-end;gap:6px;margin-top:12px;">
<form hx-delete="/api/firewall/zones/{{ zone.get('name', '') }}" hx-swap="none" hx-confirm="Delete zone {{ zone.name }}? This will affect traffic to its interfaces." hx-on::after-request="if(evt.detail.successful){ refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone deleted'); }">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
{% if not (zones or []) %}
<div class="card">
<div class="text-muted text-sm">No zones configured. Create a zone to get started.</div>
</div>
{% endif %}
</div>
<!-- Create Zone Modal -->
<div class="modal-overlay" id="create-zone-modal" onclick="if(event.target===this) closeModal('create-zone-modal')">
<div class="modal">
<h2>Create Zone</h2>
<form hx-post="/api/firewall/zones" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('create-zone-modal'); refreshTable('/api/firewall/zones', document.getElementById('zone-grid'), renderZones); showSuccessToast('Zone created'); }">
<div class="form-group">
<label for="zone-name">Zone Name</label>
<input type="text" id="zone-name" name="name" placeholder="e.g., trusted, dmz, external" required>
</div>
<div class="form-group">
<label for="zone-target">Target</label>
<select id="zone-target" name="target">
<option value="default">default</option>
<option value="%%REJECT%%">%REJECT%</option>
<option value="%%DROP%%">%DROP%</option>
<option value="%%ACCEPT%%">%ACCEPT%</option>
</select>
</div>
<div class="form-group">
<label for="zone-services">Default Services (comma-separated)</label>
<input type="text" id="zone-services" name="services" placeholder="e.g., dhcp, dns, ssh">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('create-zone-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
{% endblock %}