Welle 1 · Vorhersage „voll in etwa N Tagen“: Pool, NAS, Systemplatte, Container, VMs und die Platte der KI-Box

Aus dem Verlauf der Woche (Tagesmittel, Schutz gegen einmalige Sprünge, schneller werdendes Volllaufen zählt sofort):
gelb unter 14, rot unter 3 Tagen. Der Wächter meldet es, auch wenn der Füllstand selbst noch unauffällig ist;
Speicherkarte, Geräte-Tabelle, Instrumente der Übersicht und die KI-Box-Seite zeigen es an. Jeder Bericht des
Ausführers schreibt dafür die Belegungen in die Messreihe „speicher“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:21:38 +02:00
co-authored by Claude Opus 5.5
parent 34360c9644
commit f7a9b8ad50
19 changed files with 691 additions and 72 deletions
+158
View File
@@ -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)
+5 -1
View File
@@ -22,6 +22,7 @@ from datetime import datetime
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from kern import prognose
from kern.zeit import LOCAL_TZ from kern.zeit import LOCAL_TZ
from pydantic import BaseModel from pydantic import BaseModel
from services import ( from services import (
@@ -29,6 +30,7 @@ from services import (
backup, backup,
box_updates, box_updates,
llamaswap, llamaswap,
messwerte,
modell_nutzung, modell_nutzung,
system, system,
update_verlauf, update_verlauf,
@@ -116,7 +118,9 @@ def _box() -> dict:
p = system.metrik_punkt() p = system.metrik_punkt()
try: try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".") 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: except Exception:
platte = None platte = None
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"), return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
+2
View File
@@ -247,7 +247,9 @@ def auftrag_abholen() -> dict:
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)]) @router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
def bericht(daten: dict) -> dict: def bericht(daten: dict) -> dict:
from services.homelab import speicher
kanal.bericht_speichern(daten) kanal.bericht_speichern(daten)
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
return {"ok": True} return {"ok": True}
+9 -7
View File
@@ -24,7 +24,7 @@ import time
from kern import messreihen from kern import messreihen
from services.homelab import apps, inventar, kanal from services.homelab import apps, inventar, kanal, speicher
HOST = "pve" HOST = "pve"
_GAST = re.compile(r"(ct|vm)-\d+") _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: def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
"""Für GET /api/homelab/messwerte: {"zeitraum", "schritt_s", "geraete": [{"id", "name", "art", "reihen", """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) 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 = [] geraete = []
for geraet in _geraete(jetzt - dauer, jetzt): for geraet in _geraete(zeit - dauer, zeit):
host = geraet["id"] == HOST host = geraet["id"] == HOST
daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, jetzt) daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, zeit)
letzter = messreihen.letzter_punkt(geraet["id"], jetzt, AKTUELL_MAX_S) or {} letzter = messreihen.letzter_punkt(geraet["id"], zeit, AKTUELL_MAX_S) or {}
aktuell = {name: letzter.get(name) for name in AKTUELL} aktuell = {name: letzter.get(name) for name in AKTUELL}
if not host: if not host:
aktuell["temp_cpu"] = aktuell["load1"] = None 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} return {"zeitraum": zeitraum, "schritt_s": schritt, "geraete": geraete}
+127 -25
View File
@@ -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. (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 • 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). 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). Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage).
""" """
import logging
import re import re
import socket import socket
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from kern import messreihen, prognose
from kern.zeit import LOCAL_TZ from kern.zeit import LOCAL_TZ
from services.homelab import apps, kanal from services.homelab import apps, kanal
from services.homelab.inventar import EIGENES_ETIKETT from services.homelab.inventar import EIGENES_ETIKETT
log = logging.getLogger(__name__)
GELB, ROT = 0.80, 0.90 GELB, ROT = 0.80, 0.90
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert 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 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} "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:<name>“) und Metadaten („pool-meta:<name>“), je Netzlaufwerk („nas:<ziel>“), je Sicherungsziel
(„ziel:<name>“) und die Platte jeder VM („vm-<vmid>“ — 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 ---------------------------------------------------------------------------------- # --- Für Oberfläche und Wächter ----------------------------------------------------------------------------------
def lage(jetzt: float | None = None) -> dict: 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() b = _bericht()
jetzt = time.time() if jetzt is None else jetzt zeit = time.time() if jetzt is None else jetzt
return {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b), ergebnis = {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b),
"sicherungen": sicherungen(b, jetzt), "empfehlungen": empfehlungen(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: 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 from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
b = _bericht() b = _bericht()
if not b: if not b:
return [] return []
jetzt = time.time() if jetzt is None else jetzt zeit = time.time() if jetzt is None else jetzt
befunde = [] befunde = []
tipps = empfehlungen(b) tipps = empfehlungen(b)
platz_tipp = "; ".join([e["kurz"] for e in tipps if e.get("ort") == "pool"][:3]) platz_tipp = "; ".join([e["kurz"] for e in tipps if e.get("ort") == "pool"][:3])
for p in pools(b): 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 continue
anteil = max(p["anteil"], p["meta_anteil"] or 0) rest = ("Läuft er voll, stehen alle Gäste darauf still. " + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool" + "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“.")
befunde.append(Befund( if kommend:
id=f"pool:{p['speicher']}", stufe=p["stufe"], was = "Metadaten des Speicherpools" if meta else "Speicherpool"
titel=f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll", titel = f"{was} {p['speicher']} {prognose.in_tagen(v['tage'])} voll"
text=(f"Noch {_groesse(p['frei'])} frei. Läuft er voll, stehen alle Gäste darauf still. " text = f"{prognose.tempo(v)} Noch {_groesse(p['frei'])} frei. {rest}"
+ (f"Platz bringt: {platz_tipp}. " if platz_tipp else "") else:
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“."), anteil = max(p["anteil"], p["meta_anteil"] or 0)
quelle="pve")) 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) 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) kernel = next((e for e in tipps if e["art"] == "kernel"), None)
befunde.append(Befund( befunde.append(Befund(
id="host-platte", stufe=sp["stufe"], id="host-platte", stufe=stufe,
titel=f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll", titel=(f"Systemplatte des Proxmox-Hosts {prognose.in_tagen(v['tage'])} voll" if kommend
text=(f"Noch {_groesse(sp['frei'])} frei. Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. " 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“)." + (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.")), if kernel else "Alte Sicherungen unter /var/lib/vz/dump und ISO-Abbilder prüfen.")),
quelle="pve")) quelle="pve"))
for n in nas(b): 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 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"]: if n["problem"]:
titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. " 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.") "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: else:
titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll" 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 " text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}"
"Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder alte Dateien auf dem NAS " befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas"))
"aufräumen.") s = sicherungen(b, zeit)
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=n["stufe"], titel=titel, text=text, quelle="nas"))
s = sicherungen(b, jetzt)
if s["stufe"] in ("gelb", "rot"): if s["stufe"] in ("gelb", "rot"):
teile = list(s["probleme"]) teile = list(s["probleme"])
if s["fehlend"]: if s["fehlend"]:
+9 -7
View File
@@ -25,7 +25,7 @@ from collections.abc import Callable
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
from kern import messreihen from kern import messreihen, prognose
from services import system, token_stats from services import system, token_stats
@@ -176,9 +176,11 @@ ZEITRAEUME = messreihen.ZEITRAEUME
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict: def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
"""Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}}. aktuell ist der letzte """Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}, "prognose": {"platte": …}}.
Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null.""" aktuell ist der letzte Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null; prognose
jetzt = time.time() if jetzt is None else jetzt die Vorhersage „voll in etwa N Tagen“ des Modell-Laufwerks (kern/prognose.py, seit 25.09.2026)."""
daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, jetzt) zeit = time.time() if jetzt is None else jetzt
letzter = messreihen.letzter_punkt(QUELLE, jetzt, AKTUELL_MAX_S) or {} daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, zeit)
return {**daten, "aktuell": {name: letzter.get(name) for name in REIHEN}} 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)}}
+42 -22
View File
@@ -9,7 +9,7 @@ gesehen. Dieser Wächter schaut deshalb breiter hin:
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung) • Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log) • Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP • 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 • Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
• Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb) • Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb)
@@ -44,7 +44,7 @@ from pathlib import Path
import httpx import httpx
import psutil import psutil
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL 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.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ from kern.zeit import LOCAL_TZ
@@ -454,19 +454,29 @@ def pruefe_kern() -> list[Befund]:
def pruefe_platte() -> 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: try:
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent
except Exception: except Exception:
return [] pct = None
if pct >= DISK_ROT_PCT: if pct is not None and pct >= DISK_GELB_PCT:
stufe = "rot" stufe = "rot" if pct >= DISK_ROT_PCT else "gelb"
elif pct >= DISK_GELB_PCT: befunde.append(Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
stufe = "gelb" text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
else: quelle="platte", sofort=True))
return [] if ROLLE == "box":
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll", from services import messwerte
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.", v = prognose.aus_messreihe(messwerte.QUELLE, "platte")
quelle="platte", sofort=True)] 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]: def pruefe_probe_wiederherstellung() -> list[Befund]:
@@ -563,28 +573,38 @@ def _gb_komma(n: float) -> str:
def pruefe_gast_platten() -> list[Befund]: def pruefe_gast_platten() -> list[Befund]:
"""Homelab: Läuft die Platte eines Containers voll? Am 24.09.2026 stand AdGuard bei 100 %: Das Abfrageprotokoll """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.""" schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es. Seit 25.09.2026 auch nach dem Tempo
from services.homelab import apps, kanal (kern/prognose.py): in weniger als 14 Tagen voll = gelb, unter 3 Tagen rot."""
from services.homelab import apps, kanal, speicher
daten = kanal.bericht() daten = kanal.bericht()
befunde = [] befunde = []
for g in (daten or {}).get("bericht", {}).get("gaeste") or []: for g in (daten or {}).get("bericht", {}).get("gaeste") or []:
platte = g.get("platte") or {} platte = g.get("platte") or {}
gesamt, belegt = platte.get("gesamt"), platte.get("belegt") or 0 gesamt, belegt = platte.get("gesamt"), platte.get("belegt") or 0
if not gesamt or belegt / gesamt < GAST_PLATTE_GELB: if not gesamt:
continue 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 anteil = belegt / gesamt
# Seit 25.09.2026 kennt der Bericht auch die Platte der VMs (über den Gast-Agenten). # Seit 25.09.2026 kennt der Bericht auch die Platte der VMs (über den Gast-Agenten).
vm = g.get("art") != "lxc" 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 " 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") "mitwachsen lassen" if vm else "Container → Ressourcen → Laufwerk → Größe ändern")
befunde.append(Befund( befunde.append(Befund(
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb", id=f"gast-platte:{g['vmid']}", stufe=stufe,
titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll", titel=(f"Platte von {name} {prognose.in_tagen(v['tage'])} voll" if kommend
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']}). " else f"Platte von {name} zu {min(100, round(anteil * 100))} % voll"),
f"Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."), text=((f"{prognose.tempo(v)} " if kommend else "")
quelle=f"{'vm' if vm else 'ct'}-{g['vmid']}")) + 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 return befunde
+2
View File
@@ -168,6 +168,7 @@ def test_endpunkt_der_box(daten):
assert all(len(reihe) == 288 for reihe in antwort["reihen"].values()) 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 _werte(antwort["reihen"]["tokens"]) == [900] and _werte(antwort["reihen"]["cpu"]) == [12.5]
assert antwort["aktuell"] == punkt 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 stunde = client.get("/api/messwerte").json() # ohne Angabe: die letzte Stunde
assert stunde["schritt_s"] == 60 and len(stunde["reihen"]["cpu"]) == 60 assert stunde["schritt_s"] == 60 and len(stunde["reihen"]["cpu"]) == 60
assert client.get("/api/messwerte", params={"zeitraum": "1w"}).status_code == 400 assert client.get("/api/messwerte", params={"zeitraum": "1w"}).status_code == 400
@@ -258,6 +259,7 @@ def test_homelab_endpunkte(daten):
host = geraete["pve"] host = geraete["pve"]
assert list(host["reihen"]) == ["cpu", "ram", "platte", "netz_rx", "netz_tx", "temp_cpu"] 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 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 _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, assert host["aktuell"] == {"cpu": 12.5, "ram": 37.5, "ram_used": 12_000_000_000, "ram_total": 32_000_000_000,
+162
View File
@@ -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
@@ -15,6 +15,7 @@ export function Instrument({
unter, unter,
beschriftung, beschriftung,
ariaText, ariaText,
hinweis,
}: { }: {
anteil: number | null anteil: number | null
farbe?: string farbe?: string
@@ -23,6 +24,8 @@ export function Instrument({
unter?: string unter?: string
beschriftung: string beschriftung: string
ariaText: 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 const zeigen = anteil != null && anteil > 0.004
return ( return (
@@ -46,6 +49,7 @@ export function Instrument({
)} )}
</svg> </svg>
<figcaption className="etikett text-[11.5px] tracking-[0.14em] text-text-3">{beschriftung}</figcaption> <figcaption className="etikett text-[11.5px] tracking-[0.14em] text-text-3">{beschriftung}</figcaption>
{hinweis && <span className={`text-center text-xs ${hinweis.className}`}>{hinweis.text}</span>}
</figure> </figure>
) )
} }
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react"
import { cn } from "cn" import { cn } from "cn"
import type { Bereich } from "@/lib/navigation" import type { Bereich } from "@/lib/navigation"
import { type Einheit, wertText } from "@/lib/messung" import { type Einheit, wertText } from "@/lib/messung"
import { STUFE_FARBE, type VorhersageStufe } from "@/lib/prognose"
import type { Messpunkt } from "@/lib/typen" import type { Messpunkt } from "@/lib/typen"
import { Sparkline } from "./Sparkline" 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) } 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 label: string
wert: number | null | undefined wert: number | null | undefined
einheit: Einheit einheit: Einheit
@@ -39,6 +42,8 @@ export function Messring({ label, wert, einheit, punkte, zustand = "ruhig", bere
zeitraumText: string zeitraumText: string
/** Reihenfolge für das Auftauchen. */ /** Reihenfolge für das Auftauchen. */
i?: number 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 z = ZUSTAND[zustand]
const farbe = zustand === "rot" ? "var(--rot)" : zustand === "gelb" ? "var(--bernstein)" : bereich === "box" ? "var(--box)" : "var(--lab)" 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
? <Sparkline punkte={punkte} max={max} farbe={farbe} hoehe={20} beschriftung={`${label}, ${zeitraumText}`} /> ? <Sparkline punkte={punkte} max={max} farbe={farbe} hoehe={20} beschriftung={`${label}, ${zeitraumText}`} />
: <span className="block h-5" aria-hidden />} : <span className="block h-5" aria-hidden />}
</div> </div>
{hinweis && <span className={cn("text-center text-[11px] leading-tight", STUFE_FARBE[hinweis.stufe])}>{hinweis.text}</span>}
</div> </div>
) )
} }
+12 -3
View File
@@ -4,6 +4,7 @@ import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { geraetebild } from "@/lib/geraetebild" import { geraetebild } from "@/lib/geraetebild"
import { artText, plattenAnteil } from "@/lib/homelab" import { artText, plattenAnteil } from "@/lib/homelab"
import { laufzeit } from "@/lib/messung" import { laufzeit } from "@/lib/messung"
import { knappeVorhersage, STUFE_FARBE } from "@/lib/prognose"
import type { GeraetMesswerte, ZielStand } from "@/lib/typen" 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]" 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(".", ",") 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 /** Balken mit Wert (v5): ruhig in der Farbe des Homelabs, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich
* in Zahlen. */ * 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 }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) { 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 <span className="text-sm text-text-3">–</span> if (wert === null || wert === undefined) return <span className="text-sm text-text-3">–</span>
const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "color-mix(in srgb, var(--lab) 80%, transparent)" 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" 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;
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden> <span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} /> <span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} />
</span> </span>
{unter && <span className={cn("text-xs", STUFE_FARBE[unter.stufe])}>{unter.text}</span>}
</span> </span>
) )
} }
@@ -43,6 +51,7 @@ function rang(z: ZielStand): number {
*/ */
export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) { export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) {
const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell])) 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)) const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b))
return ( return (
<div role="table" aria-label="Zustand der Geräte" className="flex flex-col"> <div role="table" aria-label="Zustand der Geräte" className="flex flex-col">
@@ -77,7 +86,7 @@ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte
</span> </span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span> <span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">Speicher </span><Balken wert={a?.ram} gelb={85} rot={95} zusatz={ram} /></span> <span role="cell"><span className="text-xs text-text-3 md:hidden">Speicher </span><Balken wert={a?.ram} gelb={85} rot={95} zusatz={ram} /></span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">Platte </span><Balken wert={platte} gelb={80} rot={90} /></span> <span role="cell"><span className="text-xs text-text-3 md:hidden">Platte </span><Balken wert={platte} gelb={80} rot={90} unter={knappeVorhersage(prognose.get(z.id))} /></span>
<span role="cell" className="ziffern text-sm text-text-2"><span className="font-sans text-xs text-text-3 md:hidden">Läuft seit </span>{laufzeit(a?.uptime_s)}</span> <span role="cell" className="ziffern text-sm text-text-2"><span className="font-sans text-xs text-text-3 md:hidden">Läuft seit </span>{laufzeit(a?.uptime_s)}</span>
</div> </div>
) )
@@ -35,6 +35,21 @@ describe("Speicher", () => {
expect(screen.getByText(/Alle 8 Geräte gesichert/)).toBeInTheDocument() expect(screen.getByText(/Alle 8 Geräte gesichert/)).toBeInTheDocument()
}) })
it("sagt, wann es bei diesem Tempo voll ist (seit 25.09.2026)", () => {
render(<Speicher daten={{
...daten,
pools: [{ ...daten.pools[0], vorhersage: { tage: 9.4, rate: 5.4, anteil: 49, grund: null } }],
systemplatte: { ...daten.systemplatte!, vorhersage: { tage: null, rate: 0, anteil: 50, grund: "gleich" } },
nas: [{ ...daten.nas[0], vorhersage: { tage: null, rate: null, anteil: null, grund: "verlauf" } }],
sicherungen: { ...daten.sicherungen, ziele: [{ speicher: "pbs-qnap", art: "pbs", aktiv: true, belegt: 1_517_685_571_584,
gesamt: 3_847_503_609_856, vorhersage: { tage: 200, rate: 0.3, anteil: 39, grund: null } }] },
}} />)
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", () => { it("klappt die Vorschläge erst auf, wenn der Pool knapp wird", () => {
const { container, rerender } = render(<Speicher daten={daten} />) const { container, rerender } = render(<Speicher daten={daten} />)
const klappe = container.querySelector("details")! const klappe = container.querySelector("details")!
+24 -1
View File
@@ -4,9 +4,11 @@ import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt } from "@/components/cockpit/Chip" import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { STUFE_FARBE, tempoText, vollText, vorhersageStufe } from "@/lib/prognose"
import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher" import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher"
import type { import type {
HomelabSpeicher, NasStand, SicherungsLage, SpeicherEmpfehlung, SpeicherPool, SpeicherStufe, Systemplatte, ZielAktion, HomelabSpeicher, NasStand, SicherungsLage, SpeicherEmpfehlung, SpeicherPool, SpeicherStufe, Systemplatte, Vorhersage,
ZielAktion,
} from "@/lib/typen" } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit" 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 <p className="text-xs text-text-3">Belegung gleichbleibend</p>
const voll = vollText(v)
if (!voll) return null
return <p className={cn("ziffern text-xs", STUFE_FARBE[vorhersageStufe(v)])}>{tempoText(v)} · {voll}</p>
}
function Kachel({ titel, unter, chip, chipText, children }: { function Kachel({ titel, unter, chip, chipText, children }: {
titel: string titel: string
unter?: string unter?: string
@@ -67,6 +78,7 @@ function PoolKachel({ p }: { p: SpeicherPool }) {
{p.meta_anteil !== null && ( {p.meta_anteil !== null && (
<p className="text-xs text-text-3">Metadaten {Math.round(p.meta_anteil * 100)} % belegt</p> <p className="text-xs text-text-3">Metadaten {Math.round(p.meta_anteil * 100)} % belegt</p>
)} )}
<Tempo v={p.vorhersage} />
</Kachel> </Kachel>
) )
} }
@@ -78,6 +90,7 @@ function SystemplatteKachel({ s }: { s: Systemplatte }) {
<p className="ziffern text-sm text-text-2"> <p className="ziffern text-sm text-text-2">
{Math.round(s.anteil * 100)} % · {groesse(s.belegt)} von {groesse(s.gesamt)} · {groesse(s.frei)} frei {Math.round(s.anteil * 100)} % · {groesse(s.belegt)} von {groesse(s.gesamt)} · {groesse(s.frei)} frei
</p> </p>
<Tempo v={s.vorhersage} />
</Kachel> </Kachel>
) )
} }
@@ -100,6 +113,7 @@ function NasKachel({ n }: { n: NasStand }) {
<p className="ziffern text-sm text-text-2"> <p className="ziffern text-sm text-text-2">
{Math.round(n.anteil * 100)} % · {groesse(n.belegt)} von {groesse(n.gesamt)} · {groesse((n.gesamt ?? 0) - (n.belegt ?? 0))} frei {Math.round(n.anteil * 100)} % · {groesse(n.belegt)} von {groesse(n.gesamt)} · {groesse((n.gesamt ?? 0) - (n.belegt ?? 0))} frei
</p> </p>
<Tempo v={n.vorhersage} />
</> </>
) )
)} )}
@@ -131,6 +145,15 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
))} ))}
</ul> </ul>
)} )}
{s.ziele.filter((z) => z.gesamt).map((z) => {
const voll = vollText(z.vorhersage)
return (
<p key={z.speicher} className={cn("ziffern text-xs", voll ? STUFE_FARBE[vorhersageStufe(z.vorhersage)] : "text-text-3")}>
Ziel {z.speicher}: {Math.round(((z.belegt ?? 0) / (z.gesamt ?? 1)) * 100)} % belegt
{voll ? ` · ${voll}` : z.vorhersage?.grund === "gleich" ? " · gleichbleibend" : ""}
</p>
)
})}
</Kachel> </Kachel>
) )
} }
+24
View File
@@ -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()
})
+51
View File
@@ -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<VorhersageStufe, string> = {
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
}
+27 -2
View File
@@ -61,7 +61,7 @@ export interface Start {
ram_total: number | null ram_total: number | null
temp_cpu: number | null temp_cpu: number | null
temp_gpu: 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 uptime_s: number | null
} }
updates: { 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]. */ /** Ein Punkt einer Messreihe: [Unix-Sekunden des Schrittbeginns, Wert oder null bei einer Lücke]. */
export type Messpunkt = [number, number | null] 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. */ /** 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 { export interface BoxMesswerte {
zeitraum: Zeitraum zeitraum: Zeitraum
schritt_s: number schritt_s: number
reihen: Partial<Record<"cpu" | "ram" | "gpu" | "temp_cpu" | "temp_gpu" | "platte" | "netz_rx" | "netz_tx" | "tokens", Messpunkt[]>> reihen: Partial<Record<"cpu" | "ram" | "gpu" | "temp_cpu" | "temp_gpu" | "platte" | "netz_rx" | "netz_tx" | "tokens", Messpunkt[]>>
aktuell: Partial<Record<string, number | null>> aktuell: Partial<Record<string, number | null>>
prognose?: { platte?: Vorhersage | null }
} }
export interface GeraetMesswerte { export interface GeraetMesswerte {
@@ -452,6 +465,7 @@ export interface GeraetMesswerte {
load1?: number | null load1?: number | null
uptime_s?: number | null uptime_s?: number | null
} }
prognose?: { platte?: Vorhersage | null }
} }
export interface HomelabMesswerte { export interface HomelabMesswerte {
@@ -517,6 +531,8 @@ export interface SpeicherPool {
meta_anteil: number | null meta_anteil: number | null
aktiv: boolean aktiv: boolean
stufe: SpeicherStufe stufe: SpeicherStufe
/** Daten oder Metadaten, was früher voll ist. */
vorhersage?: Vorhersage | null
} }
export interface NasStand { export interface NasStand {
@@ -532,11 +548,19 @@ export interface NasStand {
anteil: number | null anteil: number | null
stufe: SpeicherStufe stufe: SpeicherStufe
problem: string | null problem: string | null
vorhersage?: Vorhersage | null
} }
export interface SicherungsLage { export interface SicherungsLage {
jobs: { zeitplan: string | null; speicher: string | null; alle: boolean }[] 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 erfasst: number
fehlend: { ziel: string; name: string; zuletzt: number | null }[] fehlend: { ziel: string; name: string; zuletzt: number | null }[]
zuletzt: number | null zuletzt: number | null
@@ -562,6 +586,7 @@ export interface Systemplatte {
frei: number frei: number
anteil: number anteil: number
stufe: SpeicherStufe stufe: SpeicherStufe
vorhersage?: Vorhersage | null
} }
export interface HomelabSpeicher { export interface HomelabSpeicher {
+3
View File
@@ -10,6 +10,7 @@ import { Zaehlwerk } from "@/components/cockpit/Zaehlwerk"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useRadar, useStart } from "@/lib/abfragen" import { useRadar, useStart } from "@/lib/abfragen"
import { RADAR_STATUS_TEXT, temperaturFarbe } from "@/lib/anzeige" import { RADAR_STATUS_TEXT, temperaturFarbe } from "@/lib/anzeige"
import { knappeVorhersage, STUFE_FARBE } from "@/lib/prognose"
import { useLetzteMetrik } from "@/lib/strom" import { useLetzteMetrik } from "@/lib/strom"
import type { Radar, Start } from "@/lib/typen" import type { Radar, Start } from "@/lib/typen"
import { gb, gbText } from "@/lib/zeit" 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 temp = m?.temp_cpu ?? box.temp_cpu
const tempGpu = m?.temp_gpu ?? box.temp_gpu const tempGpu = m?.temp_gpu ?? box.temp_gpu
const platte = box.platte const platte = box.platte
const vorhersage = knappeVorhersage(platte?.vorhersage)
const uptime = m?.uptime_s ?? box.uptime_s const uptime = m?.uptime_s ?? box.uptime_s
const ramAnteil = ramBelegt != null && ramGesamt ? ramBelegt / ramGesamt : null const ramAnteil = ramBelegt != null && ramGesamt ? ramBelegt / ramGesamt : null
@@ -53,6 +55,7 @@ function Instrumente({ box }: { box: Start["box"] }) {
mitte={platte ? `${Math.round(platte.percent)}%` : "–"} mitte={platte ? `${Math.round(platte.percent)}%` : "–"}
unter={platte ? `${gbText(platte.used)} / ${gbText(platte.total)}` : undefined} unter={platte ? `${gbText(platte.used)} / ${gbText(platte.total)}` : undefined}
ariaText={platte ? `Platte zu ${Math.round(platte.percent)} Prozent belegt` : "Platte unbekannt"} ariaText={platte ? `Platte zu ${Math.round(platte.percent)} Prozent belegt` : "Platte unbekannt"}
hinweis={vorhersage ? { text: vorhersage.text, className: STUFE_FARBE[vorhersage.stufe] } : null}
/> />
<Zaehlwerk sekunden={uptime} /> <Zaehlwerk sekunden={uptime} />
</section> </section>
+8 -3
View File
@@ -17,6 +17,7 @@ import { boxbild, type Geraetebild, geraetebild, PUNKT, type Punktzustand } from
import { artText, kurzname } from "@/lib/homelab" import { artText, kurzname } from "@/lib/homelab"
import { laufzeit, zustandBei } from "@/lib/messung" import { laufzeit, zustandBei } from "@/lib/messung"
import { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation" import { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation"
import { knappeVorhersage, type VorhersageStufe } from "@/lib/prognose"
import { istSnapshotLauf } from "@/lib/snapshots" import { istSnapshotLauf } from "@/lib/snapshots"
import { groesse } from "@/lib/speicher" import { groesse } from "@/lib/speicher"
import { useLetzteMetrik } from "@/lib/strom" import { useLetzteMetrik } from "@/lib/strom"
@@ -115,6 +116,7 @@ interface Messwert {
punkte?: Messpunkt[] punkte?: Messpunkt[]
gelb: number gelb: number
rot: number rot: number
hinweis?: { text: string; stufe: VorhersageStufe } | null
} }
/** Vier Messringe eines Rechners unter seinem Namen. */ /** Vier Messringe eines Rechners unter seinem Namen. */
@@ -135,7 +137,8 @@ function Instrumentgruppe({ bereich, titel, unterzeile, werte, basis }: {
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{werte.map((w, n) => ( {werte.map((w, n) => (
<Messring key={w.label} label={w.label} wert={w.wert} einheit={w.einheit} punkte={w.punkte} bereich={bereich} <Messring key={w.label} label={w.label} wert={w.wert} einheit={w.einheit} punkte={w.punkte} bereich={bereich}
grenzen={[w.gelb, w.rot]} zustand={zustandBei(w.wert ?? null, w.gelb, w.rot)} zeitraumText="letzte Stunde" i={basis + n} /> grenzen={[w.gelb, w.rot]} zustand={zustandBei(w.wert ?? null, w.gelb, w.rot)} zeitraumText="letzte Stunde" i={basis + n}
hinweis={w.hinweis} />
))} ))}
</div> </div>
</div> </div>
@@ -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: "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: "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: "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 ? ( {homelabAktiv ? (
<Instrumentgruppe bereich="homelab" titel="Proxmox-Host" basis={4} <Instrumentgruppe bereich="homelab" titel="Proxmox-Host" basis={4}
@@ -168,7 +172,8 @@ function Instrumente({ homelabAktiv }: { homelabAktiv: boolean }) {
{ label: "CPU", wert: host?.aktuell.cpu, einheit: "prozent", punkte: host?.reihen.cpu, gelb: 85, rot: 95 }, { label: "CPU", wert: host?.aktuell.cpu, einheit: "prozent", punkte: host?.reihen.cpu, gelb: 85, rot: 95 },
{ label: "Speicher", wert: host?.aktuell.ram, einheit: "prozent", punkte: host?.reihen.ram, gelb: 90, rot: 95 }, { label: "Speicher", wert: host?.aktuell.ram, einheit: "prozent", punkte: host?.reihen.ram, gelb: 90, rot: 95 },
{ label: "Temp.", wert: host?.aktuell.temp_cpu, einheit: "grad", punkte: host?.reihen.temp_cpu, gelb: 80, rot: 90 }, { label: "Temp.", wert: host?.aktuell.temp_cpu, einheit: "grad", punkte: host?.reihen.temp_cpu, gelb: 80, rot: 90 },
{ label: "Platte", wert: host?.aktuell.platte, einheit: "prozent", punkte: host?.reihen.platte, gelb: 80, rot: 90 }, { label: "Platte", wert: host?.aktuell.platte, einheit: "prozent", punkte: host?.reihen.platte, gelb: 80, rot: 90,
hinweis: knappeVorhersage(host?.prognose?.platte) },
]} /> ]} />
) : ( ) : (
<p className="self-center text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht; hier steht nichts Veraltetes.</p> <p className="self-center text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht; hier steht nichts Veraltetes.</p>