Später · 22 Ausreißer ohne feste Schwellen: Normalwert je Wochenstunde
- kern/ausreisser.py: 168 Fächer je Reihe (CPU, GPU, Speicher, Temperaturen, Netz), erst genau, ab acht Werten gleitend; auffällig ab 4 Streuungen und Mindestabstand, erst mit drei Wochen Erfahrung; Ausreißer gekappt gelernt - services/ausreisser.py: Wächter beider Rollen, gelber Hinweis nach zwei auffälligen Stunden mit Normalwert und Wochenstunde im Satz; erster Lauf lernt aus den aufbewahrten acht Tagen - Monitoring: Zeile mit Lernwoche bzw. was gerade auffällt; GET /api/ausreisser, /api/homelab/ausreisser Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
904598031a
commit
2972cdfeca
@@ -0,0 +1,161 @@
|
|||||||
|
"""Ausreißer ohne feste Schwellen (Ausbauplan „Später“, Punkt 22, seit 26.09.2026).
|
||||||
|
|
||||||
|
Der Orchestrator lernt je Messreihe den Normalwert für jede Stunde der Woche (168 Fächer: montags 0 Uhr bis sonntags
|
||||||
|
23 Uhr) und meldet, wenn ein Stundenmittel zwei Stunden hintereinander deutlich darüber liegt — etwa „Gitea braucht seit
|
||||||
|
2 Stunden ungewöhnlich viel CPU: 35 % statt sonst etwa 3 % dienstags gegen 20 Uhr“. Deutlich heißt: mindestens
|
||||||
|
STREUUNGEN Streuungen über dem Normalwert UND ein Mindestabstand je Größe (CPU 15 Prozentpunkte, Speicher 10,
|
||||||
|
Temperatur 8 °C, Netz 2 MB/s), damit Rauschen auf fast leeren Geräten nicht auffällt. Gemeldet wird erst, wenn ein Fach
|
||||||
|
Werte aus mindestens MIN_WOCHEN Wochen hat; weniger Last als sonst ist kein Problem und wird nicht gemeldet.
|
||||||
|
|
||||||
|
Grundlage sind die Minutenwerte aus kern/messreihen.py (acht Tage aufbewahrt). Gelernt wird kompakt in
|
||||||
|
<Datenordner>/mc2-normalwerte.json: je Reihe und Fach [Anzahl, Mittel, Varianz]. lernen_und_pruefen() rechnet jede
|
||||||
|
neu abgeschlossene Stunde (beim allerersten Mal die aufbewahrten acht Tage): Stundenmittel bilden, mit dem Fach
|
||||||
|
vergleichen, dann ins Fach lernen — einen Ausreißer nur gekappt, damit er den Normalwert nicht mitzieht. Ab
|
||||||
|
VERGESSEN_AB Werten je Fach vergisst es langsam (gleitendes Mittel) und folgt so, wenn sich die Nutzung dauerhaft ändert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern import messreihen
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
# name → (Anzeige, Einheit, Mindestabstand, Mindeststreuung)
|
||||||
|
GROESSEN: dict[str, tuple[str, str, float, float]] = {
|
||||||
|
"cpu": ("CPU", "%", 15.0, 2.0),
|
||||||
|
"gpu": ("Grafik", "%", 20.0, 2.0),
|
||||||
|
"ram": ("Speicher", "%", 10.0, 1.5),
|
||||||
|
"temp_cpu": ("CPU-Temperatur", "°C", 8.0, 1.0),
|
||||||
|
"temp_gpu": ("Grafik-Temperatur", "°C", 8.0, 1.0),
|
||||||
|
"netz_rx": ("eingehenden Netzverkehr", "B/s", 2e6, 5e4),
|
||||||
|
"netz_tx": ("ausgehenden Netzverkehr", "B/s", 2e6, 5e4),
|
||||||
|
}
|
||||||
|
MIN_WOCHEN = 3
|
||||||
|
STREUUNGEN = 4.0
|
||||||
|
VERGESSEN_AB = 8
|
||||||
|
MIN_MINUTEN = 30
|
||||||
|
KAPPE_STREUUNGEN = 3.0
|
||||||
|
STUNDE = 3600
|
||||||
|
NACHHOLEN_S = messreihen.AUFBEWAHREN_TAGE * 86400
|
||||||
|
TAGE = ("montags", "dienstags", "mittwochs", "donnerstags", "freitags", "samstags", "sonntags")
|
||||||
|
|
||||||
|
|
||||||
|
def fach(t: float) -> int:
|
||||||
|
dt = datetime.fromtimestamp(t, LOCAL_TZ)
|
||||||
|
return dt.weekday() * 24 + dt.hour
|
||||||
|
|
||||||
|
|
||||||
|
def fach_text(nr: int) -> str:
|
||||||
|
return f"{TAGE[nr // 24]} gegen {nr % 24} Uhr"
|
||||||
|
|
||||||
|
|
||||||
|
def lernen(stat: list | None, wert: float) -> list:
|
||||||
|
"""[Anzahl, Mittel, Varianz]: erst genau (Welford), ab VERGESSEN_AB gleitend."""
|
||||||
|
n, mittel, var = stat if stat else (0, 0.0, 0.0)
|
||||||
|
d = wert - mittel
|
||||||
|
if n < VERGESSEN_AB:
|
||||||
|
n1 = n + 1
|
||||||
|
mittel1 = mittel + d / n1
|
||||||
|
return [n1, mittel1, (var * n + d * (wert - mittel1)) / n1]
|
||||||
|
a = 1 / VERGESSEN_AB
|
||||||
|
return [n + 1, mittel + a * d, (1 - a) * (var + a * d * d)]
|
||||||
|
|
||||||
|
|
||||||
|
def auffaellig(stat: list | None, wert: float, name: str) -> bool:
|
||||||
|
if not stat or stat[0] < MIN_WOCHEN:
|
||||||
|
return False
|
||||||
|
_, _, abstand, mindeststreuung = GROESSEN[name]
|
||||||
|
streuung = max(math.sqrt(max(stat[2], 0.0)), mindeststreuung)
|
||||||
|
return wert - stat[1] >= max(abstand, STREUUNGEN * streuung)
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "mc2-normalwerte.json"
|
||||||
|
|
||||||
|
|
||||||
|
def lesen() -> dict:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {"stunde": None, "reihen": {}}
|
||||||
|
return daten if isinstance(daten, dict) and isinstance(daten.get("reihen"), dict) else {"stunde": None, "reihen": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _schreiben(daten: dict) -> None:
|
||||||
|
pfad = _pfad()
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||||
|
tmp.replace(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
def _stundenmittel(punkte: list[dict], name: str) -> dict[int, float]:
|
||||||
|
"""Stundenbeginn → Mittel der Minutenwerte (nur Stunden mit mindestens MIN_MINUTEN Werten)."""
|
||||||
|
eimer: dict[int, list[float]] = {}
|
||||||
|
for p in punkte:
|
||||||
|
if messreihen.ist_zahl(p.get(name)):
|
||||||
|
eimer.setdefault(int(p["t"] // STUNDE * STUNDE), []).append(float(p[name]))
|
||||||
|
return {h: sum(w) / len(w) for h, w in eimer.items() if len(w) >= MIN_MINUTEN}
|
||||||
|
|
||||||
|
|
||||||
|
def lernen_und_pruefen(quellen: list[str], jetzt: float) -> dict:
|
||||||
|
"""Jede neu abgeschlossene Stunde einarbeiten. Rückgabe: der Stand (auch unverändert, wenn es nichts Neues gab)."""
|
||||||
|
daten = lesen()
|
||||||
|
bis = int(jetzt // STUNDE * STUNDE) # Beginn der laufenden Stunde
|
||||||
|
von = daten["stunde"] + STUNDE if daten.get("stunde") else bis - NACHHOLEN_S
|
||||||
|
von = max(von, bis - NACHHOLEN_S)
|
||||||
|
if von >= bis:
|
||||||
|
return daten
|
||||||
|
for quelle in quellen:
|
||||||
|
punkte = messreihen.punkte(quelle, von, bis)
|
||||||
|
for name in GROESSEN:
|
||||||
|
mittel = _stundenmittel(punkte, name)
|
||||||
|
if not mittel:
|
||||||
|
continue
|
||||||
|
schluessel = f"{quelle}/{name}"
|
||||||
|
reihe = daten["reihen"].setdefault(schluessel, {"faecher": {}, "folge": 0})
|
||||||
|
for stunde in sorted(mittel):
|
||||||
|
wert, nr = mittel[stunde], fach(stunde)
|
||||||
|
stat = reihe["faecher"].get(str(nr))
|
||||||
|
ist = auffaellig(stat, wert, name)
|
||||||
|
reihe["folge"] = reihe.get("folge", 0) + 1 if ist else 0
|
||||||
|
reihe["zuletzt"] = {"stunde": stunde, "wert": round(wert, 2), "fach": nr,
|
||||||
|
"normal": round(stat[1], 2) if stat else None}
|
||||||
|
kappe = stat[1] + KAPPE_STREUUNGEN * max(math.sqrt(max(stat[2], 0.0)), GROESSEN[name][3]) \
|
||||||
|
if ist and stat else wert
|
||||||
|
reihe["faecher"][str(nr)] = [round(x, 4) for x in lernen(stat, min(wert, kappe))]
|
||||||
|
# Fehlt die letzte Stunde (Gerät aus, keine Messung), ist die Folge gerissen.
|
||||||
|
if bis - STUNDE not in mittel:
|
||||||
|
reihe["folge"] = 0
|
||||||
|
daten["stunde"] = bis - STUNDE
|
||||||
|
_schreiben(daten)
|
||||||
|
return daten
|
||||||
|
|
||||||
|
|
||||||
|
def zahl_text(wert: float, einheit: str) -> str:
|
||||||
|
if einheit == "B/s":
|
||||||
|
return f"{wert / 1e6:.1f} MB/s".replace(".", ",")
|
||||||
|
return f"{round(wert)} {einheit}" if einheit == "°C" else f"{round(wert)} %"
|
||||||
|
|
||||||
|
|
||||||
|
def auffaelligkeiten(daten: dict, min_folge: int = 2) -> list[dict]:
|
||||||
|
"""Reihen, die seit mindestens min_folge Stunden auffällig sind: {quelle, name, folge, wert, normal, fach}."""
|
||||||
|
ergebnis = []
|
||||||
|
for schluessel, reihe in (daten.get("reihen") or {}).items():
|
||||||
|
quelle, _, name = schluessel.partition("/")
|
||||||
|
z = reihe.get("zuletzt") or {}
|
||||||
|
if reihe.get("folge", 0) >= min_folge and name in GROESSEN and z.get("normal") is not None:
|
||||||
|
ergebnis.append({"quelle": quelle, "name": name, "folge": reihe["folge"], "wert": z["wert"],
|
||||||
|
"normal": z["normal"], "fach": z["fach"]})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def lernstand(daten: dict) -> dict:
|
||||||
|
"""Wie weit das Lernen ist: die meisten Wochen in einem Fach, Zahl der Reihen."""
|
||||||
|
wochen = max((s[0] for r in (daten.get("reihen") or {}).values() for s in (r.get("faecher") or {}).values()),
|
||||||
|
default=0)
|
||||||
|
return {"reihen": len(daten.get("reihen") or {}), "wochen": wochen, "lernt": wochen < MIN_WOCHEN,
|
||||||
|
"stunde": daten.get("stunde")}
|
||||||
@@ -156,6 +156,13 @@ def start() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ausreisser")
|
||||||
|
def ausreisser() -> dict:
|
||||||
|
"""Lernstand der Ausreißer-Erkennung und was gerade auffällt (seit 26.09.2026)."""
|
||||||
|
from services import ausreisser as box_ausreisser
|
||||||
|
return box_ausreisser.lage()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/platten")
|
@router.get("/platten")
|
||||||
def platten() -> dict:
|
def platten() -> dict:
|
||||||
"""Plattengesundheit der KI-Box (smartctl, höchstens stündlich gelesen; seit 26.09.2026)."""
|
"""Plattengesundheit der KI-Box (smartctl, höchstens stündlich gelesen; seit 26.09.2026)."""
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
|||||||
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
|
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ausreisser")
|
||||||
|
def ausreisser() -> dict:
|
||||||
|
"""Lernstand der Ausreißer-Erkennung im Homelab und was gerade auffällt (seit 26.09.2026)."""
|
||||||
|
from services import ausreisser as homelab_ausreisser
|
||||||
|
return homelab_ausreisser.lage()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/platten")
|
@router.get("/platten")
|
||||||
def platten() -> dict:
|
def platten() -> dict:
|
||||||
"""Plattengesundheit: Proxmox-Host (Ausführer) und NAS (SNMP), bewertet (seit 26.09.2026)."""
|
"""Plattengesundheit: Proxmox-Host (Ausführer) und NAS (SNMP), bewertet (seit 26.09.2026)."""
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Ausreißer ohne feste Schwellen — die Prüfung des Wächters (Ausbauplan „Später“, Punkt 22, seit 26.09.2026).
|
||||||
|
|
||||||
|
Beide Rollen: Die KI-Box lernt ihre eigene Messreihe („box“), der Homelab-Teil die des Proxmox-Hosts („pve“) und jedes
|
||||||
|
Gasts („ct-104“, „vm-106“). Das Lernen und Vergleichen steht in kern/ausreisser.py; hier kommen Gerätenamen und die
|
||||||
|
Sätze der Hinweise dazu. Der Wächter ruft pruefe_ausreisser() jede Minute; gerechnet wird nur, wenn eine Stunde neu
|
||||||
|
abgeschlossen ist. Hinweise sind gelb — etwas Ungewöhnliches, kein Ausfall; „24 h stumm“ passt, wenn es gewollt ist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from kern import ausreisser as kern_ausreisser
|
||||||
|
from kern import messreihen
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
TITEL = {"temp_cpu": "ist seit {n} Stunden ungewöhnlich warm", "temp_gpu": "ist seit {n} Stunden ungewöhnlich warm",
|
||||||
|
"netz_rx": "hat seit {n} Stunden ungewöhnlich viel {was}", "netz_tx": "hat seit {n} Stunden ungewöhnlich viel {was}"}
|
||||||
|
|
||||||
|
|
||||||
|
def quellen(jetzt: float) -> list[str]:
|
||||||
|
if einstellungen().rolle == "box":
|
||||||
|
return ["box"]
|
||||||
|
return [q for q in messreihen.quellen(jetzt - 2 * 86400, jetzt + 1) if q == "pve" or q.startswith(("ct-", "vm-"))]
|
||||||
|
|
||||||
|
|
||||||
|
def geraetename(quelle: str) -> str:
|
||||||
|
if quelle == "box":
|
||||||
|
return "Die KI-Box"
|
||||||
|
if quelle == "pve":
|
||||||
|
return "Der Proxmox-Host"
|
||||||
|
try:
|
||||||
|
from services.homelab import apps, kanal
|
||||||
|
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
if f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g.get('vmid')}" == quelle:
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
return app.name if app else str(g.get("name") or quelle)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return quelle
|
||||||
|
|
||||||
|
|
||||||
|
def satz(a: dict) -> tuple[str, str]:
|
||||||
|
anzeige, einheit, _, _ = kern_ausreisser.GROESSEN[a["name"]]
|
||||||
|
muster = TITEL.get(a["name"], "braucht seit {n} Stunden ungewöhnlich viel {was}")
|
||||||
|
titel = f"{geraetename(a['quelle'])} {muster.format(n=a['folge'], was=anzeige)}"
|
||||||
|
text = (f"{kern_ausreisser.zahl_text(a['wert'], einheit)} statt sonst etwa "
|
||||||
|
f"{kern_ausreisser.zahl_text(a['normal'], einheit)} {kern_ausreisser.fach_text(a['fach'])}. Ist das gewollt "
|
||||||
|
"(ein Update, eine Sicherung, ein Test), schalte den Hinweis stumm; sonst im Monitoring nachsehen.")
|
||||||
|
return titel, text
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_ausreisser(jetzt: float | None = None) -> list:
|
||||||
|
from services.waechter import Befund
|
||||||
|
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
daten = kern_ausreisser.lernen_und_pruefen(quellen(jetzt), jetzt)
|
||||||
|
befunde = []
|
||||||
|
for a in kern_ausreisser.auffaelligkeiten(daten):
|
||||||
|
titel, text = satz(a)
|
||||||
|
befunde.append(Befund(id=f"ausreisser:{a['quelle']}:{a['name']}", stufe="gelb", titel=titel, text=text,
|
||||||
|
quelle=a["quelle"], sofort=True))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def lage() -> dict:
|
||||||
|
"""Für GET /api/ausreisser bzw. /api/homelab/ausreisser: Lernstand und was gerade auffällt."""
|
||||||
|
daten = kern_ausreisser.lesen()
|
||||||
|
return {**kern_ausreisser.lernstand(daten), "min_wochen": kern_ausreisser.MIN_WOCHEN,
|
||||||
|
"auffaellig": [{**a, "titel": satz(a)[0]} for a in kern_ausreisser.auffaelligkeiten(daten)]}
|
||||||
@@ -643,6 +643,9 @@ PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docke
|
|||||||
# Plattengesundheit (seit 26.09.2026): Host und NAS im Homelab, die NVMe der KI-Box auf der Box.
|
# Plattengesundheit (seit 26.09.2026): Host und NAS im Homelab, die NVMe der KI-Box auf der Box.
|
||||||
PRUEFUNGEN += ((__import__("services.homelab.platten").homelab.platten.pruefe_platten_gesundheit,) if ROLLE == "homelab"
|
PRUEFUNGEN += ((__import__("services.homelab.platten").homelab.platten.pruefe_platten_gesundheit,) if ROLLE == "homelab"
|
||||||
else (__import__("services.platten").platten.pruefe_platten_gesundheit,))
|
else (__import__("services.platten").platten.pruefe_platten_gesundheit,))
|
||||||
|
# Ausreißer ohne feste Schwellen (seit 26.09.2026): gelb, wenn ein Stundenmittel zwei Stunden lang weit über dem
|
||||||
|
# Normalwert dieser Wochenstunde liegt (lernt mindestens drei Wochen, bevor es meldet).
|
||||||
|
PRUEFUNGEN += (__import__("services.ausreisser").ausreisser.pruefe_ausreisser,)
|
||||||
# Neue Geräte im Heimnetz (seit 26.09.2026): gelb mit „Ist bekannt“, bis jemand ihn drückt.
|
# Neue Geräte im Heimnetz (seit 26.09.2026): gelb mit „Ist bekannt“, bis jemand ihn drückt.
|
||||||
PRUEFUNGEN += (__import__("services.homelab.netzgeraete").homelab.netzgeraete.pruefe_netzgeraete,) \
|
PRUEFUNGEN += (__import__("services.homelab.netzgeraete").homelab.netzgeraete.pruefe_netzgeraete,) \
|
||||||
if ROLLE == "homelab" else ()
|
if ROLLE == "homelab" else ()
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Ausreißer ohne feste Schwellen (Ausbauplan „Später“, Punkt 22, 26.09.2026): Normalwert je Wochenstunde, Meldung erst
|
||||||
|
nach zwei auffälligen Stunden und erst mit drei Wochen Erfahrung; ein Ausreißer zieht den Normalwert nicht mit."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from kern import ausreisser as ka
|
||||||
|
from kern import einstellungen as einstellungen_mod
|
||||||
|
from kern import messreihen
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
from services import ausreisser
|
||||||
|
|
||||||
|
DIENSTAG_18 = datetime(2026, 9, 29, 18, 0, tzinfo=LOCAL_TZ).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
@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 test_faecher():
|
||||||
|
assert ka.fach(datetime(2026, 9, 28, 0, 30, tzinfo=LOCAL_TZ).timestamp()) == 0 # Montag 0 Uhr
|
||||||
|
assert ka.fach(DIENSTAG_18) == 42
|
||||||
|
assert ka.fach_text(42) == "dienstags gegen 18 Uhr" and ka.fach_text(167) == "sonntags gegen 23 Uhr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lernen_genau_dann_gleitend():
|
||||||
|
stat = None
|
||||||
|
for x in (2.0, 4.0, 6.0):
|
||||||
|
stat = ka.lernen(stat, x)
|
||||||
|
assert stat[0] == 3 and stat[1] == pytest.approx(4.0) and stat[2] == pytest.approx(8 / 3)
|
||||||
|
for _ in range(20):
|
||||||
|
stat = ka.lernen(stat, 10.0)
|
||||||
|
assert stat[0] == 23 and 9.0 < stat[1] < 10.0 # folgt der dauerhaft höheren Nutzung
|
||||||
|
|
||||||
|
|
||||||
|
def test_auffaellig_braucht_erfahrung_und_abstand():
|
||||||
|
stat = [3, 3.0, 1.0]
|
||||||
|
assert ka.auffaellig(stat, 35.0, "cpu")
|
||||||
|
assert not ka.auffaellig(stat, 15.0, "cpu") # unter 15 Prozentpunkten Abstand
|
||||||
|
assert not ka.auffaellig([2, 3.0, 1.0], 35.0, "cpu") # erst zwei Wochen Erfahrung
|
||||||
|
assert not ka.auffaellig([5, 30.0, 100.0], 55.0, "cpu") # 25 Punkte, aber die Stunde schwankt stark (4 × 10)
|
||||||
|
assert not ka.auffaellig(stat, 0.0, "cpu") # weniger Last ist kein Problem
|
||||||
|
|
||||||
|
|
||||||
|
def _minuten(quelle: str, von: float, stunden: int, **werte: float) -> None:
|
||||||
|
for m in range(stunden * 60):
|
||||||
|
messreihen.schreiben(quelle, werte, t=von + m * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwei_auffaellige_stunden_ergeben_einen_hinweis(daten, monkeypatch):
|
||||||
|
# Drei Wochen Erfahrung für dienstags 18 und 19 Uhr: Gitea braucht dann etwa 3 % CPU.
|
||||||
|
(daten / "mc2-normalwerte.json").write_text(json.dumps({"stunde": DIENSTAG_18 - 3600, "reihen": {
|
||||||
|
"ct-104/cpu": {"faecher": {"42": [3, 3.0, 1.0], "43": [3, 3.0, 1.0]}, "folge": 0}}}), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(ausreisser, "geraetename", lambda q: "Gitea")
|
||||||
|
monkeypatch.setattr(ausreisser, "quellen", lambda jetzt: ["ct-104"])
|
||||||
|
_minuten("ct-104", DIENSTAG_18, 1, cpu=35.0, ram=40.0)
|
||||||
|
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 3600 + 30) == [] # eine Stunde reicht nicht
|
||||||
|
_minuten("ct-104", DIENSTAG_18 + 3600, 1, cpu=36.0, ram=40.0)
|
||||||
|
(b,) = ausreisser.pruefe_ausreisser(DIENSTAG_18 + 7200 + 30)
|
||||||
|
assert (b.id, b.stufe, b.quelle) == ("ausreisser:ct-104:cpu", "gelb", "ct-104")
|
||||||
|
assert b.titel == "Gitea braucht seit 2 Stunden ungewöhnlich viel CPU"
|
||||||
|
assert b.text.startswith("36 % statt sonst etwa 3 % dienstags gegen 19 Uhr.")
|
||||||
|
# Der Ausreißer wird gekappt gelernt: Das Fach bleibt nahe 3 %.
|
||||||
|
fach = ka.lesen()["reihen"]["ct-104/cpu"]["faecher"]["43"]
|
||||||
|
assert fach[0] == 4 and fach[1] < 3 + 3 * math.sqrt(1.0) + 0.01
|
||||||
|
# Normale Stunde danach: vorbei.
|
||||||
|
_minuten("ct-104", DIENSTAG_18 + 7200, 1, cpu=3.0, ram=40.0)
|
||||||
|
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 3 * 3600 + 30) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_erster_lauf_lernt_aus_den_aufbewahrten_tagen(daten, monkeypatch):
|
||||||
|
monkeypatch.setattr(ausreisser, "quellen", lambda jetzt: ["box"])
|
||||||
|
_minuten("box", DIENSTAG_18 - 2 * 3600, 2, cpu=5.0, temp_cpu=40.0)
|
||||||
|
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 30) == []
|
||||||
|
stand = ausreisser.lage()
|
||||||
|
assert stand["reihen"] == 2 and stand["wochen"] == 1 and stand["lernt"] and stand["min_wochen"] == 3
|
||||||
|
assert ka.lesen()["stunde"] == DIENSTAG_18 - 3600
|
||||||
|
# Ohne neue Stunde rechnet er nicht noch einmal.
|
||||||
|
assert ausreisser.pruefe_ausreisser(DIENSTAG_18 + 600) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_zahlen_in_worten():
|
||||||
|
assert ka.zahl_text(2_450_000, "B/s") == "2,5 MB/s" and ka.zahl_text(71.6, "°C") == "72 °C"
|
||||||
@@ -223,7 +223,7 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
|
|||||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
|
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
|
||||||
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
|
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
|
||||||
"pruefe_zertifikate", "pruefe_docker", "pruefe_platten_gesundheit",
|
"pruefe_zertifikate", "pruefe_docker", "pruefe_platten_gesundheit",
|
||||||
"pruefe_netzgeraete"]
|
"pruefe_ausreisser", "pruefe_netzgeraete"]
|
||||||
|
|
||||||
|
|
||||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ die der Orchestrator dort vor Updates anlegt (ein Snapshot geht beim PBS nicht).
|
|||||||
Wie beim Monitoring der KI-Box: Zeitraum oben, darunter der Proxmox-Host (CPU, Speicher, Temperatur, Systemplatte,
|
Wie beim Monitoring der KI-Box: Zeitraum oben, darunter der Proxmox-Host (CPU, Speicher, Temperatur, Systemplatte,
|
||||||
Netz) und für jeden Gast CPU und Speicher. Die Werte kommen jede Minute vom Ausführer auf dem Proxmox-Host.
|
Netz) und für jeden Gast CPU und Speicher. Die Werte kommen jede Minute vom Ausführer auf dem Proxmox-Host.
|
||||||
|
|
||||||
|
**Ungewöhnliches ohne feste Grenzen** (seit 26.09., auf beiden Monitoring-Seiten): Der Orchestrator lernt für jede
|
||||||
|
Stunde der Woche, was bei jedem Gerät üblich ist (CPU, Speicher, Temperatur, Netz). Liegt ein Wert zwei Stunden lang
|
||||||
|
weit darüber, kommt ein gelber Hinweis wie „Gitea braucht seit 2 Stunden ungewöhnlich viel CPU: 35 % statt sonst etwa
|
||||||
|
3 % dienstags gegen 20 Uhr“. Gemeldet wird erst mit drei Wochen Erfahrung; bis dahin steht über den Verläufen, in
|
||||||
|
welcher Lernwoche er ist. Ist es gewollt (ein Update, eine Sicherung), schalte den Hinweis 24 Stunden stumm.
|
||||||
|
|
||||||
## Homelab · Protokoll
|
## Homelab · Protokoll
|
||||||
|
|
||||||
Was der Homelab-Teil getan hat, neueste zuerst und nach Tagen: Aufträge an den Ausführer (Snapshot, Update, Suche …),
|
Was der Homelab-Teil getan hat, neueste zuerst und nach Tagen: Aufträge an den Ausführer (Snapshot, Update, Suche …),
|
||||||
|
|||||||
@@ -359,6 +359,17 @@ roten Hinweise per HTTP dorthin (`MC_PARTNER_URL` aus `/etc/mc2/homelab.env`).
|
|||||||
hermes-gateway` (der Deploy startet das Gateway bewusst nicht neu). Prüfen:
|
hermes-gateway` (der Deploy startet das Gateway bewusst nicht neu). Prüfen:
|
||||||
`grep "homelab-orchestrator" ~/.hermes/logs/agent.log | tail -1` nennt die Zahl der Werkzeuge.
|
`grep "homelab-orchestrator" ~/.hermes/logs/agent.log | tail -1` nennt die Zahl der Werkzeuge.
|
||||||
|
|
||||||
|
## Ausreißer ohne feste Schwellen (seit 26.09.)
|
||||||
|
|
||||||
|
`backend/kern/ausreisser.py` lernt je Messreihe (Box: `box`; Homelab: `pve`, `ct-*`, `vm-*`) und Größe (cpu, gpu, ram,
|
||||||
|
temp_cpu, temp_gpu, netz_rx, netz_tx) den Normalwert je Wochenstunde (168 Fächer, `[Anzahl, Mittel, Varianz]`, ab
|
||||||
|
acht Werten gleitend) in `<Datenordner>/mc2-normalwerte.json`. Der Wächter ruft `services/ausreisser.pruefe_ausreisser`
|
||||||
|
jede Minute; gerechnet wird jede neu abgeschlossene Stunde (beim ersten Mal die acht aufbewahrten Tage). Auffällig:
|
||||||
|
Stundenmittel ≥ 4 Streuungen UND ≥ Mindestabstand über dem Fach (CPU 15 Punkte, GPU 20, Speicher 10, Temperatur
|
||||||
|
8 °C, Netz 2 MB/s), erst ab drei Wochen im Fach; zwei Stunden hintereinander → gelber Hinweis. Ein Ausreißer geht
|
||||||
|
gekappt ins Fach ein. Stand: `curl -s http://127.0.0.1:9001/api/ausreisser` bzw. `/api/homelab/ausreisser`. Neu anfangen:
|
||||||
|
die Datei löschen und den Steward neu starten.
|
||||||
|
|
||||||
## Messwerte (Monitoring, seit 24.09.)
|
## Messwerte (Monitoring, seit 24.09.)
|
||||||
|
|
||||||
Jede Minute ein Punkt je Gerät, gelesen als letzte Stunde, letzter Tag oder letzte Woche (Aufbau in
|
Jede Minute ein Punkt je Gerät, gelesen als letzte Stunde, letzter Tag oder letzte Woche (Aufbau in
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-CuG_RlEw.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
|
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-Cdq4iteU.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -9,11 +9,11 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>Homelab Orchestrator</title>
|
<title>Homelab Orchestrator</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CuG_RlEw.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cdq4iteU.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
|
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js">
|
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js">
|
<link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/abfragen-CDCgpAto.js">
|
<link rel="modulepreload" crossorigin href="/assets/abfragen-DNMpmgyt.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js">
|
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js">
|
<link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
|
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { stromVerbunden } from "./strom"
|
|||||||
import type {
|
import type {
|
||||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel,
|
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel,
|
||||||
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Platte, HomelabPlatten, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Platte, HomelabPlatten, AusreisserLage, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||||
ZielStand, Zeitraum,
|
ZielStand, Zeitraum,
|
||||||
} from "./typen"
|
} from "./typen"
|
||||||
@@ -512,6 +512,16 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
|
|||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Ausreißer ohne feste Schwellen (seit 26.09.2026): Lernstand und was gerade auffällt, je Bereich. */
|
||||||
|
export const useAusreisser = (bereich: "box" | "homelab", aktiv = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["ausreisser", bereich],
|
||||||
|
queryFn: () => api<AusreisserLage>(bereich === "box" ? "/api/ausreisser" : "/api/homelab/ausreisser"),
|
||||||
|
enabled: aktiv,
|
||||||
|
refetchInterval: 600_000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
/** Plattengesundheit (seit 26.09.2026): die KI-Box selbst und das Homelab (Proxmox-Host, NAS über SNMP). */
|
/** Plattengesundheit (seit 26.09.2026): die KI-Box selbst und das Homelab (Proxmox-Host, NAS über SNMP). */
|
||||||
export const useBoxPlatten = () =>
|
export const useBoxPlatten = () =>
|
||||||
useQuery({ queryKey: ["platten"], queryFn: () => api<{ platten: Platte[] }>("/api/platten"), refetchInterval: 600_000, retry: false })
|
useQuery({ queryKey: ["platten"], queryFn: () => api<{ platten: Platte[] }>("/api/platten"), refetchInterval: 600_000, retry: false })
|
||||||
|
|||||||
@@ -915,3 +915,14 @@ export interface HomelabPlatten {
|
|||||||
platten: Platte[]
|
platten: Platte[]
|
||||||
nas: { eingerichtet: boolean; ok: boolean; detail: string | null; system: string | null }
|
nas: { eingerichtet: boolean; ok: boolean; detail: string | null; system: string | null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Ausreißer ohne feste Schwellen (GET /api/ausreisser, /api/homelab/ausreisser; seit 26.09.2026) ---------------
|
||||||
|
|
||||||
|
export interface AusreisserLage {
|
||||||
|
reihen: number
|
||||||
|
wochen: number
|
||||||
|
lernt: boolean
|
||||||
|
min_wochen: number
|
||||||
|
stunde: number | null
|
||||||
|
auffaellig: { quelle: string; name: string; folge: number; wert: number; normal: number; fach: number; titel: string }[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
|||||||
import { Panel } from "@/components/cockpit/Panel"
|
import { Panel } from "@/components/cockpit/Panel"
|
||||||
import { Kennzahl } from "@/components/diagramme/Kennzahl"
|
import { Kennzahl } from "@/components/diagramme/Kennzahl"
|
||||||
import { Verlauf } from "@/components/diagramme/Verlauf"
|
import { Verlauf } from "@/components/diagramme/Verlauf"
|
||||||
import { useBoxMesswerte, useHomelabMesswerte, useInstanz, usePartner } from "@/lib/abfragen"
|
import { useAusreisser, useBoxMesswerte, useHomelabMesswerte, useInstanz, usePartner } from "@/lib/abfragen"
|
||||||
import { geraetebild } from "@/lib/geraetebild"
|
import { geraetebild } from "@/lib/geraetebild"
|
||||||
import { laufzeit, letzterWert, zustandBei } from "@/lib/messung"
|
import { laufzeit, letzterWert, zustandBei } from "@/lib/messung"
|
||||||
import { useLetzteMetrik } from "@/lib/strom"
|
import { useLetzteMetrik } from "@/lib/strom"
|
||||||
@@ -51,6 +51,18 @@ const zeitraumText = (z: Zeitraum) => ZEITRAEUME.find((x) => x.id === z)?.text ?
|
|||||||
|
|
||||||
// --- KI-Box -------------------------------------------------------------------------------------------
|
// --- KI-Box -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Ausreißer ohne feste Schwellen (seit 26.09.2026): lernt noch — oder was gerade ungewöhnlich ist. */
|
||||||
|
export function AusreisserZeile({ bereich, aktiv = true }: { bereich: "box" | "homelab"; aktiv?: boolean }) {
|
||||||
|
const a = useAusreisser(bereich, aktiv).data
|
||||||
|
if (!a || a.reihen === 0) return null
|
||||||
|
const text = a.lernt
|
||||||
|
? `Ungewöhnliches meldet der Orchestrator, sobald er je Wochenstunde ${a.min_wochen} Wochen Erfahrung hat — er lernt gerade (Woche ${a.wochen} von ${a.min_wochen}).`
|
||||||
|
: a.auffaellig.length
|
||||||
|
? `Gerade ungewöhnlich: ${a.auffaellig.map((x) => x.titel).join("; ")}.`
|
||||||
|
: "Nichts Ungewöhnliches — verglichen mit den üblichen Werten dieser Wochenstunde."
|
||||||
|
return <p className={cn("m-0 text-sm", !a.lernt && a.auffaellig.length ? "text-bernstein" : "text-text-3")}>{text}</p>
|
||||||
|
}
|
||||||
|
|
||||||
export function BoxMonitoringSeite() {
|
export function BoxMonitoringSeite() {
|
||||||
const [zeitraum, setZeitraum] = useState<Zeitraum>("1h")
|
const [zeitraum, setZeitraum] = useState<Zeitraum>("1h")
|
||||||
const mess = useBoxMesswerte(zeitraum)
|
const mess = useBoxMesswerte(zeitraum)
|
||||||
@@ -65,6 +77,7 @@ export function BoxMonitoringSeite() {
|
|||||||
<ZeitraumWahl wert={zeitraum} onWahl={setZeitraum} farbe="box" />
|
<ZeitraumWahl wert={zeitraum} onWahl={setZeitraum} farbe="box" />
|
||||||
<span className="text-sm text-text-3">Zahlen live, Verläufe im Minutentakt</span>
|
<span className="text-sm text-text-3">Zahlen live, Verläufe im Minutentakt</span>
|
||||||
</div>
|
</div>
|
||||||
|
<AusreisserZeile bereich="box" />
|
||||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 xl:grid-cols-6">
|
||||||
<Kennzahl label="CPU" wert={wert(live?.cpu, "cpu")} einheit="prozent" max={100} zustand={zustandBei(wert(live?.cpu, "cpu"), 85, 95)} zeitraumText={zeitraumText(zeitraum)} />
|
<Kennzahl label="CPU" wert={wert(live?.cpu, "cpu")} einheit="prozent" max={100} zustand={zustandBei(wert(live?.cpu, "cpu"), 85, 95)} zeitraumText={zeitraumText(zeitraum)} />
|
||||||
<Kennzahl label="Speicher" wert={wert(live?.ram, "ram")} einheit="prozent" max={100} zustand={zustandBei(wert(live?.ram, "ram"), 85, 95)} zeitraumText={zeitraumText(zeitraum)} />
|
<Kennzahl label="Speicher" wert={wert(live?.ram, "ram")} einheit="prozent" max={100} zustand={zustandBei(wert(live?.ram, "ram"), 85, 95)} zeitraumText={zeitraumText(zeitraum)} />
|
||||||
@@ -140,6 +153,7 @@ export function HomelabMonitoringSeite() {
|
|||||||
<ZeitraumWahl wert={zeitraum} onWahl={setZeitraum} farbe="homelab" />
|
<ZeitraumWahl wert={zeitraum} onWahl={setZeitraum} farbe="homelab" />
|
||||||
<span className="text-sm text-text-3">Werte im Minutentakt vom Proxmox-Host</span>
|
<span className="text-sm text-text-3">Werte im Minutentakt vom Proxmox-Host</span>
|
||||||
</div>
|
</div>
|
||||||
|
<AusreisserZeile bereich="homelab" aktiv={aktiv} />
|
||||||
{host && (
|
{host && (
|
||||||
<Panel titel="Proxmox-Host" rechts={<span className="ziffern text-sm text-text-3">läuft {laufzeit(h.uptime_s)}{h.load1 != null ? ` · Last ${h.load1.toFixed(2).replace(".", ",")}` : ""}</span>}>
|
<Panel titel="Proxmox-Host" rechts={<span className="ziffern text-sm text-text-3">läuft {laufzeit(h.uptime_s)}{h.load1 != null ? ` · Last ${h.load1.toFixed(2).replace(".", ",")}` : ""}</span>}>
|
||||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user