faa076370d
- move state collectors from lib/state.py to daemon/collectors/ (7 modules, registration side-effect; daemon/server.py imports the package before the first populate()) - webui/api: new daemon_route() decorator factory in common.py collapses the try/except daemon-proxy boilerplate in all 8 blueprints (rules/params/body/transform keep responses identical) - firewall: interface-coverage invariant — config is the source of truth for zone interfaces (absent key = empty, no hands-off zones); pure validate_coverage() enforced at save (400) and apply (409, force: true overrides), top-level `unmanaged` exemption - lib: get_config() reads are now pure (no dir creation or writes); new lib/bootstrap.py creates runtime dirs and persists the one-shot nginx legacy migration at daemon start, after system_import (lib.nginx.migrate_config_file) - lib/common: compute_pending() apply-bookkeeping helper - daemon: emit_and_refresh() handler helper; refresh_state(bump=) so /status/refresh no longer bumps versions (poll/mutation only) - acme: move --log last so acme.sh never treats a real arg as the log-file argument - docs: AGENTS.md, config.md, state-model.md, api.md updated; HARDEN.md dropped (plan implemented); apply-confirm force wording Tests: 917 passed; ruff check + format clean.
126 lines
3.8 KiB
Python
126 lines
3.8 KiB
Python
"""System metrics collector."""
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from lib import schema
|
|
from lib.state import _now_iso, register_collector, register_volatile
|
|
|
|
|
|
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() -> schema.SystemState:
|
|
"""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)
|
|
register_volatile(
|
|
"system",
|
|
frozenset(
|
|
{
|
|
"load",
|
|
"memory",
|
|
"swap",
|
|
"traffic",
|
|
}
|
|
),
|
|
)
|