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:
co-authored by
Claude Opus 5.5
parent
34360c9644
commit
f7a9b8ad50
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
anteil = max(p["anteil"], p["meta_anteil"] or 0)
|
||||
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool"
|
||||
befunde.append(Befund(
|
||||
id=f"pool:{p['speicher']}", stufe=p["stufe"],
|
||||
titel=f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll",
|
||||
text=(f"Noch {_groesse(p['frei'])} frei. Läuft er voll, stehen alle Gäste darauf still. "
|
||||
+ (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
|
||||
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“."),
|
||||
quelle="pve"))
|
||||
rest = ("Läuft er voll, stehen alle Gäste darauf still. " + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
|
||||
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“.")
|
||||
if kommend:
|
||||
was = "Metadaten des Speicherpools" if meta else "Speicherpool"
|
||||
titel = f"{was} {p['speicher']} {prognose.in_tagen(v['tage'])} voll"
|
||||
text = f"{prognose.tempo(v)} Noch {_groesse(p['frei'])} frei. {rest}"
|
||||
else:
|
||||
anteil = max(p["anteil"], p["meta_anteil"] or 0)
|
||||
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool"
|
||||
titel = f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll"
|
||||
text = f"Noch {_groesse(p['frei'])} frei.{prognose.dazu(v)} {rest}"
|
||||
befunde.append(Befund(id=f"pool:{p['speicher']}", stufe=stufe, titel=titel, text=text, quelle="pve"))
|
||||
sp = systemplatte(b)
|
||||
if sp and sp["stufe"] != "ok":
|
||||
v = platten_vorhersage("pve", jetzt) if sp else None
|
||||
stufe, kommend = prognose.schlimmer(sp["stufe"], v) if sp else ("ok", False)
|
||||
if sp and stufe != "ok":
|
||||
kernel = next((e for e in tipps if e["art"] == "kernel"), None)
|
||||
befunde.append(Befund(
|
||||
id="host-platte", stufe=sp["stufe"],
|
||||
titel=f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll",
|
||||
text=(f"Noch {_groesse(sp['frei'])} frei. Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. "
|
||||
id="host-platte", stufe=stufe,
|
||||
titel=(f"Systemplatte des Proxmox-Hosts {prognose.in_tagen(v['tage'])} voll" if kommend
|
||||
else f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll"),
|
||||
text=((f"{prognose.tempo(v)} " if kommend else "") + f"Noch {_groesse(sp['frei'])} frei."
|
||||
+ ("" if kommend else prognose.dazu(v))
|
||||
+ " Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. "
|
||||
+ (f"Platz bringt: {kernel['kurz']} (Knopf im Homelab-Cockpit unter „Speicher und Sicherungen“)."
|
||||
if kernel else "Alte Sicherungen unter /var/lib/vz/dump und ISO-Abbilder prüfen.")),
|
||||
quelle="pve"))
|
||||
for n in nas(b):
|
||||
if n["stufe"] == "ok":
|
||||
v = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
|
||||
stufe, kommend = prognose.schlimmer(n["stufe"], v)
|
||||
if stufe == "ok":
|
||||
continue
|
||||
frei = _groesse((n["gesamt"] or 0) - (n["belegt"] or 0))
|
||||
platz = ("Die Sicherungen brauchen jede Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder "
|
||||
"alte Dateien auf dem NAS aufräumen.")
|
||||
if n["problem"]:
|
||||
titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. "
|
||||
"Strom und Netz des NAS prüfen, dann in dessen Oberfläche nachsehen.")
|
||||
elif kommend:
|
||||
titel = f"NAS ({n['server']}) {prognose.in_tagen(v['tage'])} voll"
|
||||
text = f"{prognose.tempo(v)} Noch {frei} frei. {platz}"
|
||||
else:
|
||||
titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll"
|
||||
text = (f"Noch {_groesse((n['gesamt'] or 0) - (n['belegt'] or 0))} frei. Die Sicherungen brauchen jede "
|
||||
"Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder alte Dateien auf dem NAS "
|
||||
"aufräumen.")
|
||||
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=n["stufe"], titel=titel, text=text, quelle="nas"))
|
||||
s = sicherungen(b, jetzt)
|
||||
text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}"
|
||||
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas"))
|
||||
s = sicherungen(b, zeit)
|
||||
if s["stufe"] in ("gelb", "rot"):
|
||||
teile = list(s["probleme"])
|
||||
if s["fehlend"]:
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
@@ -9,7 +9,7 @@ gesehen. Dieser Wächter schaut deshalb breiter hin:
|
||||
• Timer-Läufe Einmal-Dienste hinter Timern (projekte-sync, Sicherung)
|
||||
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
|
||||
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
|
||||
• Platte Füllstand des Modell-Laufwerks
|
||||
• Platte Füllstand des Modell-Laufwerks, seit 25.09.2026 auch das Tempo („in etwa N Tagen voll“)
|
||||
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
|
||||
• Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb)
|
||||
|
||||
@@ -44,7 +44,7 @@ from pathlib import Path
|
||||
import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL
|
||||
from kern import partner
|
||||
from kern import partner, prognose
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
@@ -454,19 +454,29 @@ def pruefe_kern() -> list[Befund]:
|
||||
|
||||
|
||||
def pruefe_platte() -> list[Befund]:
|
||||
"""Füllstand der eigenen Platte (gelb ab DISK_GELB_PCT, rot ab DISK_ROT_PCT). Auf der KI-Box seit 25.09.2026
|
||||
auch das Tempo des Modell-Laufwerks (kern/prognose.py): in weniger als 14 Tagen voll = gelb, unter 3 Tagen rot —
|
||||
als eigener Hinweis, wenn er ernster ist als der Füllstand."""
|
||||
befunde, stufe = [], "ok"
|
||||
try:
|
||||
pct = psutil.disk_usage(str(DATEN_DIR) if DATEN_DIR.exists() else os.getcwd()).percent
|
||||
except Exception:
|
||||
return []
|
||||
if pct >= DISK_ROT_PCT:
|
||||
stufe = "rot"
|
||||
elif pct >= DISK_GELB_PCT:
|
||||
stufe = "gelb"
|
||||
else:
|
||||
return []
|
||||
return [Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
|
||||
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
|
||||
quelle="platte", sofort=True)]
|
||||
pct = None
|
||||
if pct is not None and pct >= DISK_GELB_PCT:
|
||||
stufe = "rot" if pct >= DISK_ROT_PCT else "gelb"
|
||||
befunde.append(Befund(id="platte", stufe=stufe, titel=f"Die Platte ist zu {pct:.0f} % voll",
|
||||
text="Es wird eng für Modelle und Sicherungen. Alte Modelldateien löschen hilft am meisten.",
|
||||
quelle="platte", sofort=True))
|
||||
if ROLLE == "box":
|
||||
from services import messwerte
|
||||
v = prognose.aus_messreihe(messwerte.QUELLE, "platte")
|
||||
kuenftig, kommend = prognose.schlimmer(stufe, v)
|
||||
if kommend:
|
||||
befunde.append(Befund(
|
||||
id="platte:tempo", stufe=kuenftig, titel=f"Die Platte ist {prognose.in_tagen(v['tage'])} voll",
|
||||
text=f"{prognose.tempo(v)} Alte Modelldateien löschen hilft am meisten: KI-Box → Modelle → Aufräumen.",
|
||||
quelle="platte"))
|
||||
return befunde
|
||||
|
||||
|
||||
def pruefe_probe_wiederherstellung() -> list[Befund]:
|
||||
@@ -563,28 +573,38 @@ def _gb_komma(n: float) -> str:
|
||||
|
||||
def pruefe_gast_platten() -> list[Befund]:
|
||||
"""Homelab: Läuft die Platte eines Containers voll? Am 24.09.2026 stand AdGuard bei 100 %: Das Abfrageprotokoll
|
||||
schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es."""
|
||||
from services.homelab import apps, kanal
|
||||
schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es. Seit 25.09.2026 auch nach dem Tempo
|
||||
(kern/prognose.py): in weniger als 14 Tagen voll = gelb, unter 3 Tagen rot."""
|
||||
from services.homelab import apps, kanal, speicher
|
||||
daten = kanal.bericht()
|
||||
befunde = []
|
||||
for g in (daten or {}).get("bericht", {}).get("gaeste") or []:
|
||||
platte = g.get("platte") or {}
|
||||
gesamt, belegt = platte.get("gesamt"), platte.get("belegt") or 0
|
||||
if not gesamt or belegt / gesamt < GAST_PLATTE_GELB:
|
||||
if not gesamt:
|
||||
continue
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
name = app.name if app else str(g.get("name") or g["vmid"])
|
||||
anteil = belegt / gesamt
|
||||
# Seit 25.09.2026 kennt der Bericht auch die Platte der VMs (über den Gast-Agenten).
|
||||
vm = g.get("art") != "lxc"
|
||||
zid = f"{'vm' if vm else 'ct'}-{g['vmid']}"
|
||||
v = speicher.platten_vorhersage(zid)
|
||||
jetzt_stufe = "rot" if anteil >= GAST_PLATTE_ROT else "gelb" if anteil >= GAST_PLATTE_GELB else "ok"
|
||||
stufe, kommend = prognose.schlimmer(jetzt_stufe, v)
|
||||
if stufe == "ok":
|
||||
continue
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
name = app.name if app else str(g.get("name") or g["vmid"])
|
||||
vergroessern = ("VM → Hardware → Festplatte → Aktion → Größe ändern, danach in der VM das Dateisystem "
|
||||
"mitwachsen lassen" if vm else "Container → Ressourcen → Laufwerk → Größe ändern")
|
||||
befunde.append(Befund(
|
||||
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb",
|
||||
titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll",
|
||||
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']}). "
|
||||
f"Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."),
|
||||
quelle=f"{'vm' if vm else 'ct'}-{g['vmid']}"))
|
||||
id=f"gast-platte:{g['vmid']}", stufe=stufe,
|
||||
titel=(f"Platte von {name} {prognose.in_tagen(v['tage'])} voll" if kommend
|
||||
else f"Platte von {name} zu {min(100, round(anteil * 100))} % voll"),
|
||||
text=((f"{prognose.tempo(v)} " if kommend else "")
|
||||
+ f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']})."
|
||||
+ ("" if kommend else prognose.dazu(v))
|
||||
+ f" Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."),
|
||||
quelle=zid))
|
||||
return befunde
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user