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:
Hitonabi
2026-09-26 10:04:30 +02:00
co-authored by Claude Opus 5.5
parent 904598031a
commit 2972cdfeca
32 changed files with 414 additions and 27 deletions
+161
View File
@@ -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")}
+7
View File
@@ -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")
def platten() -> dict:
"""Plattengesundheit der KI-Box (smartctl, höchstens stündlich gelesen; seit 26.09.2026)."""
+7
View File
@@ -134,6 +134,13 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
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")
def platten() -> dict:
"""Plattengesundheit: Proxmox-Host (Ausführer) und NAS (SNMP), bewertet (seit 26.09.2026)."""
+68
View File
@@ -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)]}
+3
View File
@@ -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.
PRUEFUNGEN += ((__import__("services.homelab.platten").homelab.platten.pruefe_platten_gesundheit,) if ROLLE == "homelab"
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.
PRUEFUNGEN += (__import__("services.homelab.netzgeraete").homelab.netzgeraete.pruefe_netzgeraete,) \
if ROLLE == "homelab" else ()
+89
View File
@@ -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"
+1 -1
View File
@@ -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",
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
"pruefe_zertifikate", "pruefe_docker", "pruefe_platten_gesundheit",
"pruefe_netzgeraete"]
"pruefe_ausreisser", "pruefe_netzgeraete"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
+6
View File
@@ -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,
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
Was der Homelab-Teil getan hat, neueste zuerst und nach Tagen: Aufträge an den Ausführer (Snapshot, Update, Suche …),
+11
View File
@@ -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:
`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.)
Jede Minute ein Punkt je Gerät, gelesen als letzte Stunde, letzter Tag oder letzte Woche (Aufbau in
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -9,11 +9,11 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<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/button-BHVKhtQ6.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/useMatch-DDVpPKJI.js">
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
+11 -1
View File
@@ -11,7 +11,7 @@ import { stromVerbunden } from "./strom"
import type {
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
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,
ZielStand, Zeitraum,
} from "./typen"
@@ -512,6 +512,16 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
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). */
export const useBoxPlatten = () =>
useQuery({ queryKey: ["platten"], queryFn: () => api<{ platten: Platte[] }>("/api/platten"), refetchInterval: 600_000, retry: false })
+11
View File
@@ -915,3 +915,14 @@ export interface HomelabPlatten {
platten: Platte[]
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 }[]
}
+15 -1
View File
@@ -6,7 +6,7 @@ import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel"
import { Kennzahl } from "@/components/diagramme/Kennzahl"
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 { laufzeit, letzterWert, zustandBei } from "@/lib/messung"
import { useLetzteMetrik } from "@/lib/strom"
@@ -51,6 +51,18 @@ const zeitraumText = (z: Zeitraum) => ZEITRAEUME.find((x) => x.id === z)?.text ?
// --- 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() {
const [zeitraum, setZeitraum] = useState<Zeitraum>("1h")
const mess = useBoxMesswerte(zeitraum)
@@ -65,6 +77,7 @@ export function BoxMonitoringSeite() {
<ZeitraumWahl wert={zeitraum} onWahl={setZeitraum} farbe="box" />
<span className="text-sm text-text-3">Zahlen live, Verläufe im Minutentakt</span>
</div>
<AusreisserZeile bereich="box" />
<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="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" />
<span className="text-sm text-text-3">Werte im Minutentakt vom Proxmox-Host</span>
</div>
<AusreisserZeile bereich="homelab" aktiv={aktiv} />
{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>}>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">