Files
vacuum-wall/webui/server.py
T
mteehan dadabd7954 feat: add system metrics dashboard with resource monitoring
- Add system metrics endpoint (CPU load, memory, swap, network traffic)
- Collect metrics from /proc and /sys (no subprocess required)
- Overhaul dashboard to pull from per-subsystem models
- Remove deprecated /status/all monolithic endpoint
- Improve networkd import to handle optional priority prefix
- Fix CSS duplicate .grid-4 rule and unused dashboard imports
2026-07-15 00:24:43 +00:00

198 lines
6.2 KiB
Python

"""
server.py - Vacuum Wall management WebUI entry point.
Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
and enforces basic authentication before proxying to this port.
"""
import contextlib
import importlib
import logging
import os
import signal
import sys
import time
from pathlib import Path
from flask import Flask, abort, request
from werkzeug.middleware.proxy_fix import ProxyFix
from lib.logging import setup_logging
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
from webui.api.logs import bp as logs_bp
from webui.api.network import bp as network_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.status import bp as status_bp
from webui.api.wireguard import bp as wireguard_bp
# ---------------------------------------------------------------------------
# Logging — must be first so subsequent modules inherit the config
# ---------------------------------------------------------------------------
PROJECT_DIR = Path(__file__).resolve().parent.parent
setup_logging()
logger = logging.getLogger(__name__)
logger.info(
"Python %s.%s.%s",
sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro,
)
logger.info("Project directory: %s", PROJECT_DIR)
logger.info("Process ID: %d", os.getpid())
_reloading = False
def _sighup_handler(signum, frame):
"""Handle SIGHUP by reloading modules then restarting via SIGTERM.
Reloads all ``webui.*`` and ``lib.*`` modules, re-registers blueprints,
and requests systemd restart by sending SIGTERM with default handler.
"""
global _reloading
if _reloading:
return
_reloading = True
logger.info("Received SIGHUP, reloading modules...")
for mod_name, mod in sys.modules.items():
if mod_name.startswith("webui.") or mod_name.startswith("lib."):
with contextlib.suppress(Exception):
importlib.reload(mod)
logger.info("Modules reloaded, sending SIGTERM to restart under systemd...")
signal.signal(signal.SIGTERM, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGTERM)
signal.signal(signal.SIGHUP, _sighup_handler)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
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")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
app.register_blueprint(logs_bp, url_prefix="/api/logs")
app.register_blueprint(status_bp, url_prefix="/api/status")
BLUEPRINTS = [
("firewall", firewall_bp),
("network", network_bp),
("dhcp", dhcp_bp),
("proxy", proxy_bp),
("certs", certs_bp),
("wireguard", wireguard_bp),
("logs", logs_bp),
("status", status_bp),
]
for name, _ in BLUEPRINTS:
logger.info("Registered blueprint '%s' at /api/%s", name, name)
# ---------------------------------------------------------------------------
# Request logging
# ---------------------------------------------------------------------------
@app.before_request
def _log_request_start():
"""Record request start time for duration tracking."""
request._start_time = time.monotonic()
@app.after_request
def _log_request_finish(response):
"""Log request duration and status code after response generation.
Args:
response: The HTTP response object.
Returns:
The unchanged response object.
"""
elapsed_ms = (
time.monotonic() - getattr(request, "_start_time", time.monotonic())
) * 1000
logger.info(
"%s %s -> %d (%.1f ms)",
request.method,
request.path,
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
# ---------------------------------------------------------------------------
# SPA entry point — serve index.html for /, 404 for everything else
# ---------------------------------------------------------------------------
SPA_DIR = STATIC_DIR
VENDOR_DIR = PROJECT_DIR / "vendor"
@app.route("/")
def spa_root():
"""Serve the SPA entry point. No catch-all — client handles routing."""
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_PLACEHOLDER__", ws_url)
@app.route("/vendor/<path:filename>")
def vendor_files(filename):
"""Serve vendored JS libraries (htm.js, etc.)."""
from flask import send_file
target = (VENDOR_DIR / filename).resolve()
if not target.is_relative_to(VENDOR_DIR):
abort(404)
return send_file(target)
@app.errorhandler(404)
def not_found(e):
"""Return 404 JSON for API clients, 404 HTML for everything else."""
if request.path.startswith("/api/"):
return {"ok": False, "error": "Not found"}, 404
return "", 404
if __name__ == "__main__":
logger.info("Starting Flask on 127.0.0.1:9090")
app.run(host="127.0.0.1", port=9090)