Files
p42.s-m/app/collector.py
T
guyg1474andClaude Opus 5.5 a1beee3d85 Verlauf um Swap und Temperatur ergänzen, Netz p42 als externes Netz
/api/history liefert zusätzlich swapPercent und tempMaxCelsius (für den
2-Tage-Verlauf im Dashboard). Das Docker-Netz p42 legt update-docker.sh
bei Bedarf an, damit die Startreihenfolge mit P42-F.W. egal ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:22:48 +02:00

321 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Liest die Auslastung des Hosts direkt aus /proc und /sys – ohne Fremdpakete.
Im Container sind die Host-Verzeichnisse eingebunden (siehe docker-compose.yml):
HOST_PROC /proc des Hosts (Standard /host/proc)
HOST_SYS /sys des Hosts (Standard /sys – sysfs ist im Container ohnehin das des Hosts)
HOST_ROOT / des Hosts (Standard /host/root – für Plattenbelegung und os-release)
Lokal ohne Docker: HOST_PROC=/proc HOST_ROOT=/ setzen.
"""
import os
import re
import time
from datetime import datetime
HOST_PROC = os.environ.get("HOST_PROC", "/host/proc")
HOST_SYS = os.environ.get("HOST_SYS", "/sys")
HOST_ROOT = os.environ.get("HOST_ROOT", "/host/root")
# Dateisysteme, die als echte Platten zählen. Netzlaufwerke (nfs, cifs) bewusst nicht:
# statvfs kann dort bei Verbindungsproblemen hängen.
DISK_FS = {"ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "vfat", "exfat", "ntfs", "ntfs3", "fuseblk", "f2fs", "jfs"}
# Ganze Laufwerke ohne Zählwert für die Platten-Aktivität (virtuell oder doppelt gezählt)
SKIP_BLOCK = re.compile(r"^(loop|ram|zram|sr|dm-|md)")
# Virtuelle Netzwerk-Schnittstellen: werden gelistet, aber nicht in die Summe gerechnet,
# sonst zählt Container-Verkehr doppelt (veth -> Bridge -> echte Karte)
VIRTUAL_NET = re.compile(r"^(lo$|veth|docker|br-|virbr|cni|flannel|cali)")
SECTOR = 512 # /proc/diskstats zählt immer in 512-Byte-Sektoren
def _read(path: str) -> str:
with open(path, encoding="utf-8", errors="replace") as f:
return f.read()
def _read_or(path: str, default=None):
try:
return _read(path).strip()
except OSError:
return default
def _host_path(path: str) -> str:
return os.path.join(HOST_ROOT, path.lstrip("/"))
# ---------- Rohwerte (für Raten werden zwei Messungen verglichen) ----------
def _cpu_times() -> dict:
"""{'cpu': (busy, total), 'cpu0': ..., ...} aus /proc/stat."""
out = {}
for line in _read(f"{HOST_PROC}/stat").splitlines():
if not line.startswith("cpu"):
continue
name, *vals = line.split()
v = [int(x) for x in vals[:8]] # user nice system idle iowait irq softirq steal
total = sum(v)
idle = v[3] + v[4]
out[name] = (total - idle, total, v[4])
return out
def _disk_bytes() -> tuple[int, int]:
"""Gelesene/geschriebene Bytes aller echten Laufwerke (ohne Partitionen, loop, dm)."""
try:
disks = {d for d in os.listdir(f"{HOST_SYS}/block") if not SKIP_BLOCK.match(d)}
except OSError:
disks = None
read = write = 0
for line in _read(f"{HOST_PROC}/diskstats").splitlines():
f = line.split()
if len(f) < 10:
continue
name = f[2]
if disks is not None and name not in disks:
continue
read += int(f[5]) * SECTOR
write += int(f[9]) * SECTOR
return read, write
def _net_bytes() -> dict:
"""{'eth0': (rx, tx), ...} – aus dem Netz-Namespace des Hosts (PID 1), nicht des Containers."""
path = f"{HOST_PROC}/1/net/dev"
if not os.path.exists(path):
path = f"{HOST_PROC}/net/dev"
out = {}
for line in _read(path).splitlines()[2:]:
name, _, data = line.partition(":")
f = data.split()
if len(f) >= 9:
out[name.strip()] = (int(f[0]), int(f[8]))
return out
def raw_sample() -> dict:
return {"time": time.monotonic(), "cpu": _cpu_times(), "disk": _disk_bytes(), "net": _net_bytes()}
# ---------- Momentwerte ----------
def _meminfo() -> dict:
info = {}
for line in _read(f"{HOST_PROC}/meminfo").splitlines():
key, _, rest = line.partition(":")
info[key] = int(rest.split()[0]) * 1024
total = info.get("MemTotal", 0)
available = info.get("MemAvailable", info.get("MemFree", 0))
swap_total = info.get("SwapTotal", 0)
swap_used = swap_total - info.get("SwapFree", 0)
return {
"memory": {
"totalBytes": total,
"usedBytes": total - available,
"availableBytes": available,
"cachedBytes": info.get("Cached", 0) + info.get("Buffers", 0),
"percent": _pct(total - available, total),
},
"swap": {"totalBytes": swap_total, "usedBytes": swap_used, "percent": _pct(swap_used, swap_total)},
}
def _unescape_mount(path: str) -> str:
# /proc/mounts schreibt Leerzeichen usw. als \040
return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), path)
def _disks() -> list[dict]:
path = f"{HOST_PROC}/1/mounts"
if not os.path.exists(path):
path = f"{HOST_PROC}/mounts"
seen, out = set(), []
for line in _read(path).splitlines():
f = line.split()
if len(f) < 3 or f[2] not in DISK_FS:
continue
device, mount, fstype = f[0], _unescape_mount(f[1]), f[2]
if device in seen: # btrfs-Subvolumes, Bind-Mounts: dasselbe Laufwerk nur einmal
continue
try:
st = os.statvfs(_host_path(mount))
except OSError:
continue
seen.add(device)
total = st.f_blocks * st.f_frsize
used = (st.f_blocks - st.f_bfree) * st.f_frsize
free = st.f_bavail * st.f_frsize
out.append({
"mount": mount,
"device": device,
"fs": fstype,
"totalBytes": total,
"usedBytes": used,
"freeBytes": free,
"percent": _pct(used, used + free), # wie df: reservierte Blöcke nicht mitgezählt
})
return out
def _temperatures() -> list[dict]:
out = []
base = f"{HOST_SYS}/class/hwmon"
try:
sensors = sorted(os.listdir(base))
except OSError:
return out
for hw in sensors:
d = os.path.join(base, hw)
name = _read_or(f"{d}/name", hw)
try:
inputs = sorted(f for f in os.listdir(d) if re.fullmatch(r"temp\d+_input", f))
except OSError:
continue
for inp in inputs:
prefix = inp[: -len("_input")]
value = _read_or(f"{d}/{inp}")
if value is None or not value.lstrip("-").isdigit():
continue
crit = _read_or(f"{d}/{prefix}_crit")
out.append({
"sensor": name,
"label": _read_or(f"{d}/{prefix}_label", prefix),
"celsius": int(value) / 1000,
"criticalCelsius": int(crit) / 1000 if crit and crit.lstrip("-").isdigit() else None,
})
return out
def _cpu_model() -> str | None:
for line in _read(f"{HOST_PROC}/cpuinfo").splitlines():
if line.startswith("model name"):
return line.partition(":")[2].strip()
return None
def _cpu_mhz() -> float | None:
freqs = []
try:
cpus = os.listdir(f"{HOST_SYS}/devices/system/cpu")
except OSError:
return None
for cpu in cpus:
if re.fullmatch(r"cpu\d+", cpu):
v = _read_or(f"{HOST_SYS}/devices/system/cpu/{cpu}/cpufreq/scaling_cur_freq")
if v and v.isdigit():
freqs.append(int(v) / 1000)
return round(sum(freqs) / len(freqs)) if freqs else None
def _hostname() -> str | None:
# /proc/sys/kernel/hostname liefert im Container dessen ID (eigener UTS-Namespace),
# daher zuerst die Datei des Hosts
return _read_or(_host_path("/etc/hostname")) or _read_or(f"{HOST_PROC}/sys/kernel/hostname")
def _os_name() -> str | None:
for path in (_host_path("/etc/os-release"), _host_path("/usr/lib/os-release")):
text = _read_or(path)
if text:
m = re.search(r'^PRETTY_NAME="?([^"\n]*)"?', text, re.M)
if m:
return m[1]
return None
def _pct(part: float, whole: float) -> float:
return round(part / whole * 100, 1) if whole else 0.0
def _rate(new: int, old: int, seconds: float) -> int:
# Zähler können beim Neustart einer Schnittstelle zurückspringen
return max(0, round((new - old) / seconds)) if seconds > 0 else 0
# ---------- Gesamtbild ----------
def snapshot(prev: dict, cur: dict, interval: int) -> dict:
"""Alle Werte zu einem JSON-fähigen Dict; Raten aus der Differenz prev -> cur."""
seconds = cur["time"] - prev["time"]
cores, cpu_total, iowait = [], 0.0, 0.0
for name, (busy, total, wait) in cur["cpu"].items():
p_busy, p_total, p_wait = prev["cpu"].get(name, (busy, total, wait))
pct = _pct(busy - p_busy, total - p_total)
if name == "cpu":
cpu_total = pct
iowait = _pct(wait - p_wait, total - p_total)
else:
cores.append(pct)
net, rx_sum, tx_sum = [], 0, 0
for name, (rx, tx) in sorted(cur["net"].items()):
# veth*: eine pro Container mit Zufallsnamen – nur Rauschen in der Liste
if name == "lo" or name.startswith("veth"):
continue
p_rx, p_tx = prev["net"].get(name, (rx, tx))
rx_rate, tx_rate = _rate(rx, p_rx, seconds), _rate(tx, p_tx, seconds)
virtual = bool(VIRTUAL_NET.match(name))
if not virtual:
rx_sum += rx_rate
tx_sum += tx_rate
net.append({
"interface": name,
"virtual": virtual,
"rxBytesPerSec": rx_rate,
"txBytesPerSec": tx_rate,
"rxBytesTotal": rx,
"txBytesTotal": tx,
})
load = _read(f"{HOST_PROC}/loadavg").split()
stat = _read(f"{HOST_PROC}/stat")
running = re.search(r"^procs_running (\d+)", stat, re.M)
uptime = float(_read(f"{HOST_PROC}/uptime").split()[0])
return {
"timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
"intervalSeconds": interval,
"host": {
"hostname": _hostname(),
"os": _os_name(),
"kernel": _read_or(f"{HOST_PROC}/sys/kernel/osrelease"),
"uptimeSeconds": int(uptime),
},
"cpu": {
"model": _cpu_model(),
"threads": len(cores), # logische CPUs (mit Hyperthreading)
"percent": cpu_total,
"iowaitPercent": iowait,
"perCorePercent": cores,
"mhz": _cpu_mhz(),
"load": {"1m": float(load[0]), "5m": float(load[1]), "15m": float(load[2])},
},
**_meminfo(),
"disks": _disks(),
"diskIo": {
"readBytesPerSec": _rate(cur["disk"][0], prev["disk"][0], seconds),
"writeBytesPerSec": _rate(cur["disk"][1], prev["disk"][1], seconds),
},
"network": {"rxBytesPerSec": rx_sum, "txBytesPerSec": tx_sum, "interfaces": net},
"temperatures": _temperatures(),
"processes": {
"total": sum(1 for p in os.listdir(HOST_PROC) if p.isdigit()),
"running": int(running[1]) if running else None,
},
}
def history_point(snap: dict) -> dict:
"""Kurzform für den Verlauf (Diagramme)."""
return {
"timestamp": snap["timestamp"],
"cpuPercent": snap["cpu"]["percent"],
"memoryPercent": snap["memory"]["percent"],
"swapPercent": snap["swap"]["percent"],
"tempMaxCelsius": max((t["celsius"] for t in snap["temperatures"]), default=None),
"load1m": snap["cpu"]["load"]["1m"],
"rxBytesPerSec": snap["network"]["rxBytesPerSec"],
"txBytesPerSec": snap["network"]["txBytesPerSec"],
"readBytesPerSec": snap["diskIo"]["readBytesPerSec"],
"writeBytesPerSec": snap["diskIo"]["writeBytesPerSec"],
}