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
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
"""System metrics handler.
|
||||||
|
|
||||||
|
Returns CPU load, memory usage, and per-interface network traffic stats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from daemon.iface import GET_SYSTEM_METRICS
|
||||||
|
from daemon.server import registry
|
||||||
|
from lib.state import state as state_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register(GET_SYSTEM_METRICS)
|
||||||
|
def system_metrics(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
|
"""Return system-wide metrics.
|
||||||
|
|
||||||
|
Reads from pre-collected state (CPU load, memory, network traffic).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with load, memory, swap, and traffic data.
|
||||||
|
"""
|
||||||
|
sys_state = state_store.get("system")
|
||||||
|
if sys_state is None:
|
||||||
|
return {
|
||||||
|
"load": {"load1": 0.0, "load5": 0.0, "load15": 0.0},
|
||||||
|
"memory": {"total": 0, "available": 0, "used": 0, "used_pct": 0},
|
||||||
|
"swap": {"total": 0, "used": 0, "used_pct": 0},
|
||||||
|
"traffic": {},
|
||||||
|
}
|
||||||
|
return sys_state
|
||||||
+1
-1
@@ -155,12 +155,12 @@ GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app")
|
|||||||
|
|
||||||
# ---- Server infra (not going through client) ----
|
# ---- Server infra (not going through client) ----
|
||||||
GET_HEALTH: Endpoint = _ep("GET", "/health")
|
GET_HEALTH: Endpoint = _ep("GET", "/health")
|
||||||
GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
|
|
||||||
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
|
||||||
GET_WS: Endpoint = _ep("GET", "/ws")
|
GET_WS: Endpoint = _ep("GET", "/ws")
|
||||||
POST_BATCH: Endpoint = _ep("POST", "/batch")
|
POST_BATCH: Endpoint = _ep("POST", "/batch")
|
||||||
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
|
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
|
||||||
POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all")
|
POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all")
|
||||||
|
GET_SYSTEM_METRICS: Endpoint = _ep("GET", "/system/metrics")
|
||||||
|
|
||||||
# Collect all endpoint module-level constants for __all__ verification
|
# Collect all endpoint module-level constants for __all__ verification
|
||||||
_all_endpoints = [
|
_all_endpoints = [
|
||||||
|
|||||||
+1
-10
@@ -350,7 +350,6 @@ def create_app() -> web.Application:
|
|||||||
"""
|
"""
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_route("GET", "/health", _health)
|
app.router.add_route("GET", "/health", _health)
|
||||||
app.router.add_route("GET", "/status/all", get_status_all)
|
|
||||||
app.router.add_route("POST", "/status/refresh", refresh_status)
|
app.router.add_route("POST", "/status/refresh", refresh_status)
|
||||||
app.router.add_route("POST", "/batch", _handle_batch)
|
app.router.add_route("POST", "/batch", _handle_batch)
|
||||||
app.router.add_route("GET", "/ws", _handle_ws)
|
app.router.add_route("GET", "/ws", _handle_ws)
|
||||||
@@ -462,15 +461,6 @@ async def _health(_request: web.Request) -> web.Response:
|
|||||||
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
|
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
|
||||||
|
|
||||||
|
|
||||||
async def get_status_all(_request: web.Request) -> web.Response:
|
|
||||||
"""Return the entire state snapshot in one call.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response containing state data for all subsystems.
|
|
||||||
"""
|
|
||||||
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_status(_request: web.Request) -> web.Response:
|
async def refresh_status(_request: web.Request) -> web.Response:
|
||||||
"""Re-collect all state from system.
|
"""Re-collect all state from system.
|
||||||
|
|
||||||
@@ -517,6 +507,7 @@ def _register_routes() -> None:
|
|||||||
network, # noqa: F401
|
network, # noqa: F401
|
||||||
nginx, # noqa: F401
|
nginx, # noqa: F401
|
||||||
status, # noqa: F401
|
status, # noqa: F401
|
||||||
|
system, # noqa: F401
|
||||||
wireguard, # noqa: F401
|
wireguard, # noqa: F401
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+114
@@ -36,6 +36,7 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
|||||||
"wireguard": 10,
|
"wireguard": 10,
|
||||||
"dnsmasq": 10,
|
"dnsmasq": 10,
|
||||||
"networkd": 10,
|
"networkd": 10,
|
||||||
|
"system": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ class State:
|
|||||||
"acme",
|
"acme",
|
||||||
"wireguard",
|
"wireguard",
|
||||||
"networkd",
|
"networkd",
|
||||||
|
"system",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -1071,6 +1073,118 @@ register_volatile(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System metrics collector
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meminfo() -> dict[str, Any]:
|
||||||
|
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
|
||||||
|
info: dict[str, int] = {}
|
||||||
|
try:
|
||||||
|
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
parts = value.strip().split()
|
||||||
|
val = int(parts[0])
|
||||||
|
# Convert kB to bytes
|
||||||
|
if parts and parts[-1] == "kB":
|
||||||
|
val *= 1024
|
||||||
|
info[key] = val
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_system() -> dict[str, Any]:
|
||||||
|
"""Collect system-wide metrics: CPU load, memory, network traffic.
|
||||||
|
|
||||||
|
Reads from /proc and /sys — no subprocess needed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
|
||||||
|
"""
|
||||||
|
# CPU load
|
||||||
|
loads = []
|
||||||
|
try:
|
||||||
|
parts = Path("/proc/loadavg").read_text().split()
|
||||||
|
loads = [float(x) for x in parts[:3]]
|
||||||
|
except (OSError, ValueError):
|
||||||
|
loads = [0.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
# Memory
|
||||||
|
meminfo_raw = _parse_meminfo()
|
||||||
|
mem_total = meminfo_raw.get("MemTotal", 0)
|
||||||
|
mem_free = meminfo_raw.get("MemFree", 0)
|
||||||
|
mem_available = meminfo_raw.get("MemAvailable", mem_free)
|
||||||
|
mem_buffers = meminfo_raw.get("Buffers", 0)
|
||||||
|
mem_cached = meminfo_raw.get("Cached", 0)
|
||||||
|
mem_used = mem_total - mem_free - mem_buffers - mem_cached
|
||||||
|
if mem_used < 0:
|
||||||
|
mem_used = mem_total - mem_available
|
||||||
|
|
||||||
|
# Swap
|
||||||
|
swap_total = meminfo_raw.get("SwapTotal", 0)
|
||||||
|
swap_free = meminfo_raw.get("SwapFree", 0)
|
||||||
|
swap_used = swap_total - swap_free
|
||||||
|
|
||||||
|
# Network traffic from /sys/class/net/<iface>/statistics/
|
||||||
|
traffic: dict[str, dict[str, int]] = {}
|
||||||
|
try:
|
||||||
|
net_root = Path("/sys/class/net")
|
||||||
|
if net_root.is_dir():
|
||||||
|
for iface_dir in net_root.iterdir():
|
||||||
|
stats_dir = iface_dir / "statistics"
|
||||||
|
if not stats_dir.is_dir():
|
||||||
|
continue
|
||||||
|
iface_name = iface_dir.name
|
||||||
|
rx_bytes = 0
|
||||||
|
tx_bytes = 0
|
||||||
|
rx_packets = 0
|
||||||
|
tx_packets = 0
|
||||||
|
try:
|
||||||
|
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
|
||||||
|
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
|
||||||
|
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
|
||||||
|
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
traffic[iface_name] = {
|
||||||
|
"rx_bytes": rx_bytes,
|
||||||
|
"tx_bytes": tx_bytes,
|
||||||
|
"rx_packets": rx_packets,
|
||||||
|
"tx_packets": tx_packets,
|
||||||
|
}
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"load": {
|
||||||
|
"load1": loads[0],
|
||||||
|
"load5": loads[1],
|
||||||
|
"load15": loads[2],
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"total": mem_total,
|
||||||
|
"available": mem_available,
|
||||||
|
"used": mem_used,
|
||||||
|
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
|
||||||
|
},
|
||||||
|
"swap": {
|
||||||
|
"total": swap_total,
|
||||||
|
"used": swap_used,
|
||||||
|
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
|
||||||
|
},
|
||||||
|
"traffic": traffic,
|
||||||
|
"timestamp": _now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
register_collector("system", _collect_system)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"_DEFAULT_POLL_INTERVALS",
|
"_DEFAULT_POLL_INTERVALS",
|
||||||
"State",
|
"State",
|
||||||
|
|||||||
+11
-5
@@ -379,20 +379,26 @@ def _parse_wireguard_conf(text: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def import_networkd() -> bool:
|
def import_networkd() -> bool:
|
||||||
"""Parse /etc/systemd/network/99-*.network -> config/network/config.json."""
|
"""Parse /etc/systemd/network/*.network -> config/network/config.json."""
|
||||||
if not NETWORKD_DIR.exists():
|
if not NETWORKD_DIR.exists():
|
||||||
logger.debug("Skipping network: %s not found", NETWORKD_DIR)
|
logger.debug("Skipping network: %s not found", NETWORKD_DIR)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
network_files = sorted(NETWORKD_DIR.glob("99-*.network"))
|
network_files = sorted(NETWORKD_DIR.glob("*.network"))
|
||||||
if not network_files:
|
if not network_files:
|
||||||
logger.debug("Skipping network: no 99-*.network files")
|
logger.debug("Skipping network: no *.network files")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
parsed_interfaces: dict[str, dict[str, Any]] = {}
|
parsed_interfaces: dict[str, dict[str, Any]] = {}
|
||||||
for nf in network_files:
|
for nf in network_files:
|
||||||
# Extract interface name from filename: 99-eth0.network -> eth0
|
# Extract interface name from filename, stripping optional priority prefix:
|
||||||
iface_name = nf.stem[3:] # strip "99-"
|
# 99-eth0.network -> eth0
|
||||||
|
# eth0.network -> eth0
|
||||||
|
base = nf.stem
|
||||||
|
if "-" in base and base.split("-", 1)[0].isdigit():
|
||||||
|
iface_name = base.split("-", 1)[1]
|
||||||
|
else:
|
||||||
|
iface_name = base
|
||||||
try:
|
try:
|
||||||
parsed_interface = _parse_network_file(nf)
|
parsed_interface = _parse_network_file(nf)
|
||||||
if parsed_interface:
|
if parsed_interface:
|
||||||
|
|||||||
@@ -43,25 +43,6 @@ class TestWsUrlGeneration:
|
|||||||
assert b"ws://[::1]:9090/ws" in resp.data
|
assert b"ws://[::1]:9090/ws" in resp.data
|
||||||
|
|
||||||
|
|
||||||
class TestApiStatusAll:
|
|
||||||
@patch("webui.server.get")
|
|
||||||
def test_success(self, mock_get, client):
|
|
||||||
mock_get.return_value = {"firewall": {"zones": {}}, "dnsmasq": {}}
|
|
||||||
resp = client.get("/api/status/all")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data["ok"] is True
|
|
||||||
assert "firewall" in data["data"]
|
|
||||||
|
|
||||||
@patch("webui.server.get")
|
|
||||||
def test_error(self, mock_get, client):
|
|
||||||
mock_get.side_effect = RuntimeError("connection refused")
|
|
||||||
resp = client.get("/api/status/all")
|
|
||||||
assert resp.status_code == 500
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data["ok"] is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestBlueprintsRegistered:
|
class TestBlueprintsRegistered:
|
||||||
def test_all_blueprints_registered(self, client):
|
def test_all_blueprints_registered(self, client):
|
||||||
from webui.server import BLUEPRINTS
|
from webui.server import BLUEPRINTS
|
||||||
|
|||||||
+18
-1
@@ -10,7 +10,7 @@ import logging
|
|||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from daemon.client import get, post
|
from daemon.client import get, post
|
||||||
from daemon.iface import GET_STATUS_PENDING, POST_STATUS_APPLY_ALL
|
from daemon.iface import GET_STATUS_PENDING, GET_SYSTEM_METRICS, POST_STATUS_APPLY_ALL
|
||||||
from webui.api.common import _error, _ok
|
from webui.api.common import _error, _ok
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -49,3 +49,20 @@ def apply_all():
|
|||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
logger.error("Failed to apply all pending changes: %s", exc)
|
logger.error("Failed to apply all pending changes: %s", exc)
|
||||||
return _error(str(exc), 500)
|
return _error(str(exc), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/system-metrics", methods=["GET"])
|
||||||
|
def system_metrics():
|
||||||
|
"""Retrieve system-wide metrics.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
GET /api/status/system-metrics
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON response with CPU load, memory usage, and network traffic stats.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _ok(get(GET_SYSTEM_METRICS))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("Failed to get system metrics: %s", exc)
|
||||||
|
return _error(str(exc), 500)
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ from pathlib import Path
|
|||||||
from flask import Flask, abort, request
|
from flask import Flask, abort, request
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
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.logging import setup_logging
|
||||||
from webui.api.certs import bp as certs_bp
|
from webui.api.certs import bp as certs_bp
|
||||||
from webui.api.dhcp import bp as dhcp_bp
|
from webui.api.dhcp import bp as dhcp_bp
|
||||||
@@ -158,27 +156,6 @@ def _log_request_finish(response):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API proxy routes
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/status/all")
|
|
||||||
def api_status_all():
|
|
||||||
"""Return aggregated status from all subsystems.
|
|
||||||
|
|
||||||
Proxies the daemon's ``/status/all`` endpoint for SPA consumption.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON response with state data for all subsystems.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return {"ok": True, "data": get(GET_STATUS_ALL)}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Status all failed: %s", exc)
|
|
||||||
return {"ok": False, "error": str(exc)}, 500
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SPA entry point — serve index.html for /, 404 for everything else
|
# SPA entry point — serve index.html for /, 404 for everything else
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+21
-13
@@ -1,6 +1,6 @@
|
|||||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
|
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=10';
|
||||||
|
|
||||||
import DashboardPage from '/static/pages/dashboard.js?v=9';
|
import DashboardPage from '/static/pages/dashboard.js?v=11';
|
||||||
import InterfacesPage from '/static/pages/interfaces.js?v=9';
|
import InterfacesPage from '/static/pages/interfaces.js?v=9';
|
||||||
import ZonesPage from '/static/pages/zones.js?v=9';
|
import ZonesPage from '/static/pages/zones.js?v=9';
|
||||||
import RulesPage from '/static/pages/rules.js?v=9';
|
import RulesPage from '/static/pages/rules.js?v=9';
|
||||||
@@ -28,16 +28,6 @@ const Nav = [
|
|||||||
{ path: '/logs', label: 'Logs' },
|
{ path: '/logs', label: 'Logs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ── Model registration ────────────────────────────────────── */
|
|
||||||
modelRegister('status', {
|
|
||||||
subsystem: 'status',
|
|
||||||
fetch: async () => {
|
|
||||||
const r = await apiFetch('/api/status/all');
|
|
||||||
if (!r.ok) throw new Error(r.error);
|
|
||||||
return r.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
modelRegister('firewall', {
|
modelRegister('firewall', {
|
||||||
subsystem: 'firewall',
|
subsystem: 'firewall',
|
||||||
fetch: async () => {
|
fetch: async () => {
|
||||||
@@ -166,8 +156,26 @@ modelRegister('logs', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelRegister('status', {
|
||||||
|
subsystem: '*',
|
||||||
|
fetch: async () => {
|
||||||
|
const [pendingR, metricsR] = await Promise.allSettled([
|
||||||
|
apiFetch('/api/status/pending'),
|
||||||
|
apiFetch('/api/status/system-metrics'),
|
||||||
|
]);
|
||||||
|
const result = {};
|
||||||
|
if (pendingR.status === 'fulfilled' && pendingR.value.ok) {
|
||||||
|
result.pending = pendingR.value.data || {};
|
||||||
|
}
|
||||||
|
if (metricsR.status === 'fulfilled' && metricsR.value.ok) {
|
||||||
|
result.metrics = metricsR.value.data || {};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Initial fetch ─────────────────────────────────────────── */
|
/* ── Initial fetch ─────────────────────────────────────────── */
|
||||||
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme']) {
|
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
|
||||||
modelFetch(name);
|
modelFetch(name);
|
||||||
}
|
}
|
||||||
modelFetch('logs', 'journal');
|
modelFetch('logs', 'journal');
|
||||||
|
|||||||
+197
-27
@@ -1,52 +1,222 @@
|
|||||||
import { html, PageHeader, definePage, getModel, renderGuard, ServiceStatus, StatCard } from '/static/hoover/index.js?v=9';
|
import { html, PageHeader, definePage, getModel, renderGuardMulti, ServiceStatus, StatCard, Table, Badge, ActionButton } from '/static/hoover/index.js?v=10';
|
||||||
|
|
||||||
|
function fmtBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
export default definePage({
|
export default definePage({
|
||||||
init() {
|
init() {
|
||||||
return {
|
return {
|
||||||
|
firewall: getModel('firewall'),
|
||||||
|
network: getModel('network'),
|
||||||
|
dnsmasq: getModel('dnsmasq'),
|
||||||
|
wireguard: getModel('wireguard'),
|
||||||
|
acme: getModel('acme'),
|
||||||
|
nginx: getModel('nginx'),
|
||||||
status: getModel('status'),
|
status: getModel('status'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
render(state) {
|
render(state) {
|
||||||
const guard = renderGuard(state.status, 'Dashboard', 'System overview', state.status.data);
|
const guard = renderGuardMulti('Dashboard', 'System overview',
|
||||||
|
state.firewall, state.network, state.dnsmasq, state.wireguard,
|
||||||
|
state.acme, state.nginx, state.status);
|
||||||
if (guard) return guard;
|
if (guard) return guard;
|
||||||
|
|
||||||
const d = state.status.data;
|
// Extract data
|
||||||
const fwZones = (d.firewall?.zones) || {};
|
const fwIfaces = Array.isArray(state.firewall.data?.interfaces) ? state.firewall.data.interfaces : [];
|
||||||
const net = d.net || {};
|
const fwZones = state.firewall.data?.zones || {};
|
||||||
const nCount = Object.keys(net).length;
|
const netIfaces = state.network.data?.interfaces || {};
|
||||||
const upI = Object.values(net).filter(i => i.state === 'up');
|
const dnsmasqStatus = state.dnsmasq.data?.status || {};
|
||||||
const upC = upI.length;
|
const dnsmasqLeases = state.dnsmasq.data?.leases || [];
|
||||||
const certs = d.certs || [];
|
const activeLeaseCount = dnsmasqStatus.active_leases || dnsmasqLeases.length;
|
||||||
const certW = certs.filter(c => c.expired || c.days_remaining <= 30);
|
const proxyDomains = state.nginx.data?.domains || [];
|
||||||
const dmsk = d.dnsmasq?.status || {};
|
const onlineDomains = proxyDomains ? proxyDomains.filter(d => d.online).length : 0;
|
||||||
const wP = (d.wg || {}).peers || [];
|
const offlineDomains = (proxyDomains?.length || 0) - onlineDomains;
|
||||||
|
const wgp = state.wireguard.data?.peers || [];
|
||||||
|
const wgStatus = state.wireguard.data?.status || {};
|
||||||
|
const allCerts = state.acme.data?.certs || [];
|
||||||
|
const expiringCerts = allCerts.filter(c => c.expired || (c.days_remaining !== undefined && c.days_remaining <= 30));
|
||||||
|
|
||||||
|
// System metrics from status model
|
||||||
|
const sysMetrics = state.status.data?.metrics || {};
|
||||||
|
const sysLoad = sysMetrics.load || {};
|
||||||
|
const sysMem = sysMetrics.memory || {};
|
||||||
|
const sysSwap = sysMetrics.swap || {};
|
||||||
|
const sysTraffic = sysMetrics.traffic || {};
|
||||||
|
|
||||||
|
// Pending changes
|
||||||
|
const pend = state.status.data?.pending || {};
|
||||||
|
const totalChanges = pend.total_changes || 0;
|
||||||
|
const pendKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'].filter(k =>
|
||||||
|
k === 'firewall' ? (pend[k]?.needs_apply) : (pend[k]?.pending_changes)
|
||||||
|
);
|
||||||
|
const pendLabels = { firewall: 'Firewall', dnsmasq: 'DHCP', nginx: 'Proxy', wireguard: 'WireGuard', networkd: 'Network' };
|
||||||
|
|
||||||
|
// Build merged interface list
|
||||||
|
const allNames = [...new Set([...fwIfaces.map(f => f.name), ...Object.keys(netIfaces)])];
|
||||||
|
const ifaces = allNames.map(name => {
|
||||||
|
const fw = fwIfaces.find(f => f.name === name);
|
||||||
|
const netEntry = netIfaces[name] || {};
|
||||||
|
const traffic = sysTraffic[name] || {};
|
||||||
|
const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : [];
|
||||||
|
const addrs = netEntry?.addresses || [];
|
||||||
|
const isUp = ['routable', 'degraded'].some(s => (netEntry.state || '').startsWith(s));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
mac: fw?.mac || null,
|
||||||
|
ips: ips.length ? ips : addrs,
|
||||||
|
isUp,
|
||||||
|
zone: fw?.zone || '—',
|
||||||
|
rx: traffic.rx_bytes || 0,
|
||||||
|
tx: traffic.tx_bytes || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stat cards ──
|
||||||
|
const zoneNames = Object.keys(fwZones);
|
||||||
const stats = html`<div class="grid grid-4">
|
const stats = html`<div class="grid grid-4">
|
||||||
<${StatCard} label="Active Zones" value=${Object.keys(fwZones).length}
|
<${StatCard} label="Active Zones" value=${zoneNames.length}
|
||||||
meta=${Object.keys(fwZones).join(', ') || 'None'} />
|
meta=${zoneNames.join(', ') || 'None'} />
|
||||||
<${StatCard} label="Interfaces Up" value=${upC + '/' + nCount}
|
<${StatCard} label="DHCP Leases" value=${activeLeaseCount}
|
||||||
meta=${upI.map(i => i.name).join(', ') || 'None up'} />
|
meta=${dnsmasqLeases.slice(0, 3).map(l => l.hostname || l.mac).join(', ') || 'None'} />
|
||||||
<${StatCard} label="WireGuard" value=${String((d.wg?.status?.up) ? 'up' : 'down')}
|
<${StatCard} label="Proxy Domains" value=${onlineDomains + '/' + (proxyDomains?.length || 0)}
|
||||||
meta=${wP.length + ' peers'} />
|
meta=${offlineDomains + ' offline'} />
|
||||||
<${StatCard} label="Certificates" value=${certs.length}
|
<${StatCard} label="Certificates" value=${allCerts.length}
|
||||||
meta=${certW.length + ' expiring/expired'} />
|
meta=${expiringCerts.length ? expiringCerts.length + ' expiring' : 'All valid'} />
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const services = html`<div class="grid grid-2">
|
// ── Pending changes ──
|
||||||
<div class="card">
|
const pendingCard = pendKeys.length > 0
|
||||||
<div class="card-header">Services</div>
|
? html`<div class="card">
|
||||||
|
<div class="card-header">Pending Changes <span style="margin-left:8px"><${Badge} text=${String(totalChanges)} variant="warning" /></span></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ul class="service-list">
|
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
|
||||||
<li><${ServiceStatus} state=${dmsk.service_active ? 'up' : 'down'} label="Dnsmasq" /></li>
|
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
|
||||||
<li><${ServiceStatus} state=${(d.wg?.status?.up) ? 'up' : 'down'} label="WireGuard" /></li>
|
successMsg="All changes applied" refresh="status"
|
||||||
</ul>
|
cls="btn btn-sm btn-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>`
|
||||||
|
: html`<div class="card">
|
||||||
|
<div class="card-header">Pending Changes</div>
|
||||||
|
<div class="card-body"><${Badge} text="All configured" variant="success" /></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── System resources ──
|
||||||
|
const memPct = sysMem.used_pct || 0;
|
||||||
|
const memVariant = memPct > 80 ? 'danger' : memPct > 60 ? 'warning' : 'success';
|
||||||
|
const swapPct = sysSwap.used_pct || 0;
|
||||||
|
const swapVariant = swapPct > 50 ? 'warning' : 'success';
|
||||||
|
const systemCard = html`<div class="card">
|
||||||
|
<div class="card-header">System Resources</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table"><tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:120px"><strong>CPU Load</strong></td>
|
||||||
|
<td>${(sysLoad.load1 || 0).toFixed(2)} / ${(sysLoad.load5 || 0).toFixed(2)} / ${(sysLoad.load15 || 0).toFixed(2)}</td>
|
||||||
|
<td class="text-right text-muted" style="width:100px">1m / 5m / 15m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Memory</strong></td>
|
||||||
|
<td><${Badge} text=${Math.round(memPct) + '%'} variant=${memVariant} />
|
||||||
|
${fmtBytes(sysMem.used || 0)} / ${fmtBytes(sysMem.total || 0)}</td>
|
||||||
|
<td class="text-right text-muted">used / total</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Swap</strong></td>
|
||||||
|
<td>${sysSwap.total > 0
|
||||||
|
? html`<${Badge} text=${Math.round(swapPct) + '%'} variant=${swapVariant} />
|
||||||
|
${fmtBytes(sysSwap.used || 0)} / ${fmtBytes(sysSwap.total)}`
|
||||||
|
: html`<span class="text-muted">Disabled</span>`}</td>
|
||||||
|
<td class="text-right text-muted">used / total</td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// ── Interface table ──
|
||||||
|
const ifaceRows = ifaces.map(i => html`<tr key=${i.name}>
|
||||||
|
<td><strong>${i.name}</strong></td>
|
||||||
|
<td class="text-sm text-muted">${i.mac || '—'}</td>
|
||||||
|
<td class="text-sm">${i.ips.join(', ') || '—'}</td>
|
||||||
|
<td>${i.zone !== '—' ? html`<${Badge} text=${i.zone} variant="info" />` : html`<span class="text-muted">—</span>`}</td>
|
||||||
|
<td><${ServiceStatus} state=${i.isUp ? 'up' : 'down'} label="" /></td>
|
||||||
|
<td class="text-sm">${fmtBytes(i.rx)}</td>
|
||||||
|
<td class="text-sm">${fmtBytes(i.tx)}</td>
|
||||||
|
</tr>`);
|
||||||
|
const interfacesCard = html`<div class="card">
|
||||||
|
<div class="card-header">Network Interfaces</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<${Table}
|
||||||
|
columns=${['Name', 'MAC', 'IP Addresses', 'Zone', 'State', 'RX', 'TX']}
|
||||||
|
rows=${ifaceRows} emptyText="No interfaces found" />
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── DHCP leases ──
|
||||||
|
const leaseRows = dnsmasqLeases.slice(0, 10).map(l => html`<tr key=${l.mac + l.ip}>
|
||||||
|
<td class="text-sm">${l.ip}</td>
|
||||||
|
<td class="text-sm text-muted">${l.mac}</td>
|
||||||
|
<td class="text-sm">${l.hostname || '—'}</td>
|
||||||
|
<td class="text-sm text-muted">${l.interface || '—'}</td>
|
||||||
|
</tr>`);
|
||||||
|
const dhcpCard = html`<div class="card">
|
||||||
|
<div class="card-header">Active DHCP Leases (${activeLeaseCount})</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<${Table}
|
||||||
|
columns=${['IP', 'MAC', 'Hostname', 'Interface']}
|
||||||
|
rows=${leaseRows} emptyText="No active leases" />
|
||||||
|
${dnsmasqLeases.length > 10 ? html`<p class="text-muted text-sm">Showing 10 of ${dnsmasqLeases.length}.</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Proxy domains ──
|
||||||
|
const proxyCard = html`<div class="card">
|
||||||
|
<div class="card-header">Proxy Domains</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="display:flex;gap:16px;margin-bottom:8px">
|
||||||
|
<${Badge} text=${onlineDomains} variant="success" /> Online
|
||||||
|
<${Badge} text=${offlineDomains} variant=${offlineDomains > 0 ? 'danger' : 'info'} /> Offline
|
||||||
|
</div>
|
||||||
|
${proxyDomains.slice(0, 3).map(d => html`<div class="text-sm">
|
||||||
|
<strong>${d.domain}</strong> → ${d.backend_name || '—'}
|
||||||
|
<span class="text-muted">${d.path || '/'}</span>
|
||||||
|
</div>`)}
|
||||||
|
${proxyDomains.length === 0 ? html`<span class="text-muted text-sm">No proxy domains configured.</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Services ──
|
||||||
|
const upCount = ifaces.filter(i => i.isUp).length;
|
||||||
|
const services = html`<div class="card">
|
||||||
|
<div class="card-header">Services</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="service-list">
|
||||||
|
<li><${ServiceStatus} state=${dnsmasqStatus.service_active ? 'up' : 'down'} label="Dnsmasq" /></li>
|
||||||
|
<li><${ServiceStatus} state=${wgStatus.up ? 'up' : 'down'} label="WireGuard" />
|
||||||
|
<span class="text-muted text-sm">${wgp.length} peers</span></li>
|
||||||
|
<li><${ServiceStatus} state=${upCount > 0 ? 'up' : 'down'} label="Network" />
|
||||||
|
<span class="text-muted text-sm">${upCount}/${ifaces.length} interfaces online</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// ── Side-by-side layout ──
|
||||||
|
const dualCards = html`<div class="grid grid-2">
|
||||||
|
${dhcpCard}
|
||||||
|
${proxyCard}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
|
||||||
stats,
|
stats,
|
||||||
|
pendingCard,
|
||||||
|
systemCard,
|
||||||
|
interfacesCard,
|
||||||
|
dualCards,
|
||||||
services,
|
services,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-8
@@ -465,6 +465,10 @@ body {
|
|||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-4 {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
/* Text Colors */
|
/* Text Colors */
|
||||||
.text-success {
|
.text-success {
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
@@ -521,26 +525,39 @@ body {
|
|||||||
|
|
||||||
/* Stat cards */
|
/* Stat cards */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 3px solid var(--accent);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card .label {
|
.stat-card .label {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card .value {
|
.stat-card .value {
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card .meta {
|
.stat-card .meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 4px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status dot */
|
/* Status dot */
|
||||||
@@ -603,16 +620,30 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.service-list li {
|
.service-list li {
|
||||||
padding: 6px 0;
|
padding: 8px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-list .svc-name {
|
.service-list .svc-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Service status inline badge */
|
||||||
|
.service-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -827,7 +858,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid-2,
|
.grid-2,
|
||||||
.grid-3 {
|
.grid-3,
|
||||||
|
.grid-4 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user