"""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"], "load1m": snap["cpu"]["load"]["1m"], "rxBytesPerSec": snap["network"]["rxBytesPerSec"], "txBytesPerSec": snap["network"]["txBytesPerSec"], "readBytesPerSec": snap["diskIo"]["readBytesPerSec"], "writeBytesPerSec": snap["diskIo"]["writeBytesPerSec"], }