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
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"),
+2
View File
@@ -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}
+9 -7
View File
@@ -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}
+125 -23
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.
• 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:<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 ----------------------------------------------------------------------------------
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
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"
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"))
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"]:
+9 -7
View File
@@ -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)}}
+41 -21
View File
@@ -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",
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)]
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
+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 _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,
+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,
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({
)}
</svg>
<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>
)
}
@@ -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
? <Sparkline punkte={punkte} max={max} farbe={farbe} hoehe={20} beschriftung={`${label}, ${zeitraumText}`} />
: <span className="block h-5" aria-hidden />}
</div>
{hinweis && <span className={cn("text-center text-[11px] leading-tight", STUFE_FARBE[hinweis.stufe])}>{hinweis.text}</span>}
</div>
)
}
+12 -3
View File
@@ -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 <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 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="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} />
</span>
{unter && <span className={cn("text-xs", STUFE_FARBE[unter.stufe])}>{unter.text}</span>}
</span>
)
}
@@ -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 (
<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 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">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>
</div>
)
@@ -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(<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", () => {
const { container, rerender } = render(<Speicher daten={daten} />)
const klappe = container.querySelector("details")!
+24 -1
View File
@@ -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 <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 }: {
titel: string
unter?: string
@@ -67,6 +78,7 @@ function PoolKachel({ p }: { p: SpeicherPool }) {
{p.meta_anteil !== null && (
<p className="text-xs text-text-3">Metadaten {Math.round(p.meta_anteil * 100)} % belegt</p>
)}
<Tempo v={p.vorhersage} />
</Kachel>
)
}
@@ -78,6 +90,7 @@ function SystemplatteKachel({ s }: { s: Systemplatte }) {
<p className="ziffern text-sm text-text-2">
{Math.round(s.anteil * 100)} % · {groesse(s.belegt)} von {groesse(s.gesamt)} · {groesse(s.frei)} frei
</p>
<Tempo v={s.vorhersage} />
</Kachel>
)
}
@@ -100,6 +113,7 @@ function NasKachel({ n }: { n: NasStand }) {
<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
</p>
<Tempo v={n.vorhersage} />
</>
)
)}
@@ -131,6 +145,15 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
))}
</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>
)
}
+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
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<Record<"cpu" | "ram" | "gpu" | "temp_cpu" | "temp_gpu" | "platte" | "netz_rx" | "netz_tx" | "tokens", Messpunkt[]>>
aktuell: Partial<Record<string, number | null>>
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 {
+3
View File
@@ -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}
/>
<Zaehlwerk sekunden={uptime} />
</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 { 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 }: {
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{werte.map((w, n) => (
<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>
@@ -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 ? (
<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: "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: "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>