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:
2026-07-15 00:24:43 +00:00
parent c21639b7f1
commit dadabd7954
11 changed files with 438 additions and 107 deletions
+18 -1
View File
@@ -10,7 +10,7 @@ import logging
from flask import Blueprint
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
logger = logging.getLogger(__name__)
@@ -49,3 +49,20 @@ def apply_all():
except RuntimeError as exc:
logger.error("Failed to apply all pending changes: %s", exc)
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)
-23
View File
@@ -17,8 +17,6 @@ from pathlib import Path
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 webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
@@ -158,27 +156,6 @@ def _log_request_finish(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
# ---------------------------------------------------------------------------
+21 -13
View File
@@ -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 ZonesPage from '/static/pages/zones.js?v=9';
import RulesPage from '/static/pages/rules.js?v=9';
@@ -28,16 +28,6 @@ const Nav = [
{ 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', {
subsystem: 'firewall',
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 ─────────────────────────────────────────── */
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('logs', 'journal');
+197 -27
View File
@@ -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({
init() {
return {
firewall: getModel('firewall'),
network: getModel('network'),
dnsmasq: getModel('dnsmasq'),
wireguard: getModel('wireguard'),
acme: getModel('acme'),
nginx: getModel('nginx'),
status: getModel('status'),
};
},
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;
const d = state.status.data;
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 wP = (d.wg || {}).peers || [];
// Extract data
const fwIfaces = Array.isArray(state.firewall.data?.interfaces) ? state.firewall.data.interfaces : [];
const fwZones = state.firewall.data?.zones || {};
const netIfaces = state.network.data?.interfaces || {};
const dnsmasqStatus = state.dnsmasq.data?.status || {};
const dnsmasqLeases = state.dnsmasq.data?.leases || [];
const activeLeaseCount = dnsmasqStatus.active_leases || dnsmasqLeases.length;
const proxyDomains = state.nginx.data?.domains || [];
const onlineDomains = proxyDomains ? proxyDomains.filter(d => d.online).length : 0;
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">
<${StatCard} label="Active Zones" value=${Object.keys(fwZones).length}
meta=${Object.keys(fwZones).join(', ') || 'None'} />
<${StatCard} label="Interfaces Up" value=${upC + '/' + nCount}
meta=${upI.map(i => i.name).join(', ') || 'None up'} />
<${StatCard} label="WireGuard" value=${String((d.wg?.status?.up) ? 'up' : 'down')}
meta=${wP.length + ' peers'} />
<${StatCard} label="Certificates" value=${certs.length}
meta=${certW.length + ' expiring/expired'} />
<${StatCard} label="Active Zones" value=${zoneNames.length}
meta=${zoneNames.join(', ') || 'None'} />
<${StatCard} label="DHCP Leases" value=${activeLeaseCount}
meta=${dnsmasqLeases.slice(0, 3).map(l => l.hostname || l.mac).join(', ') || 'None'} />
<${StatCard} label="Proxy Domains" value=${onlineDomains + '/' + (proxyDomains?.length || 0)}
meta=${offlineDomains + ' offline'} />
<${StatCard} label="Certificates" value=${allCerts.length}
meta=${expiringCerts.length ? expiringCerts.length + ' expiring' : 'All valid'} />
</div>`;
const services = html`<div class="grid grid-2">
<div class="card">
<div class="card-header">Services</div>
// ── Pending changes ──
const pendingCard = pendKeys.length > 0
? 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">
<ul class="service-list">
<li><${ServiceStatus} state=${dmsk.service_active ? 'up' : 'down'} label="Dnsmasq" /></li>
<li><${ServiceStatus} state=${(d.wg?.status?.up) ? 'up' : 'down'} label="WireGuard" /></li>
</ul>
<p class="text-sm">Unapplied changes in: ${pendKeys.map(k => pendLabels[k]).join(', ')}</p>
<${ActionButton} url="/api/status/apply-all" label="Apply All Changes"
successMsg="All changes applied" refresh="status"
cls="btn btn-sm btn-primary" />
</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>`;
// ── 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 [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
stats,
pendingCard,
systemCard,
interfacesCard,
dualCards,
services,
];
},
+40 -8
View File
@@ -465,6 +465,10 @@ body {
grid-template-columns: repeat(3, 1fr);
}
.grid-4 {
grid-template-columns: repeat(4, 1fr);
}
/* Text Colors */
.text-success {
color: var(--success);
@@ -521,26 +525,39 @@ body {
/* Stat cards */
.stat-card {
background: var(--bg-card);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
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 {
font-size: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 4px;
margin-bottom: 6px;
}
.stat-card .value {
font-size: 28px;
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-card .meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
margin-top: 6px;
}
/* Status dot */
@@ -603,16 +620,30 @@ body {
}
.service-list li {
padding: 6px 0;
padding: 8px 0;
display: flex;
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 {
flex: 1;
}
/* Service status inline badge */
.service-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 500;
font-size: 14px;
}
/* Tabs */
.tabs {
display: flex;
@@ -827,7 +858,8 @@ body {
}
.grid-2,
.grid-3 {
.grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
}