refactor: replace Jinja templates with static frontend pages
This commit is contained in:
+50
-365
@@ -12,15 +12,14 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, request
|
||||
from flask import Flask, abort, request
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from daemon.client import get
|
||||
from daemon.iface import GET_STATUS_ALL
|
||||
from lib.logging import setup_logging
|
||||
from lib.network import get_config
|
||||
from webui.api.certs import bp as certs_bp
|
||||
from webui.api.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp as firewall_bp
|
||||
@@ -79,6 +78,15 @@ signal.signal(signal.SIGHUP, _sighup_handler)
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.urandom(32).hex()
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
# Cache-control: short TTL in dev, aggressive caching in prod (versioned assets)
|
||||
_DEV_MODE = bool(os.environ.get("VACUUM_WALL_DEV")) or False
|
||||
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000
|
||||
|
||||
# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
||||
app.register_blueprint(network_bp, url_prefix="/api/network")
|
||||
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
||||
@@ -100,7 +108,6 @@ BLUEPRINTS = [
|
||||
for name, _ in BLUEPRINTS:
|
||||
logger.info("Registered blueprint '%s' at /api/%s", name, name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request logging
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -132,387 +139,65 @@ def _log_request_finish(response):
|
||||
response.status_code,
|
||||
elapsed_ms,
|
||||
)
|
||||
|
||||
# Set cache headers: short in dev, long with staleness tolerance in prod
|
||||
if response.content_type.startswith("text/html"):
|
||||
# index.html: always short cache so browser revalidates
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
elif response.content_type.startswith(("text/javascript", "text/css")):
|
||||
if _DEV_MODE:
|
||||
response.headers["Cache-Control"] = "max-age=5"
|
||||
else:
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, stale-while-revalidate=86400"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 custom filters
|
||||
# API proxy routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.template_filter("timestamp")
|
||||
def timestamp_filter(value):
|
||||
"""Convert an ISO-8601 timestamp string to ``YYYY-MM-DD HH:MM:SS``.
|
||||
@app.route("/api/status/all")
|
||||
def api_status_all():
|
||||
"""Return aggregated status from all subsystems.
|
||||
|
||||
Args:
|
||||
value: ISO timestamp string (may end with ``Z``).
|
||||
Proxies the daemon's ``/status/all`` endpoint for SPA consumption.
|
||||
|
||||
Returns:
|
||||
Formatted date string, or original value on parse failure.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
@app.template_filter("bytes")
|
||||
def bytes_filter(value):
|
||||
"""Convert a byte count to a human-readable size string (B/KB/MB…).
|
||||
|
||||
Args:
|
||||
value: Numeric byte count.
|
||||
|
||||
Returns:
|
||||
Formatted size string, or original value on parse failure.
|
||||
JSON response with state data for all subsystems.
|
||||
"""
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if num < 0:
|
||||
return "0 B"
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if abs(num) < 1024:
|
||||
return f"{num:.1f} {unit}"
|
||||
num /= 1024
|
||||
return f"{num:.1f} PB"
|
||||
|
||||
|
||||
@app.template_filter("duration")
|
||||
def duration_filter(value):
|
||||
"""Convert a duration in seconds to a human-readable string.
|
||||
|
||||
Args:
|
||||
value: Duration in seconds.
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g. ``3d 2h 15m 30s``), or original value on failure.
|
||||
"""
|
||||
try:
|
||||
total = int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
if total < 0:
|
||||
return "0s"
|
||||
parts = []
|
||||
days, remainder = divmod(total, 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if days:
|
||||
parts.append(f"{days}d")
|
||||
if hours:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{seconds}s")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@app.template_filter("json_pretty")
|
||||
def json_pretty_filter(value):
|
||||
"""Serialize *value* as indented JSON for template display.
|
||||
|
||||
Args:
|
||||
value: Any JSON-serializable object.
|
||||
|
||||
Returns:
|
||||
Pretty-printed JSON string with 2-space indent.
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(value, indent=2, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safely(fn, default=None):
|
||||
"""Call *fn* and return *default* on any exception.
|
||||
|
||||
Args:
|
||||
fn: Zero-argument callable to execute.
|
||||
default: Fallback value returned when *fn* raises.
|
||||
|
||||
Returns:
|
||||
The result of ``fn()``, or *default* if an exception occurred.
|
||||
"""
|
||||
try:
|
||||
return fn()
|
||||
return {"ok": True, "data": get(GET_STATUS_ALL)}
|
||||
except Exception as exc:
|
||||
logger.warning("WebUI data load failed: %s", exc)
|
||||
return default
|
||||
logger.warning("Status all failed: %s", exc)
|
||||
return {"ok": False, "error": str(exc)}, 500
|
||||
|
||||
|
||||
def _get_service_status(dnsmasq_info, wg_info):
|
||||
"""Build a service status dict for the dashboard template.
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA catch-all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Args:
|
||||
dnsmasq_info: Dnsmasq status payload from the daemon.
|
||||
wg_info: WireGuard status payload from the daemon.
|
||||
|
||||
Returns:
|
||||
Dict mapping service names to ``{running: bool}``.
|
||||
"""
|
||||
services = {}
|
||||
if dnsmasq_info:
|
||||
services["Dnsmasq"] = {
|
||||
"running": dnsmasq_info.get("service_active", False),
|
||||
}
|
||||
if wg_info:
|
||||
services["WireGuard"] = {
|
||||
"running": wg_info.get("up", False),
|
||||
}
|
||||
return services
|
||||
|
||||
|
||||
def _fw_config_get() -> dict[str, Any]:
|
||||
"""Read the current firewall config from the daemon."""
|
||||
return get("/firewall/config")
|
||||
|
||||
|
||||
def _load_status_all() -> dict[str, Any]:
|
||||
"""Load all subsystem status from the daemon in a single call."""
|
||||
return get("/status/all")
|
||||
SPA_DIR = STATIC_DIR
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def root_redirect():
|
||||
"""Redirect root URL to the dashboard.
|
||||
@app.route("/<path:path>")
|
||||
def spa_page(path=""):
|
||||
"""Single-page application catch-all.
|
||||
|
||||
GET /
|
||||
|
||||
Returns:
|
||||
Redirect response to the dashboard page.
|
||||
Serves ``index.html`` (rendered as a Jinja2 template) for all non-API,
|
||||
non-static paths. The client-side router handles navigation and defaults
|
||||
to ``#dashboard``.
|
||||
"""
|
||||
from flask import redirect, url_for
|
||||
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard():
|
||||
"""Render the main dashboard overview page.
|
||||
|
||||
GET /
|
||||
|
||||
Template context:
|
||||
active_zones (dict): Active firewalld zones and bound interfaces.
|
||||
interfaces (list): Available network interfaces with zone bindings.
|
||||
dnsmasq (dict): Dnsmasq status information.
|
||||
domains (list): Configured proxy domains.
|
||||
certs (list): ACME certificate inventory.
|
||||
wg_status (dict): WireGuard tunnel status.
|
||||
services (dict): Service running indicators (Dnsmasq, WireGuard).
|
||||
firewall_config (dict): Declarative firewall JSON config.
|
||||
firewall_pending (dict): Pending firewall rules awaiting apply.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||
ng_state = all_status.get("nginx", {}) or {}
|
||||
ac_state = all_status.get("acme", {}) or {}
|
||||
wg_state = all_status.get("wireguard", {}) or {}
|
||||
|
||||
active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
|
||||
interfaces = fw_state.get("interfaces", [])
|
||||
dnsmasq = dm_state.get("status", {})
|
||||
domains = ng_state.get("domains", [])
|
||||
certs = ac_state.get("certs", [])
|
||||
wg = wg_state.get("status", {})
|
||||
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
active_zones=active_zones,
|
||||
interfaces=interfaces,
|
||||
dnsmasq=dnsmasq,
|
||||
domains=domains,
|
||||
certs=certs,
|
||||
wg_status=wg,
|
||||
services=_get_service_status(dnsmasq, wg),
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/interfaces")
|
||||
def interfaces_page():
|
||||
"""Render the network interfaces management page.
|
||||
|
||||
GET /interfaces
|
||||
|
||||
Template context:
|
||||
interfaces (list): Available network interfaces.
|
||||
zones (list): Zone names bound to interfaces.
|
||||
firewall_config (dict): Declarative firewall JSON config.
|
||||
firewall_pending (dict): Pending firewall rules awaiting apply.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
network_config = _safely(get_config, {})
|
||||
return render_template(
|
||||
"interfaces.html",
|
||||
interfaces=fw_state.get("interfaces", []),
|
||||
network_config=network_config,
|
||||
zones=fw_state.get("active_zones", {}).keys() or [],
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/zones")
|
||||
def zones_page():
|
||||
"""Render the firewall zones management page.
|
||||
|
||||
GET /zones
|
||||
|
||||
Template context:
|
||||
zones (list): All zone configurations.
|
||||
services (list): Available service identifiers for zone policies.
|
||||
firewall_config (dict): Declarative firewall JSON config.
|
||||
firewall_pending (dict): Pending firewall rules awaiting apply.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template(
|
||||
"zones.html",
|
||||
zones=list(fw_state.get("zones", {}).values()),
|
||||
services=fw_state.get("available_services", []),
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/rules")
|
||||
def rules_page():
|
||||
"""Render the firewall rich-rules editor page.
|
||||
|
||||
GET /rules
|
||||
|
||||
Template context:
|
||||
zones (list): Zone names containing rich rules.
|
||||
rules (dict | None): Zone name → rich rule mappings (``None`` if empty).
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
zones = list(fw_state.get("zones", {}).keys())
|
||||
rules: dict[str, list[str]] = {}
|
||||
for zname, zcfg in fw_state.get("zones", {}).items():
|
||||
rr = zcfg.get("rich-rules", [])
|
||||
if rr:
|
||||
rules[zname] = rr
|
||||
return render_template("rules.html", zones=zones, rules=rules or None)
|
||||
|
||||
|
||||
@app.route("/nat")
|
||||
def nat_page():
|
||||
"""Render the NAT rules management page.
|
||||
|
||||
GET /nat
|
||||
|
||||
Template context:
|
||||
zones (list): Zone configurations containing NAT rules.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
|
||||
|
||||
|
||||
@app.route("/dhcp")
|
||||
def dhcp_page():
|
||||
"""Render the DHCP/Dnsmasq configuration page.
|
||||
|
||||
GET /dhcp
|
||||
|
||||
Template context:
|
||||
config (dict): Dnsmasq configuration settings.
|
||||
status (dict): Dnsmasq runtime status.
|
||||
leases (list): Current DHCP lease table.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template(
|
||||
"dhcp.html",
|
||||
config=dm_state.get("config", {}),
|
||||
status=dm_state.get("status", {}),
|
||||
leases=dm_state.get("leases", []),
|
||||
interfaces=fw_state.get("interfaces", []),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/proxy")
|
||||
def proxy_page():
|
||||
"""Render the reverse proxy / SSL termination management page.
|
||||
|
||||
GET /proxy
|
||||
|
||||
Template context:
|
||||
domains (list): Configured proxy domains with upstream targets.
|
||||
config (dict): Nginx configuration settings.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
ng_state = all_status.get("nginx", {}) or {}
|
||||
return render_template(
|
||||
"proxy.html",
|
||||
domains=ng_state.get("domains", []),
|
||||
config=ng_state.get("config", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/certs")
|
||||
def certs_page():
|
||||
"""Render the SSL certificate management page.
|
||||
|
||||
GET /certs
|
||||
|
||||
Template context:
|
||||
certs (list): ACME certificate inventory.
|
||||
email (str): Configured ACME registration email.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
ac_state = all_status.get("acme", {}) or {}
|
||||
return render_template(
|
||||
"certs.html",
|
||||
certs=ac_state.get("certs", []),
|
||||
email=ac_state.get("email", ""),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/wireguard")
|
||||
def wireguard_page():
|
||||
"""Render the WireGuard VPN management page.
|
||||
|
||||
GET /wireguard
|
||||
|
||||
Template context:
|
||||
config (dict): WireGuard tunnel configuration.
|
||||
status (dict): WireGuard runtime status.
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
wg_state = all_status.get("wireguard", {}) or {}
|
||||
return render_template(
|
||||
"wireguard.html",
|
||||
config=wg_state.get("config", {}),
|
||||
status=wg_state.get("status", {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/logs")
|
||||
def logs_page():
|
||||
"""Render the system logs viewer page.
|
||||
|
||||
GET /logs
|
||||
"""
|
||||
return render_template("logs.html")
|
||||
if path.startswith("api/") or path.startswith("static/"):
|
||||
abort(404)
|
||||
scheme = "wss" if request.is_secure else "ws"
|
||||
ws_url = f"{scheme}://{request.host}/ws"
|
||||
html = (SPA_DIR / "index.html").read_text()
|
||||
return html.replace("__WS_URL__", ws_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+92
-565
@@ -1,578 +1,105 @@
|
||||
// Toast notifications
|
||||
const showToast = (message, type, duration = 4000) => {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=4';
|
||||
|
||||
import DashboardPage from '/static/pages/dashboard.js?v=4';
|
||||
import InterfacesPage from '/static/pages/interfaces.js?v=4';
|
||||
import ZonesPage from '/static/pages/zones.js?v=4';
|
||||
import RulesPage from '/static/pages/rules.js?v=4';
|
||||
import NatPage from '/static/pages/nat.js?v=4';
|
||||
import DhcpPage from '/static/pages/dhcp.js?v=4';
|
||||
import ProxyPage from '/static/pages/proxy.js?v=4';
|
||||
import CertsPage from '/static/pages/certs.js?v=4';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=4';
|
||||
import LogsPage from '/static/pages/logs.js?v=4';
|
||||
import NotFoundPage from '/static/pages/notfound.js?v=4';
|
||||
|
||||
/* ── Navigation items ──────────────────────────────────────── */
|
||||
const Nav = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/interfaces', label: 'Interfaces' },
|
||||
{ path: '/zones', label: 'Zones' },
|
||||
{ path: '/rules', label: 'Rules' },
|
||||
{ path: '/nat', label: 'NAT' },
|
||||
{ path: '/dhcp', label: 'DHCP' },
|
||||
{ path: '/proxy', label: 'Proxy' },
|
||||
{ path: '/certs', label: 'Certs' },
|
||||
{ path: '/wireguard', label: 'WireGuard' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
];
|
||||
|
||||
/* ── Page map ──────────────────────────────────────────────── */
|
||||
const Pages = {
|
||||
dashboard: DashboardPage,
|
||||
interfaces: InterfacesPage,
|
||||
zones: ZonesPage,
|
||||
rules: RulesPage,
|
||||
nat: NatPage,
|
||||
dhcp: DhcpPage,
|
||||
proxy: ProxyPage,
|
||||
certs: CertsPage,
|
||||
wireguard: WireguardPage,
|
||||
logs: LogsPage,
|
||||
};
|
||||
|
||||
const showSuccessToast = (msg) => showToast(msg, 'success');
|
||||
|
||||
const showErrorToast = (msg) => showToast(msg, 'error');
|
||||
|
||||
const showWarningToast = (msg) => showToast(msg, 'warning');
|
||||
|
||||
// Modal helpers
|
||||
const openModal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.add('active');
|
||||
/* ── Router ────────────────────────────────────────────────── */
|
||||
const router = {
|
||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||
component() {
|
||||
const name = this.state.path.replace(/^\//, '');
|
||||
const page = Pages[name] || NotFoundPage;
|
||||
return hComp(page, this.state.path);
|
||||
},
|
||||
};
|
||||
|
||||
const closeModal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
};
|
||||
|
||||
// Tab switching
|
||||
let switchTab = (tabName) => {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('tab-' + tabName).classList.add('active');
|
||||
const clickedTab = document.querySelector('.tab[data-tab="' + tabName + '"]');
|
||||
if (clickedTab) clickedTab.classList.add('active');
|
||||
};
|
||||
|
||||
// Refresh a container from a JSON GET endpoint using a renderer callback
|
||||
const refreshTable = (url, container, renderer) => {
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const json = data.ok ? data.data : data;
|
||||
container.innerHTML = renderer(json);
|
||||
htmx.process(container);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// HTMX event handlers
|
||||
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||
const toastHeader = evt.detail.xhr?.getResponseHeader('X-Toast');
|
||||
if (toastHeader) {
|
||||
const parts = toastHeader.split(':');
|
||||
const msg = parts.slice(1).join(':').trim();
|
||||
showToast(msg, parts[0]?.trim() || 'info');
|
||||
}
|
||||
window.location.hash || (window.location.hash = router.state.path);
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', (evt) => {
|
||||
const status = evt.detail.xhr?.status || 0;
|
||||
const json = evt.detail.xhr?.response;
|
||||
let msg = 'Request failed (' + status + ')';
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (parsed.error) msg = parsed.error;
|
||||
} catch (e) {}
|
||||
showToast(msg, 'error');
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', (evt) => {
|
||||
const btn = evt.target.closest('.btn');
|
||||
if (btn) {
|
||||
btn.dataset.originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Loading...';
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', (evt) => {
|
||||
const btn = evt.target.closest('.btn');
|
||||
if (btn && btn.dataset.originalText !== undefined) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.originalText;
|
||||
delete btn.dataset.originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard: Escape closes all modals
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal-overlay.active').forEach(el => el.classList.remove('active'));
|
||||
}
|
||||
});
|
||||
|
||||
// -------- Renderer helpers for htmx-driven DOM updates --------
|
||||
|
||||
const renderZones = (data) => {
|
||||
const active = Array.isArray(data) ? data : (data.active || []);
|
||||
if (!active.length) return '<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,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
};
|
||||
|
||||
// ─── Certificate Issue Wizard ────────────────────────────────────────
|
||||
|
||||
let _issuePollHandle = null;
|
||||
let _issueRequestId = null;
|
||||
|
||||
function closeIssueWizard() {
|
||||
if (_issuePollHandle) {
|
||||
clearInterval(_issuePollHandle);
|
||||
_issuePollHandle = null;
|
||||
}
|
||||
_issueRequestId = null;
|
||||
resetIssueWizard();
|
||||
closeModal('issue-cert-modal');
|
||||
/* ── Sidebar component ─────────────────────────────────────── */
|
||||
function Sidebar() {
|
||||
const current = router.state.path;
|
||||
return h('div', { class: 'sidebar' },
|
||||
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
||||
h('nav', null,
|
||||
Nav.map(item =>
|
||||
Link({
|
||||
path: item.path,
|
||||
class: current === item.path ? 'active' : '',
|
||||
children: [item.label],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resetIssueWizard() {
|
||||
document.getElementById('cert-wizard-input').style.display = '';
|
||||
document.getElementById('cert-wizard-progress').style.display = 'none';
|
||||
document.getElementById('cert-check-results').style.display = 'none';
|
||||
document.getElementById('cert-check-btn').style.display = '';
|
||||
document.getElementById('cert-issue-btn').style.display = 'none';
|
||||
document.getElementById('cert-close-progress').style.display = 'none';
|
||||
/* ── Route component wrapper ───────────────────────────────── */
|
||||
function RouteComponent() {
|
||||
return router.component();
|
||||
}
|
||||
|
||||
function validateCertIssue() {
|
||||
const domain = document.getElementById('cert-domain').value.trim();
|
||||
if (!domain) {
|
||||
showErrorToast('Domain is required');
|
||||
return;
|
||||
/* ── App layout ────────────────────────────────────────────── */
|
||||
function AppLayout() {
|
||||
return [
|
||||
h('div', { class: 'layout' },
|
||||
Sidebar(),
|
||||
h('div', { class: 'main' },
|
||||
RouteComponent(),
|
||||
),
|
||||
),
|
||||
ToastContainer(),
|
||||
];
|
||||
}
|
||||
|
||||
/* ── Init ──────────────────────────────────────────────────── */
|
||||
export function initApp() {
|
||||
const appEl = document.getElementById('app');
|
||||
if (appEl) {
|
||||
render(appEl, AppLayout);
|
||||
}
|
||||
const email = document.getElementById('cert-email').value.trim() || undefined;
|
||||
|
||||
const checkBtn = document.getElementById('cert-check-btn');
|
||||
checkBtn.disabled = true;
|
||||
checkBtn.textContent = 'Checking...';
|
||||
|
||||
fetch('/api/certs/validate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ domain, email })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = 'Check';
|
||||
|
||||
const result = data.ok ? data.data : data;
|
||||
renderChecks(result.checks);
|
||||
|
||||
if (result.ready) {
|
||||
document.getElementById('cert-check-btn').style.display = 'none';
|
||||
document.getElementById('cert-issue-btn').style.display = '';
|
||||
} else {
|
||||
document.getElementById('cert-issue-btn').style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
checkBtn.disabled = false;
|
||||
checkBtn.textContent = 'Check';
|
||||
showErrorToast('Validation failed: ' + e.message);
|
||||
});
|
||||
// Defer connect() after the first render microtask settles to prevent
|
||||
// the initial requestUpdate() from triggering a second commit while
|
||||
// the vnode tree is still being finalized.
|
||||
setTimeout(connect, 0);
|
||||
}
|
||||
|
||||
function renderChecks(checks) {
|
||||
const container = document.getElementById('cert-checks-list');
|
||||
const resultsDiv = document.getElementById('cert-check-results');
|
||||
resultsDiv.style.display = '';
|
||||
|
||||
container.innerHTML = checks.map(c => {
|
||||
let icon, badge;
|
||||
if (c.passed) {
|
||||
icon = '✓';
|
||||
badge = c.blocking ? 'badge-success' : 'badge-info';
|
||||
} else {
|
||||
icon = '✗';
|
||||
badge = 'badge-danger';
|
||||
}
|
||||
return '<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>';
|
||||
};
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 }, []);
|
||||
}
|
||||
@@ -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 || []);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Hoover — helpers.js
|
||||
*
|
||||
* Shared utilities: text escaping, attribute escaping, DOM value helpers,
|
||||
* zone parsing, form utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape text for safe HTML output.
|
||||
* Appends the string to a temporary div and reads innerHTML,
|
||||
* which safely escapes all HTML special characters.
|
||||
*/
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.append(String(s ?? ''));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe use in HTML attributes.
|
||||
*/
|
||||
export function att_esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encode a string.
|
||||
*/
|
||||
export const enc = encodeURIComponent;
|
||||
|
||||
/**
|
||||
* Get the value of a DOM element by ID.
|
||||
*/
|
||||
export function $val(id) {
|
||||
return document.getElementById(id)?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse zone data from various API response shapes into a flat string array.
|
||||
*/
|
||||
export function parseZones(data) {
|
||||
let z = data?.active || data?.zones || [];
|
||||
if (typeof z === 'object' && !Array.isArray(z))
|
||||
z = Object.values(z).map(i => i?.name || i);
|
||||
return Array.isArray(z) ? z : [];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 || []);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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' }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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')
|
||||
)
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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.' })]),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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.' }),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -301,10 +301,40 @@ body {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
animation: toastSlideIn 0.3s ease forwards;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-message .toast-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.toast-message .toast-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 1.4em;
|
||||
}
|
||||
|
||||
.toast-message .toast-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.toast-message .toast-btn:hover { opacity: 1; }
|
||||
|
||||
.toast-message.toast-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
@@ -464,6 +494,204 @@ body {
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-header .subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Stat cards */
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-card .meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-up { background: var(--success); }
|
||||
.status-down { background: var(--danger); }
|
||||
.status-pending { background: var(--warning); }
|
||||
|
||||
/* Badge info */
|
||||
.badge-info {
|
||||
background: rgba(0, 180, 216, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Modal actions */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Section title */
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title:first-child { margin-top: 0; }
|
||||
|
||||
/* Card grid */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card .card-grid {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Service list */
|
||||
.service-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.service-list li {
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.service-list .svc-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Logs area */
|
||||
.logs-area {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 1rem;
|
||||
background: #0d0d1a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.log-line.error { color: var(--danger); }
|
||||
.log-line.warn { color: var(--warning); }
|
||||
.log-line.info { color: var(--text); }
|
||||
|
||||
/* Auto-refresh indicator */
|
||||
.refresh-active::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
margin-right: 6px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--danger);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -1,214 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}DHCP & DNS - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>DHCP & 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 & Restart Dnsmasq</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,131 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}NAT - Vacuum Wall{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>NAT & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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> |
|
||||
Public Key: <strong>{{ config.get('public_key', 'N/A')[:12] if config.get('public_key') else 'N/A' }}...</strong> |
|
||||
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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user