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
+114
View File
@@ -36,6 +36,7 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = {
"wireguard": 10,
"dnsmasq": 10,
"networkd": 10,
"system": 30,
}
@@ -63,6 +64,7 @@ class State:
"acme",
"wireguard",
"networkd",
"system",
]
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__ = [
"_DEFAULT_POLL_INTERVALS",
"State",
+11 -5
View File
@@ -379,20 +379,26 @@ def _parse_wireguard_conf(text: str) -> dict[str, Any]:
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():
logger.debug("Skipping network: %s not found", NETWORKD_DIR)
return False
network_files = sorted(NETWORKD_DIR.glob("99-*.network"))
network_files = sorted(NETWORKD_DIR.glob("*.network"))
if not network_files:
logger.debug("Skipping network: no 99-*.network files")
logger.debug("Skipping network: no *.network files")
return False
parsed_interfaces: dict[str, dict[str, Any]] = {}
for nf in network_files:
# Extract interface name from filename: 99-eth0.network -> eth0
iface_name = nf.stem[3:] # strip "99-"
# Extract interface name from filename, stripping optional priority prefix:
# 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:
parsed_interface = _parse_network_file(nf)
if parsed_interface: