diff --git a/backend/kern/prognose.py b/backend/kern/prognose.py new file mode 100644 index 0000000..b748d1d --- /dev/null +++ b/backend/kern/prognose.py @@ -0,0 +1,158 @@ +"""Füllstands-Vorhersage „voll in etwa N Tagen“ (Ausbauplan Welle 1, seit 25.09.2026). + +Die Schwellen bei 80 und 90 % melden sich erst, wenn es schon eng ist. Die Vorhersage schaut auf das Tempo: Wächst +eine Belegung weiter wie zuletzt, wann ist sie voll? Gelb unter GELB_TAGE, rot unter ROT_TAGE Tagen — so bleibt Zeit, +Platz zu schaffen, bevor etwas stillsteht. + +Grundlage sind die 30-min-Mittel der letzten sieben Tage aus kern/messreihen.py (Belegung in %). Daraus werden +Tagesmittel über 24-Stunden-Fenster (das jüngste endet jetzt); ein Fenster zählt ab sechs Stunden mit Messungen. +Die Rate in Prozentpunkten am Tag: + • Normalfall: die Steigung der Ausgleichsgeraden durch die Tagesmittel der Woche. + • Das Wachstum stockt — der letzte Tag wuchs um weniger als ein Viertel der Wochenrate, etwa nach einem großen + Download, der einmal kam: Dann gilt der letzte Tag (mindestens 0). Ein einzelner Sprung hält so keinen Alarm. + • Es wird schneller — die letzten beiden Tage wuchsen beide stärker als die Woche, und in der Woche wurde nichts + freigeräumt: Dann gilt ihr Mittel. Ein Protokoll, das gerade vollläuft, wartet so nicht eine Woche. +Unter MIN_RATE gilt die Belegung als gleichbleibend. Ohne die beiden jüngsten Fenster und ein drittes (also rund zwei +Tage Verlauf) oder ohne Messung in den letzten zwei Stunden gibt es keine Vorhersage. + +„Voll“ ist nicht überall 100 %: Proxmox rechnet bei Containern und beim Host die für root reservierten Blöcke von +ext4 (5 %) als belegt — dort ist für Dienste schon bei rund 95 % Schluss (AdGuard am 24.09.2026). Das gibt der +Aufrufer mit (voll_bei). +""" + +import math +import threading +import time + +from kern import messreihen + +TAG_S = 86400 +FENSTER = 7 # Tagesfenster in der Woche +MIN_WERTE = 12 # 30-min-Mittel je Fenster: sechs Stunden mit Messungen +FRISCH_S = 2 * 3600 # so alt darf die jüngste Messung höchstens sein +MIN_RATE = 0.05 # Prozentpunkte am Tag; darunter „gleichbleibend“ +STOCKT = 0.25 # der letzte Tag wuchs um weniger als dieser Anteil der Wochenrate +FREIGERAEUMT = -0.5 # ein Tag, der um mehr als so viele Prozentpunkte sank, hat freigeräumt +GELB_TAGE, ROT_TAGE = 14, 3 +MERKEN_S = 600 # je Reihe höchstens alle zehn Minuten neu rechnen (der Wächter fragt jede Minute) +VOLL_EXT4 = 95.0 # Container und Proxmox-Host: siehe oben + +_lock = threading.Lock() +_merk: dict[tuple[str, str, float], tuple[float, dict]] = {} + + +def tagesmittel(reihe: list, jetzt: float) -> dict[int, float]: + """Fenster k (0 = die letzten 24 Stunden, 1 = die 24 davor …) → Mittel der Werte darin. Nur Fenster mit + mindestens MIN_WERTE Werten. reihe: [[t, wert], …] wie messreihen.lesen() sie liefert.""" + summen: dict[int, list[float]] = {} + for t, wert in reihe: + if not messreihen.ist_zahl(wert): + continue + k = int((jetzt - t) // TAG_S) + if 0 <= k < FENSTER: + summe = summen.setdefault(k, [0.0, 0]) + summe[0] += wert + summe[1] += 1 + return {k: s[0] / s[1] for k, s in summen.items() if s[1] >= MIN_WERTE} + + +def _steigung(punkte: list[tuple[float, float]]) -> float: + """Steigung der Ausgleichsgeraden (kleinste Quadrate).""" + mx = sum(x for x, _ in punkte) / len(punkte) + my = sum(y for _, y in punkte) / len(punkte) + sxx = sum((x - mx) ** 2 for x, _ in punkte) + return sum((x - mx) * (y - my) for x, y in punkte) / sxx if sxx else 0.0 + + +def vorhersage(reihe: list, jetzt: float, voll_bei: float = 100.0) -> dict: + """{"tage": Tage bis voll oder None, "rate": Prozentpunkte am Tag oder None, "anteil": jüngstes Mittel in % oder + None, "grund": None | "verlauf" (zu wenig Verlauf) | "gleich" (wächst nicht)}.""" + werte = [(t, w) for t, w in reihe if messreihen.ist_zahl(w)] + if not werte or jetzt - werte[-1][0] > FRISCH_S: + return {"tage": None, "rate": None, "anteil": None, "grund": "verlauf"} + anteil = float(werte[-1][1]) + mittel = tagesmittel(werte, jetzt) + if 0 not in mittel or 1 not in mittel or len(mittel) < 3: + return {"tage": None, "rate": None, "anteil": anteil, "grund": "verlauf"} + woche = _steigung([(-k, m) for k, m in mittel.items()]) + zuletzt = mittel[0] - mittel[1] + davor = mittel[1] - mittel[2] if 2 in mittel else None + schritte = [mittel[k] - mittel[k + 1] for k in range(FENSTER - 1) if k in mittel and k + 1 in mittel] + rate = woche + if zuletzt < STOCKT * woche: + rate = max(zuletzt, 0.0) + elif davor is not None and zuletzt > woche and davor > woche and min(schritte) > FREIGERAEUMT: + rate = (zuletzt + davor) / 2 + if rate < MIN_RATE: + return {"tage": None, "rate": round(rate, 2), "anteil": anteil, "grund": "gleich"} + return {"tage": round(max(0.0, (voll_bei - anteil) / rate), 1), "rate": round(rate, 2), "anteil": anteil, + "grund": None} + + +def aus_messreihe(quelle: str, name: str, voll_bei: float = 100.0, jetzt: float | None = None) -> dict: + """Die Vorhersage für eine Reihe aus kern/messreihen.py. Ohne jetzt (Betrieb) je Reihe höchstens alle MERKEN_S + neu gerechnet; mit jetzt (Tests) immer frisch.""" + betrieb = jetzt is None + jetzt = time.time() if jetzt is None else jetzt + schluessel = (quelle, name, voll_bei) + if betrieb: + with _lock: + gemerkt = _merk.get(schluessel) + if gemerkt and 0 <= jetzt - gemerkt[0] < MERKEN_S: + return gemerkt[1] + ergebnis = vorhersage(messreihen.lesen(quelle, "7d", [name], jetzt)["reihen"][name], jetzt, voll_bei) + if betrieb: + with _lock: + _merk[schluessel] = (jetzt, ergebnis) + return ergebnis + + +def stufe(v: dict | None) -> str: + """„rot“ unter ROT_TAGE, „gelb“ unter GELB_TAGE Tagen bis voll, sonst „ok“.""" + tage = (v or {}).get("tage") + if tage is None: + return "ok" + return "rot" if tage < ROT_TAGE else "gelb" if tage < GELB_TAGE else "ok" + + +def in_tagen(tage: float) -> str: + """„in weniger als einem Tag“, „in etwa 9 Tagen“, „in etwa 5 Wochen“ … — für Hinweise.""" + if tage < 1: + return "in weniger als einem Tag" + if tage < 1.5: + return "in etwa einem Tag" + if tage < 21: + return f"in etwa {round(tage)} Tagen" + if tage < 90: + return f"in etwa {round(tage / 7)} Wochen" + return f"in etwa {round(tage / 30)} Monaten" if tage < 365 else "frühestens in einem Jahr" + + +def tempo(v: dict) -> str: + """„Die Belegung wächst um etwa 1,2 % am Tag (jetzt 78 %).“""" + rate = f"{v['rate']:.1f}".replace(".", ",") + return f"Die Belegung wächst um etwa {rate} % am Tag (jetzt {round(v['anteil'])} %)." + + +def bis_voll(v: dict | None) -> float: + """Tage bis voll; unendlich, wenn es keine Vorhersage gibt.""" + return v["tage"] if v and v.get("tage") is not None else math.inf + + +IM_TEXT_TAGE = 60 # weiter weg sagt ein Hinweis zum Füllstand nicht mehr dazu, wann es voll wäre + + +def dazu(v: dict | None) -> str: + """„ Bei diesem Tempo voll in etwa 9 Tagen.“ — der Zusatz für Hinweise, die schon der Füllstand auslöst.""" + tage = bis_voll(v) + return f" Bei diesem Tempo voll {in_tagen(tage)}." if tage < IM_TEXT_TAGE else "" + + +RANG = {"ok": 0, "gelb": 1, "rot": 2} + + +def schlimmer(stufe_jetzt: str | None, v: dict | None) -> tuple[str, bool]: + """Die ernstere Stufe aus Füllstand (None/„ok“ = unauffällig) und Vorhersage — und ob die Vorhersage sie bestimmt + (dann gehört „voll in etwa N Tagen“ in den Titel).""" + jetzt, kuenftig = stufe_jetzt or "ok", stufe(v) + return (kuenftig, True) if RANG[kuenftig] > RANG.get(jetzt, 0) else (jetzt, False) diff --git a/backend/routers/boxwart.py b/backend/routers/boxwart.py index 497c221..7ba376d 100644 --- a/backend/routers/boxwart.py +++ b/backend/routers/boxwart.py @@ -22,6 +22,7 @@ from datetime import datetime import psutil from config import MODELS_DIR from fastapi import APIRouter, HTTPException +from kern import prognose from kern.zeit import LOCAL_TZ from pydantic import BaseModel from services import ( @@ -29,6 +30,7 @@ from services import ( backup, box_updates, llamaswap, + messwerte, modell_nutzung, system, update_verlauf, @@ -116,7 +118,9 @@ def _box() -> dict: p = system.metrik_punkt() try: du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".") - platte = {"used": du.used, "total": du.total, "percent": du.percent} + # Seit 25.09.2026 mit „voll in etwa N Tagen“ aus dem Verlauf der Woche (kern/prognose.py). + platte = {"used": du.used, "total": du.total, "percent": du.percent, + "vorhersage": prognose.aus_messreihe(messwerte.QUELLE, "platte")} except Exception: platte = None return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"), diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index d6acf9f..fb1ccef 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -247,7 +247,9 @@ def auftrag_abholen() -> dict: @router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)]) def bericht(daten: dict) -> dict: + from services.homelab import speicher kanal.bericht_speichern(daten) + speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026) return {"ok": True} diff --git a/backend/services/homelab/messwerte.py b/backend/services/homelab/messwerte.py index 714aa93..cf279ee 100644 --- a/backend/services/homelab/messwerte.py +++ b/backend/services/homelab/messwerte.py @@ -24,7 +24,7 @@ import time from kern import messreihen -from services.homelab import apps, inventar, kanal +from services.homelab import apps, inventar, kanal, speicher HOST = "pve" _GAST = re.compile(r"(ct|vm)-\d+") @@ -199,18 +199,20 @@ def _geraete(von: float, bis: float) -> list[dict]: def abfrage(zeitraum: str, jetzt: float | None = None) -> dict: """Für GET /api/homelab/messwerte: {"zeitraum", "schritt_s", "geraete": [{"id", "name", "art", "reihen", - "aktuell"}]}. temp_cpu gibt es als Reihe nur beim Host; in „aktuell“ stehen temp_cpu und load1 bei Gästen auf null.""" + "aktuell", "prognose"}]}. temp_cpu gibt es als Reihe nur beim Host; in „aktuell“ stehen temp_cpu und load1 bei + Gästen auf null. prognose.platte: „voll in etwa N Tagen“ (kern/prognose.py, seit 25.09.2026).""" dauer, schritt = messreihen.zeitraum_pruefen(zeitraum) - jetzt = time.time() if jetzt is None else jetzt + zeit = time.time() if jetzt is None else jetzt geraete = [] - for geraet in _geraete(jetzt - dauer, jetzt): + for geraet in _geraete(zeit - dauer, zeit): host = geraet["id"] == HOST - daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, jetzt) - letzter = messreihen.letzter_punkt(geraet["id"], jetzt, AKTUELL_MAX_S) or {} + daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, zeit) + letzter = messreihen.letzter_punkt(geraet["id"], zeit, AKTUELL_MAX_S) or {} aktuell = {name: letzter.get(name) for name in AKTUELL} if not host: aktuell["temp_cpu"] = aktuell["load1"] = None - geraete.append({**geraet, "reihen": daten["reihen"], "aktuell": aktuell}) + geraete.append({**geraet, "reihen": daten["reihen"], "aktuell": aktuell, + "prognose": {"platte": speicher.platten_vorhersage(geraet["id"], jetzt)}}) return {"zeitraum": zeitraum, "schritt_s": schritt, "geraete": geraete} diff --git a/backend/services/homelab/speicher.py b/backend/services/homelab/speicher.py index 57580d9..551d8af 100644 --- a/backend/services/homelab/speicher.py +++ b/backend/services/homelab/speicher.py @@ -11,20 +11,27 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu (täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar. • Systemplatte des Proxmox-Hosts (Stufen wie oben): Dort liegen die Kernel, die Sicherungen vor Updates (local) und alles vom Host selbst. Alte Kernel lassen sich per Knopf entfernen (Ausführer: kernel_aufraeumen). + • Tempo (seit 25.09.2026, kern/prognose.py): Läuft etwas in weniger als 14 Tagen voll, ist es gelb, unter 3 Tagen + rot — auch wenn der Füllstand selbst noch unauffällig ist. Den Verlauf dafür schreibt jeder Bericht in die + Messreihe VERLAUF (verlauf_merken); Host und Container haben ihn schon in den Minutenwerten. Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage). """ +import logging import re import socket import threading import time from datetime import datetime +from kern import messreihen, prognose from kern.zeit import LOCAL_TZ from services.homelab import apps, kanal from services.homelab.inventar import EIGENES_ETIKETT +log = logging.getLogger(__name__) + GELB, ROT = 0.80, 0.90 EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert TRIM_AB = 250_000_000 # ein Container zählt beim gemeinsamen TRIM erst ab hier mit @@ -320,60 +327,155 @@ def sicherungen(bericht: dict, jetzt: float) -> dict: "erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe} +# --- Vorhersage „voll in etwa N Tagen“ (seit 25.09.2026) -------------------------------------------------------- + +VERLAUF = "speicher" # Quelle in kern/messreihen.py: die Belegungen aus jedem Bericht (alle zehn Minuten) + + +def _prozent(belegt: object, gesamt: object) -> float | None: + if not (messreihen.ist_zahl(belegt) and messreihen.ist_zahl(gesamt)) or gesamt <= 0: + return None + return round(belegt / gesamt * 100, 2) + + +def verlauf_merken(bericht: dict, jetzt: float | None = None) -> None: + """Die Belegungen eines Berichts als ein Punkt der Messreihe VERLAUF (in %): je Speicherpool Daten + („pool:“) und Metadaten („pool-meta:“), je Netzlaufwerk („nas:“), je Sicherungsziel + („ziel:“) und die Platte jeder VM („vm-“ — die Minutenwerte kennen sie nicht). Ein kaputter Bericht + kostet nur diesen Punkt.""" + try: + host = bericht.get("host") if isinstance(bericht.get("host"), dict) else {} + werte: dict[str, float | None] = {} + for p in pools(bericht): + werte[f"pool:{p['speicher']}"] = round(p["anteil"] * 100, 2) + if messreihen.ist_zahl(p["meta_anteil"]): + werte[f"pool-meta:{p['speicher']}"] = round(p["meta_anteil"] * 100, 2) + for m in host.get("netzlaufwerke") or []: + platz = m.get("platz") or {} + werte[f"nas:{m.get('ziel')}"] = _prozent(platz.get("belegt"), platz.get("gesamt")) + for name, z in (host.get("sicherungsziele") or {}).items(): + werte[f"ziel:{name}"] = _prozent(z.get("belegt"), z.get("gesamt")) + for g in bericht.get("gaeste") or []: + if g.get("art") == "qemu": + platte = g.get("platte") or {} + werte[f"vm-{g['vmid']}"] = _prozent(platte.get("belegt"), platte.get("gesamt")) + werte = {name: wert for name, wert in werte.items() if wert is not None} + if werte: + messreihen.schreiben(VERLAUF, werte, jetzt) + except Exception: + log.warning("speicher: Verlauf der Belegungen nicht geschrieben", exc_info=True) + + +def platten_reihe(geraet_id: str) -> tuple[str, str, float] | None: + """Wo der Verlauf der Platte eines Geräts liegt: (Quelle, Name, voll bei %). Host und Container in den + Minutenwerten; VMs im VERLAUF, denn ihre Platte kennt nur der Bericht (über den Gast-Agenten).""" + if geraet_id == "pve": + return "pve", "platte", prognose.VOLL_EXT4 + if re.fullmatch(r"ct-\d+", geraet_id): + return geraet_id, "platte", prognose.VOLL_EXT4 + if re.fullmatch(r"vm-\d+", geraet_id): + return VERLAUF, geraet_id, prognose.VOLL_EXT4 + return None + + +def platten_vorhersage(geraet_id: str, jetzt: float | None = None) -> dict | None: + reihe = platten_reihe(geraet_id) + return prognose.aus_messreihe(*reihe, jetzt=jetzt) if reihe else None + + +def _vorhersage(name: str, jetzt: float | None) -> dict: + return prognose.aus_messreihe(VERLAUF, name, jetzt=jetzt) + + +def pool_vorhersage(speicher: str, jetzt: float | None = None) -> tuple[dict, bool]: + """Die Vorhersage eines Pools — Daten oder Metadaten, was früher voll ist — und ob es die Metadaten sind.""" + daten, meta = _vorhersage(f"pool:{speicher}", jetzt), _vorhersage(f"pool-meta:{speicher}", jetzt) + return (meta, True) if prognose.bis_voll(meta) < prognose.bis_voll(daten) else (daten, False) + + # --- Für Oberfläche und Wächter ---------------------------------------------------------------------------------- def lage(jetzt: float | None = None) -> dict: + """Für GET /api/homelab/speicher. Pools, Systemplatte, NAS und Sicherungsziele tragen seit 25.09.2026 je eine + „vorhersage“ (kern/prognose.vorhersage). jetzt nur für Tests: Dann wird die Vorhersage nicht gemerkt.""" b = _bericht() - jetzt = time.time() if jetzt is None else jetzt - return {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b), - "sicherungen": sicherungen(b, jetzt), "empfehlungen": empfehlungen(b)} + zeit = time.time() if jetzt is None else jetzt + ergebnis = {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b), + "sicherungen": sicherungen(b, zeit), "empfehlungen": empfehlungen(b)} + for p in ergebnis["pools"]: + p["vorhersage"] = pool_vorhersage(p["speicher"], jetzt)[0] + if ergebnis["systemplatte"]: + ergebnis["systemplatte"]["vorhersage"] = platten_vorhersage("pve", jetzt) + for n in ergebnis["nas"]: + n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt) + for z in ergebnis["sicherungen"]["ziele"]: + z["vorhersage"] = _vorhersage(f"ziel:{z['speicher']}", jetzt) + return ergebnis def pruefe_speicher(jetzt: float | None = None) -> list: - """Befunde für den Wächter. Ohne Bericht keine — das Schweigen des Ausführers meldet pruefe_ausfuehrer().""" + """Befunde für den Wächter. Ohne Bericht keine — das Schweigen des Ausführers meldet pruefe_ausfuehrer(). + Pool, Systemplatte und NAS melden sich auch nach dem Tempo (siehe oben); bestimmt die Vorhersage die Stufe, steht + „in etwa N Tagen voll“ im Titel, sonst hängt der Text sie an.""" from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier b = _bericht() if not b: return [] - jetzt = time.time() if jetzt is None else jetzt + zeit = time.time() if jetzt is None else jetzt befunde = [] tipps = empfehlungen(b) platz_tipp = "; ".join([e["kurz"] for e in tipps if e.get("ort") == "pool"][:3]) for p in pools(b): - if p["stufe"] == "ok": + v, meta = pool_vorhersage(p["speicher"], jetzt) + stufe, kommend = prognose.schlimmer(p["stufe"], v) + if stufe == "ok": continue - anteil = max(p["anteil"], p["meta_anteil"] or 0) - was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool" - befunde.append(Befund( - id=f"pool:{p['speicher']}", stufe=p["stufe"], - titel=f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll", - text=(f"Noch {_groesse(p['frei'])} frei. Läuft er voll, stehen alle Gäste darauf still. " - + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "") - + "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“."), - quelle="pve")) + rest = ("Läuft er voll, stehen alle Gäste darauf still. " + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "") + + "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“.") + if kommend: + was = "Metadaten des Speicherpools" if meta else "Speicherpool" + titel = f"{was} {p['speicher']} {prognose.in_tagen(v['tage'])} voll" + text = f"{prognose.tempo(v)} Noch {_groesse(p['frei'])} frei. {rest}" + else: + anteil = max(p["anteil"], p["meta_anteil"] or 0) + was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool" + titel = f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll" + text = f"Noch {_groesse(p['frei'])} frei.{prognose.dazu(v)} {rest}" + befunde.append(Befund(id=f"pool:{p['speicher']}", stufe=stufe, titel=titel, text=text, quelle="pve")) sp = systemplatte(b) - if sp and sp["stufe"] != "ok": + v = platten_vorhersage("pve", jetzt) if sp else None + stufe, kommend = prognose.schlimmer(sp["stufe"], v) if sp else ("ok", False) + if sp and stufe != "ok": kernel = next((e for e in tipps if e["art"] == "kernel"), None) befunde.append(Befund( - id="host-platte", stufe=sp["stufe"], - titel=f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll", - text=(f"Noch {_groesse(sp['frei'])} frei. Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. " + id="host-platte", stufe=stufe, + titel=(f"Systemplatte des Proxmox-Hosts {prognose.in_tagen(v['tage'])} voll" if kommend + else f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll"), + text=((f"{prognose.tempo(v)} " if kommend else "") + f"Noch {_groesse(sp['frei'])} frei." + + ("" if kommend else prognose.dazu(v)) + + " Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. " + (f"Platz bringt: {kernel['kurz']} (Knopf im Homelab-Cockpit unter „Speicher und Sicherungen“)." if kernel else "Alte Sicherungen unter /var/lib/vz/dump und ISO-Abbilder prüfen.")), quelle="pve")) for n in nas(b): - if n["stufe"] == "ok": + v = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt) + stufe, kommend = prognose.schlimmer(n["stufe"], v) + if stufe == "ok": continue + frei = _groesse((n["gesamt"] or 0) - (n["belegt"] or 0)) + platz = ("Die Sicherungen brauchen jede Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder " + "alte Dateien auf dem NAS aufräumen.") if n["problem"]: titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. " "Strom und Netz des NAS prüfen, dann in dessen Oberfläche nachsehen.") + elif kommend: + titel = f"NAS ({n['server']}) {prognose.in_tagen(v['tage'])} voll" + text = f"{prognose.tempo(v)} Noch {frei} frei. {platz}" else: titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll" - text = (f"Noch {_groesse((n['gesamt'] or 0) - (n['belegt'] or 0))} frei. Die Sicherungen brauchen jede " - "Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder alte Dateien auf dem NAS " - "aufräumen.") - befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=n["stufe"], titel=titel, text=text, quelle="nas")) - s = sicherungen(b, jetzt) + text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}" + befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas")) + s = sicherungen(b, zeit) if s["stufe"] in ("gelb", "rot"): teile = list(s["probleme"]) if s["fehlend"]: diff --git a/backend/services/messwerte.py b/backend/services/messwerte.py index f44376b..ebfea3f 100644 --- a/backend/services/messwerte.py +++ b/backend/services/messwerte.py @@ -25,7 +25,7 @@ from collections.abc import Callable import psutil from config import MODELS_DIR -from kern import messreihen +from kern import messreihen, prognose from services import system, token_stats @@ -176,9 +176,11 @@ ZEITRAEUME = messreihen.ZEITRAEUME def abfrage(zeitraum: str, jetzt: float | None = None) -> dict: - """Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}}. aktuell ist der letzte - Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null.""" - jetzt = time.time() if jetzt is None else jetzt - daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, jetzt) - letzter = messreihen.letzter_punkt(QUELLE, jetzt, AKTUELL_MAX_S) or {} - return {**daten, "aktuell": {name: letzter.get(name) for name in REIHEN}} + """Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}, "prognose": {"platte": …}}. + aktuell ist der letzte Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null; prognose + die Vorhersage „voll in etwa N Tagen“ des Modell-Laufwerks (kern/prognose.py, seit 25.09.2026).""" + zeit = time.time() if jetzt is None else jetzt + daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, zeit) + letzter = messreihen.letzter_punkt(QUELLE, zeit, AKTUELL_MAX_S) or {} + return {**daten, "aktuell": {name: letzter.get(name) for name in REIHEN}, + "prognose": {"platte": prognose.aus_messreihe(QUELLE, "platte", jetzt=jetzt)}} diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 60e0bae..1c99c65 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -9,7 +9,7 @@ gesehen. Dieser Wächter schaut deshalb breiter hin: • Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung) • Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log) • Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP - • Platte Füllstand des Modell-Laufwerks + • Platte Füllstand des Modell-Laufwerks, seit 25.09.2026 auch das Tempo („in etwa N Tagen voll“) • Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält • Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb) @@ -44,7 +44,7 @@ from pathlib import Path import httpx import psutil from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL -from kern import partner +from kern import partner, prognose from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ @@ -454,19 +454,29 @@ def pruefe_kern() -> list[Befund]: def pruefe_platte() -> list[Befund]: + """Füllstand der eigenen Platte (gelb ab DISK_GELB_PCT, rot ab DISK_ROT_PCT). Auf der KI-Box seit 25.09.2026 + auch das Tempo des Modell-Laufwerks (kern/prognose.py): in weniger als 14 Tagen voll = gelb, unter 3 Tagen rot — + als eigener Hinweis, wenn er ernster ist als der Füllstand.""" + befunde, stufe = [], "ok" try: pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent except Exception: - return [] - if pct >= DISK_ROT_PCT: - stufe = "rot" - elif pct >= DISK_GELB_PCT: - stufe = "gelb" - else: - return [] - return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll", - text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.", - quelle="platte", sofort=True)] + pct = None + if pct is not None and pct >= DISK_GELB_PCT: + stufe = "rot" if pct >= DISK_ROT_PCT else "gelb" + befunde.append(Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll", + text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.", + quelle="platte", sofort=True)) + if ROLLE == "box": + from services import messwerte + v = prognose.aus_messreihe(messwerte.QUELLE, "platte") + kuenftig, kommend = prognose.schlimmer(stufe, v) + if kommend: + befunde.append(Befund( + id="platte:tempo", stufe=kuenftig, titel=f"Die Platte ist {prognose.in_tagen(v['tage'])} voll", + text=f"{prognose.tempo(v)} Alte Modelldateien löschen hilft am meisten: KI-Box → Modelle → Aufräumen.", + quelle="platte")) + return befunde def pruefe_probe_wiederherstellung() -> list[Befund]: @@ -563,28 +573,38 @@ def _gb_komma(n: float) -> str: def pruefe_gast_platten() -> list[Befund]: """Homelab: Läuft die Platte eines Containers voll? Am 24.09.2026 stand AdGuard bei 100 %: Das Abfrageprotokoll - schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es.""" - from services.homelab import apps, kanal + schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es. Seit 25.09.2026 auch nach dem Tempo + (kern/prognose.py): in weniger als 14 Tagen voll = gelb, unter 3 Tagen rot.""" + from services.homelab import apps, kanal, speicher daten = kanal.bericht() befunde = [] for g in (daten or {}).get("bericht", {}).get("gaeste") or []: platte = g.get("platte") or {} gesamt, belegt = platte.get("gesamt"), platte.get("belegt") or 0 - if not gesamt or belegt / gesamt < GAST_PLATTE_GELB: + if not gesamt: continue - app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name")) - name = app.name if app else str(g.get("name") or g["vmid"]) anteil = belegt / gesamt # Seit 25.09.2026 kennt der Bericht auch die Platte der VMs (über den Gast-Agenten). vm = g.get("art") != "lxc" + zid = f"{'vm' if vm else 'ct'}-{g['vmid']}" + v = speicher.platten_vorhersage(zid) + jetzt_stufe = "rot" if anteil >= GAST_PLATTE_ROT else "gelb" if anteil >= GAST_PLATTE_GELB else "ok" + stufe, kommend = prognose.schlimmer(jetzt_stufe, v) + if stufe == "ok": + continue + app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name")) + name = app.name if app else str(g.get("name") or g["vmid"]) vergroessern = ("VM → Hardware → Festplatte → Aktion → Größe ändern, danach in der VM das Dateisystem " "mitwachsen lassen" if vm else "Container → Ressourcen → Laufwerk → Größe ändern") befunde.append(Befund( - id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb", - titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll", - text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']}). " - f"Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."), - quelle=f"{'vm' if vm else 'ct'}-{g['vmid']}")) + id=f"gast-platte:{g['vmid']}", stufe=stufe, + titel=(f"Platte von {name} {prognose.in_tagen(v['tage'])} voll" if kommend + else f"Platte von {name} zu {min(100, round(anteil * 100))} % voll"), + text=((f"{prognose.tempo(v)} " if kommend else "") + + f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']})." + + ("" if kommend else prognose.dazu(v)) + + f" Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."), + quelle=zid)) return befunde diff --git a/backend/tests/test_messwerte.py b/backend/tests/test_messwerte.py index 5bca3aa..3900072 100644 --- a/backend/tests/test_messwerte.py +++ b/backend/tests/test_messwerte.py @@ -168,6 +168,7 @@ def test_endpunkt_der_box(daten): assert all(len(reihe) == 288 for reihe in antwort["reihen"].values()) assert _werte(antwort["reihen"]["tokens"]) == [900] and _werte(antwort["reihen"]["cpu"]) == [12.5] assert antwort["aktuell"] == punkt + assert antwort["prognose"]["platte"]["grund"] == "verlauf" # „voll in etwa N Tagen“ erst ab zwei Tagen Verlauf stunde = client.get("/api/messwerte").json() # ohne Angabe: die letzte Stunde assert stunde["schritt_s"] == 60 and len(stunde["reihen"]["cpu"]) == 60 assert client.get("/api/messwerte", params={"zeitraum": "1w"}).status_code == 400 @@ -258,6 +259,7 @@ def test_homelab_endpunkte(daten): host = geraete["pve"] assert list(host["reihen"]) == ["cpu", "ram", "platte", "netz_rx", "netz_tx", "temp_cpu"] + assert host["prognose"]["platte"]["grund"] == "verlauf" and geraete["vm-106"]["prognose"]["platte"]["anteil"] is None assert all(len(reihe) == 60 for reihe in host["reihen"].values()) assert _werte(host["reihen"]["netz_rx"]) == [10000] # erste Minute ohne Vorgänger: keine Rate assert host["aktuell"] == {"cpu": 12.5, "ram": 37.5, "ram_used": 12_000_000_000, "ram_total": 32_000_000_000, diff --git a/backend/tests/test_prognose.py b/backend/tests/test_prognose.py new file mode 100644 index 0000000..d20c933 --- /dev/null +++ b/backend/tests/test_prognose.py @@ -0,0 +1,162 @@ +"""Füllstands-Vorhersage „voll in etwa N Tagen“ (kern/prognose.py, Ausbauplan Welle 1, 25.09.2026): die Rechnung auf +künstlichen Verläufen (stetig, ein Sprung, plötzlich schneller, zu wenig Verlauf) und wie Speicherkarte und Wächter sie +nutzen.""" + +from types import SimpleNamespace + +import pytest +from kern import einstellungen as einstellungen_mod +from kern import messreihen, prognose + +JETZT = 1_790_340_000.0 # 25.09.2026 ~14:40 +TAG = 86400 + + +def _reihe(wert_bei, tage: float = 7, alter_ab: float = 0) -> list[list]: + """30-min-Raster wie messreihen.lesen("7d"): [[t, wert], …], ältester zuerst; wert_bei(alter in Tagen).""" + schritte = int(tage * TAG // 1800) + return [[JETZT - alter_ab - i * 1800, wert_bei((alter_ab + i * 1800) / TAG)] for i in reversed(range(schritte))] + + +def test_stetiges_wachstum(): + v = prognose.vorhersage(_reihe(lambda alter: 57 - alter), JETZT) # 1 % am Tag, jetzt 57 % + assert v == {"tage": 43.0, "rate": 1.0, "anteil": 57.0, "grund": None} + assert prognose.stufe(v) == "ok" + assert prognose.vorhersage(_reihe(lambda alter: 57 - alter), JETZT, voll_bei=95)["tage"] == 38.0 + + +def test_ein_einmaliger_sprung_haelt_keinen_alarm(): + """Ein großer Download vor zweieinhalb Tagen, seitdem nichts: Die Woche steigt, der letzte Tag nicht.""" + v = prognose.vorhersage(_reihe(lambda alter: 50 if alter < 2.5 else 40), JETZT) + assert (v["grund"], v["tage"], v["rate"]) == ("gleich", None, 0.0) + + +def test_ploetzlich_schneller_wartet_nicht_eine_woche(): + """Fünf Tage gleich, dann 5 % am Tag: Die Woche allein sähe rund 1 % am Tag (voll in 30 Tagen).""" + v = prognose.vorhersage(_reihe(lambda alter: 60 + 5 * max(0.0, 2 - alter)), JETZT) + assert v["rate"] == pytest.approx(3.75, abs=0.05) and v["anteil"] == 70.0 and v["tage"] == pytest.approx(8, abs=0.2) + assert prognose.stufe(v) == "gelb" + + +def test_freigeraeumt_in_der_woche_zaehlt_die_woche(): + """Sicherungsziel: jeden Tag +1 %, vor dreieinhalb Tagen hat das Aufräumen 6 % freigegeben — netto kaum Zuwachs. + Die letzten beiden Tage allein (je +1 %) sollen nicht „schneller“ heißen.""" + v = prognose.vorhersage(_reihe(lambda alter: 80 - alter - (0 if alter < 3.5 else -6)), JETZT) + assert v["rate"] < 1.0 + + +def test_ohne_genug_verlauf_keine_vorhersage(): + assert prognose.vorhersage([], JETZT)["grund"] == "verlauf" + kurz = prognose.vorhersage(_reihe(lambda alter: 50 - alter, tage=1.5), JETZT) + assert (kurz["grund"], kurz["anteil"]) == ("verlauf", 50.0) + alt = prognose.vorhersage(_reihe(lambda alter: 50 - alter, alter_ab=3 * 3600), JETZT) # seit 3 h nichts + assert (alt["grund"], alt["anteil"]) == ("verlauf", None) + luecken = [[t, w if i % 5 == 0 else None] for i, (t, w) in enumerate(_reihe(lambda alter: 50 - alter))] + assert prognose.vorhersage(luecken, JETZT)["grund"] == "verlauf" # je Tag keine sechs Stunden mit Messungen + + +def test_texte_und_stufen(): + assert [prognose.in_tagen(t) for t in (0.4, 1.2, 9.4, 35, 200, 400)] == [ + "in weniger als einem Tag", "in etwa einem Tag", "in etwa 9 Tagen", "in etwa 5 Wochen", "in etwa 7 Monaten", + "frühestens in einem Jahr"] + v = {"tage": 2.5, "rate": 1.25, "anteil": 91.9, "grund": None} + assert prognose.tempo(v) == "Die Belegung wächst um etwa 1,2 % am Tag (jetzt 92 %)." + assert prognose.schlimmer("gelb", v) == ("rot", True) and prognose.schlimmer("rot", v) == ("rot", False) + assert prognose.schlimmer(None, {"tage": None}) == ("ok", False) + assert prognose.dazu({"tage": 30}) == " Bei diesem Tempo voll in etwa 4 Wochen." and prognose.dazu(None) == "" + assert prognose.dazu({"tage": 80}) == "" + + +# --- Mit echten Dateien: Verlauf aus den Berichten ------------------------------------------------------------- + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + einstellungen_mod.einstellungen.cache_clear() + yield tmp_path + einstellungen_mod.einstellungen.cache_clear() + + +def _bericht(pool: float, vm: float) -> dict: + return {"host": {"pools": [{"speicher": "local-lvm", "belegt": int(pool * 1000), "gesamt": 1000, "meta_anteil": 0.01}], + "netzlaufwerke": [{"ziel": "/mnt/qnap-backup", "platz": {"belegt": 400, "gesamt": 1000}}, + {"ziel": "/mnt/weg", "zustand": "haengt", "platz": None}], + "sicherungsziele": {"pbs-qnap": {"belegt": 400, "gesamt": 1000}}}, + "gaeste": [{"vmid": 106, "art": "qemu", "platte": {"belegt": int(vm * 1000), "gesamt": 1000}}, + {"vmid": 102, "art": "lxc", "platte": {"belegt": 1, "gesamt": 4}}]} + + +def test_berichte_schreiben_den_verlauf_und_die_vorhersage_liest_ihn(daten): + from services.homelab import speicher + for stunde in range(3 * 24, -1, -1): # drei Tage lang stündlich ein Bericht + tage = stunde / 24 + speicher.verlauf_merken(_bericht(pool=0.70 - 0.02 * tage, vm=0.5), JETZT - stunde * 3600) + letzte = messreihen.letzter_punkt(speicher.VERLAUF, JETZT) + assert {k: v for k, v in letzte.items() if k != "t"} == { + "pool:local-lvm": 70.0, "pool-meta:local-lvm": 1.0, "nas:/mnt/qnap-backup": 40.0, "ziel:pbs-qnap": 40.0, + "vm-106": 50.0} + v, meta = speicher.pool_vorhersage("local-lvm", JETZT) + assert meta is False and v["rate"] == pytest.approx(2.0, abs=0.05) and v["tage"] == pytest.approx(15, abs=0.5) + assert speicher.platten_vorhersage("vm-106", JETZT)["grund"] == "gleich" + assert speicher.platten_vorhersage("ct-102", JETZT)["grund"] == "verlauf" # Minutenwerte fehlen hier + assert speicher.platten_reihe("pve") == ("pve", "platte", 95.0) and speicher.platten_reihe("nas") is None + speicher.verlauf_merken({"host": "kaputt"}, JETZT) # kostet nur diesen Punkt + + +def test_speicherkarte_und_waechter_mit_vorhersage(monkeypatch): + from services.homelab import speicher + from test_homelab_speicher import _bericht as echter_bericht + monkeypatch.setattr(speicher, "antwortet", lambda s, p: True) + monkeypatch.setattr(speicher, "_bericht", lambda: echter_bericht()) + bald = {"tage": 5.2, "rate": 2.0, "anteil": 60.0, "grund": None} + spaeter = {"tage": 30.0, "rate": 0.3, "anteil": 91.0, "grund": None} + kurven = {"pool:local-lvm": bald, "nas:/mnt/qnap-backup": spaeter, "ziel:pbs-qnap": spaeter} + monkeypatch.setattr(speicher, "_vorhersage", lambda name, jetzt: kurven.get(name, {"tage": None, "grund": "verlauf"})) + monkeypatch.setattr(speicher, "platten_vorhersage", lambda gid, jetzt=None: {"tage": 2.0, "rate": 5.0, "anteil": 85.0}) + + lage = speicher.lage() + assert lage["pools"][0]["vorhersage"] == bald and lage["nas"][0]["vorhersage"] == spaeter + assert lage["systemplatte"] is None and lage["sicherungen"]["ziele"][0]["vorhersage"] == spaeter + + befunde = {b.id: b for b in speicher.pruefe_speicher(1_790_330_000.0)} + pool = befunde["pool:local-lvm"] + assert (pool.stufe, pool.titel) == ("gelb", "Speicherpool local-lvm in etwa 5 Tagen voll") + assert pool.text.startswith("Die Belegung wächst um etwa 2,0 % am Tag (jetzt 60 %). Noch ") + assert "nas:/mnt/qnap-backup" not in befunde # 39 % und 30 Tage: nichts zu melden + + # Der Füllstand löst aus, die Vorhersage steht dabei; die Systemplatte läuft in zwei Tagen voll (rot). + b = echter_bericht(nas_anteil=0.91) + b["host"]["systemplatte"] = {"belegt": 40, "gesamt": 100} + monkeypatch.setattr(speicher, "_bericht", lambda: b) + befunde = {x.id: x for x in speicher.pruefe_speicher(1_790_330_000.0)} + nas = befunde["nas:/mnt/qnap-backup"] + assert (nas.stufe, nas.titel) == ("rot", "NAS (192.168.178.62) zu 91 % voll") + assert "frei. Bei diesem Tempo voll in etwa 4 Wochen. Die Sicherungen" in nas.text + host = befunde["host-platte"] + assert (host.stufe, host.titel) == ("rot", "Systemplatte des Proxmox-Hosts in etwa 2 Tagen voll") + + +def test_waechter_gastplatten_und_box_platte(monkeypatch): + from services import waechter + from services.homelab import kanal, speicher + bericht = {"gaeste": [{"vmid": 101, "art": "lxc", "name": "npmplus", "platte": {"belegt": 1, "gesamt": 4}}, + {"vmid": 106, "art": "qemu", "name": "arcane", "platte": {"belegt": 90, "gesamt": 100}}]} + monkeypatch.setattr(kanal, "bericht", lambda: {"bericht": bericht}) + kurven = {"ct-101": {"tage": 1.2, "rate": 60.0, "anteil": 25.0}, "vm-106": {"tage": 40.0, "rate": 0.1, "anteil": 90.0}} + monkeypatch.setattr(speicher, "platten_vorhersage", lambda gid, jetzt=None: kurven[gid]) + befunde = {b.id: b for b in waechter.pruefe_gast_platten()} + ct = befunde["gast-platte:101"] + assert (ct.stufe, ct.quelle) == ("rot", "ct-101") and ct.titel.endswith(" in etwa einem Tag voll") + assert ct.text.startswith("Die Belegung wächst um etwa 60,0 % am Tag (jetzt 25 %). 0,0 von 0,0 GB belegt") + vm = befunde["gast-platte:106"] + assert vm.stufe == "rot" and vm.titel.endswith(" zu 90 % voll") and "(VM 106). Bei diesem Tempo voll in etwa 6 Wochen. Platz" in vm.text + + # KI-Box: Füllstand unauffällig, aber in 10 Tagen voll → eigener gelber Hinweis. + monkeypatch.setattr(waechter, "ROLLE", "box") + monkeypatch.setattr(waechter.psutil, "disk_usage", lambda pfad: SimpleNamespace(percent=62.0)) + monkeypatch.setattr(waechter.prognose, "aus_messreihe", + lambda quelle, name, voll_bei=100.0, jetzt=None: {"tage": 10.4, "rate": 3.6, "anteil": 62.0}) + (tempo,) = waechter.pruefe_platte() + assert (tempo.id, tempo.stufe, tempo.titel) == ("platte:tempo", "gelb", "Die Platte ist in etwa 10 Tagen voll") + monkeypatch.setattr(waechter.psutil, "disk_usage", lambda pfad: SimpleNamespace(percent=85.0)) + assert [b.id for b in waechter.pruefe_platte()] == ["platte"] # gelb ist gelb: kein zweiter Hinweis diff --git a/frontend/src/components/cockpit/Instrument.tsx b/frontend/src/components/cockpit/Instrument.tsx index 1712b7e..f3abf79 100644 --- a/frontend/src/components/cockpit/Instrument.tsx +++ b/frontend/src/components/cockpit/Instrument.tsx @@ -15,6 +15,7 @@ export function Instrument({ unter, beschriftung, ariaText, + hinweis, }: { anteil: number | null farbe?: string @@ -23,6 +24,8 @@ export function Instrument({ unter?: string beschriftung: string ariaText: string + /** Eine Zeile unter der Beschriftung, etwa „voll in etwa 9 Tagen“ (seit 25.09.2026); className setzt die Farbe. */ + hinweis?: { text: string; className: string } | null }) { const zeigen = anteil != null && anteil > 0.004 return ( @@ -46,6 +49,7 @@ export function Instrument({ )}
{beschriftung}
+ {hinweis && {hinweis.text}} ) } diff --git a/frontend/src/components/diagramme/Messring.tsx b/frontend/src/components/diagramme/Messring.tsx index 5138a01..5b3e117 100644 --- a/frontend/src/components/diagramme/Messring.tsx +++ b/frontend/src/components/diagramme/Messring.tsx @@ -2,6 +2,7 @@ import type { CSSProperties } from "react" import { cn } from "cn" import type { Bereich } from "@/lib/navigation" import { type Einheit, wertText } from "@/lib/messung" +import { STUFE_FARBE, type VorhersageStufe } from "@/lib/prognose" import type { Messpunkt } from "@/lib/typen" import { Sparkline } from "./Sparkline" @@ -26,7 +27,9 @@ function randpunkt(anteil: number, r: number) { return { x: MITTE + r * Math.cos(winkel), y: MITTE + r * Math.sin(winkel) } } -export function Messring({ label, wert, einheit, punkte, zustand = "ruhig", bereich, grenzen, max = 100, zeitraumText, i = 0 }: { +export function Messring({ + label, wert, einheit, punkte, zustand = "ruhig", bereich, grenzen, max = 100, zeitraumText, i = 0, hinweis, +}: { label: string wert: number | null | undefined einheit: Einheit @@ -39,6 +42,8 @@ export function Messring({ label, wert, einheit, punkte, zustand = "ruhig", bere zeitraumText: string /** Reihenfolge für das Auftauchen. */ i?: number + /** Eine Zeile unter der Linie, etwa „voll in etwa 9 Tagen“ (seit 25.09.2026). */ + hinweis?: { text: string; stufe: VorhersageStufe } | null }) { const z = ZUSTAND[zustand] const farbe = zustand === "rot" ? "var(--rot)" : zustand === "gelb" ? "var(--bernstein)" : bereich === "box" ? "var(--box)" : "var(--lab)" @@ -77,6 +82,7 @@ export function Messring({ label, wert, einheit, punkte, zustand = "ruhig", bere ? : } + {hinweis && {hinweis.text}} ) } diff --git a/frontend/src/components/homelab/Gesundheit.tsx b/frontend/src/components/homelab/Gesundheit.tsx index 65f80d3..0de0767 100644 --- a/frontend/src/components/homelab/Gesundheit.tsx +++ b/frontend/src/components/homelab/Gesundheit.tsx @@ -4,6 +4,7 @@ import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol" import { geraetebild } from "@/lib/geraetebild" import { artText, plattenAnteil } from "@/lib/homelab" import { laufzeit } from "@/lib/messung" +import { knappeVorhersage, STUFE_FARBE } from "@/lib/prognose" import type { GeraetMesswerte, ZielStand } from "@/lib/typen" const SPALTEN = "md:grid-cols-[minmax(0,1.35fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]" @@ -11,8 +12,14 @@ const SPALTEN = "md:grid-cols-[minmax(0,1.35fr)_110px_minmax(0,1fr)_minmax(0,1.2 const gb = (b: number) => (b / 1e9).toFixed(1).replace(".", ",") /** Balken mit Wert (v5): ruhig in der Farbe des Homelabs, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich - * in Zahlen. */ -function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) { + * in Zahlen. Darunter auf Wunsch eine Zeile (bei der Platte: „voll in etwa 9 Tagen“, seit 25.09.2026). */ +function Balken({ wert, gelb, rot, zusatz, unter }: { + wert: number | null | undefined + gelb: number + rot: number + zusatz?: string + unter?: { text: string; stufe: keyof typeof STUFE_FARBE } | null +}) { if (wert === null || wert === undefined) return – const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "color-mix(in srgb, var(--lab) 80%, transparent)" const text = wert >= rot ? "text-rot-text" : wert >= gelb ? "text-bernstein" : "text-text-2" @@ -24,6 +31,7 @@ function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; + {unter && {unter.text}} ) } @@ -43,6 +51,7 @@ function rang(z: ZielStand): number { */ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) { const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell])) + const prognose = new Map((messwerte ?? []).map((g) => [g.id, g.prognose?.platte])) const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b)) return (
@@ -77,7 +86,7 @@ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte CPU Speicher - Platte + Platte Läuft seit {laufzeit(a?.uptime_s)}
) diff --git a/frontend/src/components/homelab/Speicher.test.tsx b/frontend/src/components/homelab/Speicher.test.tsx index 7e90a92..b1184a4 100644 --- a/frontend/src/components/homelab/Speicher.test.tsx +++ b/frontend/src/components/homelab/Speicher.test.tsx @@ -35,6 +35,21 @@ describe("Speicher", () => { expect(screen.getByText(/Alle 8 Geräte gesichert/)).toBeInTheDocument() }) + it("sagt, wann es bei diesem Tempo voll ist (seit 25.09.2026)", () => { + render() + const pool = screen.getByText("+5,4 % am Tag · voll in etwa 9 Tagen") + expect(pool).toHaveClass("text-bernstein") + expect(screen.getByText("Belegung gleichbleibend")).toBeInTheDocument() + expect(screen.getByText("Ziel pbs-qnap: 39 % belegt · voll in etwa 7 Monaten")).toHaveClass("text-text-3") + }) + it("klappt die Vorschläge erst auf, wenn der Pool knapp wird", () => { const { container, rerender } = render() const klappe = container.querySelector("details")! diff --git a/frontend/src/components/homelab/Speicher.tsx b/frontend/src/components/homelab/Speicher.tsx index b04b1a3..c615918 100644 --- a/frontend/src/components/homelab/Speicher.tsx +++ b/frontend/src/components/homelab/Speicher.tsx @@ -4,9 +4,11 @@ import { cn } from "cn" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Chip, type ChipArt } from "@/components/cockpit/Chip" import { Button } from "@/components/ui/button" +import { STUFE_FARBE, tempoText, vollText, vorhersageStufe } from "@/lib/prognose" import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher" import type { - HomelabSpeicher, NasStand, SicherungsLage, SpeicherEmpfehlung, SpeicherPool, SpeicherStufe, Systemplatte, ZielAktion, + HomelabSpeicher, NasStand, SicherungsLage, SpeicherEmpfehlung, SpeicherPool, SpeicherStufe, Systemplatte, Vorhersage, + ZielAktion, } from "@/lib/typen" import { flugplanZeit } from "@/lib/zeit" @@ -35,6 +37,15 @@ function Fuellstand({ anteil, stufe, name }: { anteil: number; stufe: SpeicherSt ) } +/** Das Tempo der Belegung (seit 25.09.2026): „+1,2 % am Tag · voll in etwa 9 Tagen“, unter 14 Tagen bernstein, unter + * 3 Tagen rot. Ohne zwei Tage Verlauf steht nichts da. */ +function Tempo({ v }: { v?: Vorhersage | null }) { + if (v?.grund === "gleich") return

Belegung gleichbleibend

+ const voll = vollText(v) + if (!voll) return null + return

{tempoText(v)} · {voll}

+} + function Kachel({ titel, unter, chip, chipText, children }: { titel: string unter?: string @@ -67,6 +78,7 @@ function PoolKachel({ p }: { p: SpeicherPool }) { {p.meta_anteil !== null && (

Metadaten {Math.round(p.meta_anteil * 100)} % belegt

)} + ) } @@ -78,6 +90,7 @@ function SystemplatteKachel({ s }: { s: Systemplatte }) {

{Math.round(s.anteil * 100)} % · {groesse(s.belegt)} von {groesse(s.gesamt)} · {groesse(s.frei)} frei

+ ) } @@ -100,6 +113,7 @@ function NasKachel({ n }: { n: NasStand }) {

{Math.round(n.anteil * 100)} % · {groesse(n.belegt)} von {groesse(n.gesamt)} · {groesse((n.gesamt ?? 0) - (n.belegt ?? 0))} frei

+ ) )} @@ -131,6 +145,15 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) { ))} )} + {s.ziele.filter((z) => z.gesamt).map((z) => { + const voll = vollText(z.vorhersage) + return ( +

+ Ziel {z.speicher}: {Math.round(((z.belegt ?? 0) / (z.gesamt ?? 1)) * 100)} % belegt + {voll ? ` · ${voll}` : z.vorhersage?.grund === "gleich" ? " · gleichbleibend" : ""} +

+ ) + })} ) } diff --git a/frontend/src/lib/prognose.test.ts b/frontend/src/lib/prognose.test.ts new file mode 100644 index 0000000..b8fb1c6 --- /dev/null +++ b/frontend/src/lib/prognose.test.ts @@ -0,0 +1,24 @@ +import { inTagen, knappeVorhersage, tempoText, vollText, vorhersageStufe } from "@/lib/prognose" +import type { Vorhersage } from "@/lib/typen" + +const v = (tage: number | null, rate: number | null = 1.25): Vorhersage => ({ tage, rate, anteil: 70, grund: tage == null ? "gleich" : null }) + +it("nennt die Zeit bis voll wie das Backend", () => { + expect([0.4, 1.2, 9.4, 35, 200, 400].map(inTagen)).toEqual([ + "in weniger als einem Tag", "in etwa einem Tag", "in etwa 9 Tagen", "in etwa 5 Wochen", "in etwa 7 Monaten", + "frühestens in einem Jahr", + ]) +}) + +it("stuft wie der Wächter: gelb unter 14, rot unter 3 Tagen", () => { + expect([v(2.9), v(3), v(13.9), v(14), v(null), null].map(vorhersageStufe)).toEqual(["rot", "gelb", "gelb", "ok", "ok", "ok"]) +}) + +it("sagt nur etwas, wenn es etwas zu sagen gibt", () => { + expect(vollText(v(9.4))).toBe("voll in etwa 9 Tagen") + expect(vollText(v(null))).toBeNull() + expect(vollText(undefined)).toBeNull() + expect(tempoText(v(9.4))).toBe("+1,3 % am Tag") + expect(knappeVorhersage(v(9.4))).toEqual({ text: "voll in etwa 9 Tagen", stufe: "gelb" }) + expect(knappeVorhersage(v(75))).toBeNull() +}) diff --git a/frontend/src/lib/prognose.ts b/frontend/src/lib/prognose.ts new file mode 100644 index 0000000..0256eca --- /dev/null +++ b/frontend/src/lib/prognose.ts @@ -0,0 +1,51 @@ +import type { Vorhersage } from "@/lib/typen" + +// „Voll in etwa N Tagen“ (Ausbauplan Welle 1, seit 25.09.2026): Das Backend rechnet aus dem Verlauf der letzten Woche +// (kern/prognose.py), hier stehen nur die Worte. Dieselben Stufen wie beim Wächter: gelb unter 14, rot unter 3 Tagen. + +export type VorhersageStufe = "ok" | "gelb" | "rot" + +export const GELB_TAGE = 14 +export const ROT_TAGE = 3 +/** Knappe Stellen (Ringe, Geräte-Tabelle) nennen nur, was näher liegt — weiter weg wäre es Rauschen. */ +export const KNAPP_TAGE = 60 + +/** „in weniger als einem Tag“, „in etwa 9 Tagen“, „in etwa 5 Wochen“ … — wie im Backend (prognose.in_tagen). */ +export function inTagen(tage: number): string { + if (tage < 1) return "in weniger als einem Tag" + if (tage < 1.5) return "in etwa einem Tag" + if (tage < 21) return `in etwa ${Math.round(tage)} Tagen` + if (tage < 90) return `in etwa ${Math.round(tage / 7)} Wochen` + return tage < 365 ? `in etwa ${Math.round(tage / 30)} Monaten` : "frühestens in einem Jahr" +} + +export function vorhersageStufe(v: Vorhersage | null | undefined): VorhersageStufe { + if (v?.tage == null) return "ok" + return v.tage < ROT_TAGE ? "rot" : v.tage < GELB_TAGE ? "gelb" : "ok" +} + +/** „voll in etwa 9 Tagen“ — oder null, wenn es nichts zu sagen gibt (zu wenig Verlauf, gleichbleibend, weiter weg + * als bis Tage). */ +export function vollText(v: Vorhersage | null | undefined, bis = Infinity): string | null { + if (v?.tage == null || v.tage >= bis) return null + return `voll ${inTagen(v.tage)}` +} + +/** „+1,2 % am Tag“ */ +export function tempoText(v: Vorhersage | null | undefined): string | null { + if (v?.rate == null) return null + return `+${v.rate.toFixed(1).replace(".", ",")} % am Tag` +} + +/** Die Textfarbe zur Stufe: ruhig grau, dann bernstein, dann rot. */ +export const STUFE_FARBE: Record = { + ok: "text-text-3", + gelb: "text-bernstein", + rot: "text-rot-text", +} + +/** Für die knappen Stellen: {text: „voll in etwa 9 Tagen“, stufe} oder null (weiter weg als KNAPP_TAGE). */ +export function knappeVorhersage(v: Vorhersage | null | undefined): { text: string; stufe: VorhersageStufe } | null { + const text = vollText(v, KNAPP_TAGE) + return text ? { text, stufe: vorhersageStufe(v) } : null +} diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 838239b..3e8210f 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -61,7 +61,7 @@ export interface Start { ram_total: number | null temp_cpu: number | null temp_gpu: number | null - platte: { used: number; total: number; percent: number } | null + platte: { used: number; total: number; percent: number; vorhersage?: Vorhersage | null } | null uptime_s: number | null } updates: { @@ -427,12 +427,25 @@ export type Zeitraum = "1h" | "24h" | "7d" /** Ein Punkt einer Messreihe: [Unix-Sekunden des Schrittbeginns, Wert oder null bei einer Lücke]. */ export type Messpunkt = [number, number | null] +/** Vorhersage „voll in etwa N Tagen“ aus dem Verlauf der letzten Woche (kern/prognose.py, seit 25.09.2026). */ +export interface Vorhersage { + /** Tage bis voll; null ohne Vorhersage (grund sagt, warum). */ + tage: number | null + /** Zuwachs in Prozentpunkten am Tag. */ + rate: number | null + /** Jüngstes Mittel in %. */ + anteil: number | null + /** verlauf = weniger als zwei Tage Verlauf, gleich = wächst nicht. */ + grund: "verlauf" | "gleich" | null +} + /** KI-Box: cpu, ram, gpu, platte in %, temp_cpu/temp_gpu in °C, netz_rx/netz_tx in Bytes/s, tokens je Minute. */ export interface BoxMesswerte { zeitraum: Zeitraum schritt_s: number reihen: Partial> aktuell: Partial> + prognose?: { platte?: Vorhersage | null } } export interface GeraetMesswerte { @@ -452,6 +465,7 @@ export interface GeraetMesswerte { load1?: number | null uptime_s?: number | null } + prognose?: { platte?: Vorhersage | null } } export interface HomelabMesswerte { @@ -517,6 +531,8 @@ export interface SpeicherPool { meta_anteil: number | null aktiv: boolean stufe: SpeicherStufe + /** Daten oder Metadaten, was früher voll ist. */ + vorhersage?: Vorhersage | null } export interface NasStand { @@ -532,11 +548,19 @@ export interface NasStand { anteil: number | null stufe: SpeicherStufe problem: string | null + vorhersage?: Vorhersage | null } export interface SicherungsLage { jobs: { zeitplan: string | null; speicher: string | null; alle: boolean }[] - ziele: { speicher: string; art: string | null; aktiv: boolean | null; belegt: number | null; gesamt: number | null }[] + ziele: { + speicher: string + art: string | null + aktiv: boolean | null + belegt: number | null + gesamt: number | null + vorhersage?: Vorhersage | null + }[] erfasst: number fehlend: { ziel: string; name: string; zuletzt: number | null }[] zuletzt: number | null @@ -562,6 +586,7 @@ export interface Systemplatte { frei: number anteil: number stufe: SpeicherStufe + vorhersage?: Vorhersage | null } export interface HomelabSpeicher { diff --git a/frontend/src/views/Start.tsx b/frontend/src/views/Start.tsx index 3b666f4..1efb56a 100644 --- a/frontend/src/views/Start.tsx +++ b/frontend/src/views/Start.tsx @@ -10,6 +10,7 @@ import { Zaehlwerk } from "@/components/cockpit/Zaehlwerk" import { Button } from "@/components/ui/button" import { useRadar, useStart } from "@/lib/abfragen" import { RADAR_STATUS_TEXT, temperaturFarbe } from "@/lib/anzeige" +import { knappeVorhersage, STUFE_FARBE } from "@/lib/prognose" import { useLetzteMetrik } from "@/lib/strom" import type { Radar, Start } from "@/lib/typen" import { gb, gbText } from "@/lib/zeit" @@ -22,6 +23,7 @@ function Instrumente({ box }: { box: Start["box"] }) { const temp = m?.temp_cpu ?? box.temp_cpu const tempGpu = m?.temp_gpu ?? box.temp_gpu const platte = box.platte + const vorhersage = knappeVorhersage(platte?.vorhersage) const uptime = m?.uptime_s ?? box.uptime_s const ramAnteil = ramBelegt != null && ramGesamt ? ramBelegt / ramGesamt : null @@ -53,6 +55,7 @@ function Instrumente({ box }: { box: Start["box"] }) { mitte={platte ? `${Math.round(platte.percent)}%` : "–"} unter={platte ? `${gbText(platte.used)} / ${gbText(platte.total)}` : undefined} ariaText={platte ? `Platte zu ${Math.round(platte.percent)} Prozent belegt` : "Platte unbekannt"} + hinweis={vorhersage ? { text: vorhersage.text, className: STUFE_FARBE[vorhersage.stufe] } : null} /> diff --git a/frontend/src/views/Uebersicht.tsx b/frontend/src/views/Uebersicht.tsx index e8e2907..03a4f4d 100644 --- a/frontend/src/views/Uebersicht.tsx +++ b/frontend/src/views/Uebersicht.tsx @@ -17,6 +17,7 @@ import { boxbild, type Geraetebild, geraetebild, PUNKT, type Punktzustand } from import { artText, kurzname } from "@/lib/homelab" import { laufzeit, zustandBei } from "@/lib/messung" import { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation" +import { knappeVorhersage, type VorhersageStufe } from "@/lib/prognose" import { istSnapshotLauf } from "@/lib/snapshots" import { groesse } from "@/lib/speicher" import { useLetzteMetrik } from "@/lib/strom" @@ -115,6 +116,7 @@ interface Messwert { punkte?: Messpunkt[] gelb: number rot: number + hinweis?: { text: string; stufe: VorhersageStufe } | null } /** Vier Messringe eines Rechners unter seinem Namen. */ @@ -135,7 +137,8 @@ function Instrumentgruppe({ bereich, titel, unterzeile, werte, basis }: {
{werte.map((w, n) => ( + grenzen={[w.gelb, w.rot]} zustand={zustandBei(w.wert ?? null, w.gelb, w.rot)} zeitraumText="letzte Stunde" i={basis + n} + hinweis={w.hinweis} /> ))}
@@ -159,7 +162,8 @@ function Instrumente({ homelabAktiv }: { homelabAktiv: boolean }) { { label: "CPU", wert: live?.cpu ?? mess.data?.aktuell.cpu, einheit: "prozent", punkte: r?.cpu, gelb: 85, rot: 95 }, { label: "Speicher", wert: ramProzent, einheit: "prozent", punkte: r?.ram, gelb: 85, rot: 95 }, { label: "Temp.", wert: live?.temp_cpu ?? b?.temp_cpu, einheit: "grad", punkte: r?.temp_cpu, gelb: 80, rot: 90 }, - { label: "Platte", wert: live?.disk ?? b?.platte?.percent, einheit: "prozent", punkte: r?.platte, gelb: 80, rot: 90 }, + { label: "Platte", wert: live?.disk ?? b?.platte?.percent, einheit: "prozent", punkte: r?.platte, gelb: 80, rot: 90, + hinweis: knappeVorhersage(mess.data?.prognose?.platte ?? b?.platte?.vorhersage) }, ]} /> {homelabAktiv ? ( ) : (

Der Homelab-Teil antwortet gerade nicht; hier steht nichts Veraltetes.