diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index c82287e..da6fd74 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -162,9 +162,10 @@ def sammellauf_quittieren(sl_id: str) -> dict: @router.get("/protokoll") -def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict: +def protokoll_lesen(grenze: int = 200, berichte: bool = False, ziel: str | None = None) -> dict: + """Was geschah, neueste zuerst; mit ziel nur ein Gerät (Zeitleiste je Gerät, seit 25.09.2026).""" from services.homelab import protokoll - return {"eintraege": protokoll.eintraege(grenze, berichte)} + return {"eintraege": protokoll.eintraege(grenze, berichte, ziel or None)} @router.get("/speicher") diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py index 5266dcd..56a80d2 100644 --- a/backend/services/homelab/protokoll.py +++ b/backend/services/homelab/protokoll.py @@ -347,7 +347,10 @@ def _lauf(x: dict, schw: Callable) -> dict | None: elif ergebnis == "eingespielt": stufe = "ok" if baustein == "app": - titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "") + # Seit 25.09.2026 „von → nach“, wo der Lauf beide kennt (Zeitleiste je Gerät). + von_nach = (f" {x['version_alt']} → {x['version_neu']}" if x.get("version_alt") and x.get("version_neu") + else f" {x['version_neu']}" if x.get("version_neu") else "") + titel = f"Update eingespielt · {name}{von_nach}" elif baustein == "docker": titel = f"Docker-Images aktualisiert · {name}" elif baustein == "pakete": @@ -407,7 +410,13 @@ def _waechter_pfad() -> Path: return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json"))) -def _ziel_aus(hinweis_id: str) -> str | None: +_GERAET = re.compile(r"pve|(?:ct|vm)-\d+") + + +def _ziel_aus(hinweis_id: str, quelle: object = None) -> str | None: + """Das Gerät eines Hinweises: seit 25.09.2026 aus seiner Quelle, wenn sie ein Gerät ist, sonst aus der ID.""" + if isinstance(quelle, str) and _GERAET.fullmatch(quelle): + return quelle if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id): return f"{m.group(1)}-{m.group(2)}" if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id): @@ -428,15 +437,15 @@ def _hinweise(schw: Callable) -> list[dict]: continue art, hid = v.get("art"), str(v.get("id") or "") titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {} + ziel = _ziel_aus(hid, v.get("quelle") or h.get("quelle")) if art == "neu": erschienen.add(hid) e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"), - STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), _ziel_aus(hid), None, schw) + STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), ziel, None, schw) elif art == "erledigt": - e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", _ziel_aus(hid), None, schw) + e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", ziel, None, schw) elif art == "auto": - e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", _ziel_aus(hid), - None, schw) + e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", ziel, None, schw) else: continue eintraege.append(e) @@ -552,8 +561,9 @@ def _pflege(geraete: dict, schw: Callable) -> list[dict]: # --- Zusammen ------------------------------------------------------------------------------------------- -def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]: - """Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX).""" +def eintraege(grenze: int = 200, berichte: bool = False, ziel: str | None = None) -> list[dict]: + """Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX). Mit ziel (seit 25.09.2026) nur die eines + Geräts — die Zeitleiste je Gerät.""" grenze = max(1, min(int(grenze), GRENZE_MAX)) schw = _schwaerzer() geraete = _geraete() @@ -568,6 +578,6 @@ def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]: alle += _hinweise(schw) alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw) alle += _pflege(geraete, schw) - liste = [e for e in alle if e is not None] + liste = [e for e in alle if e is not None and (ziel is None or e.get("ziel") == ziel)] liste.sort(key=lambda e: e["zeit"], reverse=True) return liste[:grenze] diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 7ff3477..4498698 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -664,10 +664,12 @@ def _speichere() -> None: log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True) -def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None) -> None: +def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None, quelle: str | None = None) -> None: eintrag = {"ts": time.time(), "art": art, "id": hinweis_id, "text": text} if stufe: # seit 24.09.2026: das Protokoll des Homelab-Teils zeigt, wie ernst ein Hinweis war eintrag["stufe"] = stufe + if quelle: # seit 25.09.2026: welches Gerät — für die Zeitleiste je Gerät + eintrag["quelle"] = quelle _stand["verlauf"].append(eintrag) del _stand["verlauf"][:-VERLAUF_MAX] @@ -744,7 +746,7 @@ def takt() -> None: h.pop("fehlt", None) hinweise[b.id] = h if neu: - _verlauf("neu", b.id, b.titel, b.stufe) + _verlauf("neu", b.id, b.titel, b.stufe, b.quelle) if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S) \ and not ist_stumm(zustaende.get(b.id), b.stufe, jetzt): vorsatz = "" if neu else "Immer noch: " @@ -763,7 +765,7 @@ def takt() -> None: continue h = hinweise.pop(hid) h.pop("fehlt", None) - _verlauf("erledigt", hid, h.get("titel", hid), h.get("stufe")) + _verlauf("erledigt", hid, h.get("titel", hid), h.get("stufe"), h.get("quelle")) if h.get("stufe") == "rot" and h.get("gemeldet"): _telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.") for kid in list(kandidaten): diff --git a/backend/tests/test_homelab_protokoll.py b/backend/tests/test_homelab_protokoll.py index feabedc..a4c758b 100644 --- a/backend/tests/test_homelab_protokoll.py +++ b/backend/tests/test_homelab_protokoll.py @@ -168,8 +168,8 @@ def test_alle_quellen_mit_sprechenden_titeln(geschichte): assert lang.startswith("…\nZeile ") and len(lang) <= protokoll.DETAILS_MAX and lang.endswith("Zeile 199: " + "x" * 90) # Update-Läufe mit ihren Schritten - assert e["Update eingespielt · Gitea 1.27.3"]["stufe"] == "ok" - assert e["Update eingespielt · Gitea 1.27.3"]["details"] == "snapshot: ok\nupdate: ok" + assert e["Update eingespielt · Gitea 1.27.2 → 1.27.3"]["stufe"] == "ok" + assert e["Update eingespielt · Gitea 1.27.2 → 1.27.3"]["details"] == "snapshot: ok\nupdate: ok" assert e["Paket-Update zurückgerollt · Proxmox Backup Server"]["stufe"] == "warn" assert e["Update nicht begonnen · NetBird"]["stufe"] == "warn" host_lauf = e["5 Host-Pakete eingespielt · Proxmox-Host"] @@ -333,3 +333,19 @@ def test_pflege_merkt_sich_anstoss_fehlschlag_und_erholung(daten, monkeypatch): assert titel == Counter({"Wöchentliche Suche angestoßen · AdGuard Home": 2, "Wöchentliche Suche gescheitert · AdGuard Home": 1, "Wöchentliche Suche klappt wieder · AdGuard Home": 1}) + + +def test_zeitleiste_je_geraet(geschichte): + """Seit 25.09.2026: nur die Einträge eines Geräts — Updates von → nach, Aufträge, Hinweise.""" + gitea = protokoll.eintraege(ziel="ct-104") + assert gitea and all(e["ziel"] == "ct-104" for e in gitea) + assert "Update eingespielt · Gitea 1.27.2 → 1.27.3" in {e["titel"] for e in gitea} + assert protokoll.eintraege(ziel="vm-999") == [] + + +def test_hinweise_finden_ihr_geraet_ueber_die_quelle(): + assert protokoll._ziel_aus("zertifikat:npm", "ct-101") == "ct-101" + assert protokoll._ziel_aus("docker:rippy-api-1", "vm-106") == "vm-106" + assert protokoll._ziel_aus("gast-platte:106", None) == "ct-106" # alte Einträge ohne Quelle + assert protokoll._ziel_aus("sicherung:probe", "pve") == "pve" + assert protokoll._ziel_aus("platte", "platte") is None diff --git a/frontend/src/components/homelab/Gesundheit.tsx b/frontend/src/components/homelab/Gesundheit.tsx index 0de0767..19af02f 100644 --- a/frontend/src/components/homelab/Gesundheit.tsx +++ b/frontend/src/components/homelab/Gesundheit.tsx @@ -77,7 +77,11 @@ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte {z.name} - {artText(z) !== z.name && {artText(z)}} + + {artText(z) !== z.name && {artText(z)}} + {/* Zeitleiste je Gerät (seit 25.09.2026). */} + Zeitleiste + diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index a63c332..b2e9bdd 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -479,10 +479,13 @@ export const useSammellaufQuittieren = () => neuLaden: ["homelab-sammellauf"], }) -export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) => +/** Das Protokoll des Homelab-Teils; mit ziel nur ein Gerät (Zeitleiste je Gerät, seit 25.09.2026). */ +export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean, ziel: string | null = null) => useQuery({ - queryKey: ["homelab-protokoll", berichte], - queryFn: () => api<{ eintraege: ProtokollEintrag[] }>(`/api/homelab/protokoll?grenze=300&berichte=${berichte ? 1 : 0}`), + queryKey: ["homelab-protokoll", berichte, ziel], + queryFn: () => api<{ eintraege: ProtokollEintrag[] }>( + `/api/homelab/protokoll?grenze=300&berichte=${berichte ? 1 : 0}${ziel ? `&ziel=${encodeURIComponent(ziel)}` : ""}`, + ), enabled: aktiv, refetchInterval: takt(30_000), retry: false, diff --git a/frontend/src/views/HomelabProtokoll.tsx b/frontend/src/views/HomelabProtokoll.tsx index abc68a8..1a7d02d 100644 --- a/frontend/src/views/HomelabProtokoll.tsx +++ b/frontend/src/views/HomelabProtokoll.tsx @@ -1,4 +1,5 @@ import { useState } from "react" +import { useNavigate, useRouterState } from "@tanstack/react-router" import { Bell, ChevronRight, Cog, History, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react" import { cn } from "cn" import { Zustandsfeld } from "@/components/cockpit/Chip" @@ -7,7 +8,9 @@ import { useHomelab, useHomelabProtokoll, useInstanz, usePartner } from "@/lib/a import type { ProtokollEintrag } from "@/lib/typen" // Was der Homelab-Teil getan hat: Aufträge an den Ausführer (Snapshot, Update, Suchen …), Update-Läufe, -// Hinweise des Wächters, gesendete Meldungen und die nächtliche Pflege — neueste zuerst, nach Tagen. +// Hinweise des Wächters, gesendete Meldungen und die nächtliche Pflege — neueste zuerst, nach Tagen. Seit 25.09.2026 +// (Ausbauplan Welle 2, Punkt 12) auch je Gerät: /homelab/protokoll#ct-104 ist die Zeitleiste von Gitea — Updates +// von → nach, Snapshots, Rückwege, Neustarts und Hinweise auf einer Linie. type Art = ProtokollEintrag["art"] @@ -55,8 +58,10 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null }) return (
  • {uhr.format(new Date(e.zeit * 1000))} - - + {/* Die Linie verbindet die Punkte eines Tages zur Zeitleiste. */} + + +
    @@ -91,8 +96,12 @@ export function HomelabProtokollSeite() { const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar) const [art, setArt] = useState("alle") const [berichte, setBerichte] = useState(false) - const protokoll = useHomelabProtokoll(aktiv, berichte) - const namen = new Map((useHomelab(aktiv).data?.ziele ?? []).map((z) => [z.id, z.name])) + const navigate = useNavigate() + const hash = useRouterState({ select: (s) => s.location.hash }) + const ziele = useHomelab(aktiv).data?.ziele ?? [] + const namen = new Map(ziele.map((z) => [z.id, z.name])) + const geraet = hash && /^(pve|(ct|vm)-\d+)$/.test(hash) ? hash : null + const protokoll = useHomelabProtokoll(aktiv, berichte, geraet) if (instanz.isPending || partner.isPending) return if (!aktiv) return @@ -108,11 +117,21 @@ export function HomelabProtokollSeite() { } return ( - +

    - Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots, - Hinweise, gesendete Meldungen und die nächtliche Paketsuche. + {geraet + ? "Alles, was an diesem Gerät geschah: Updates von → nach, Snapshots, Rückwege, Neustarts und Hinweise." + : "Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots, " + + "Hinweise, gesendete Meldungen und die nächtliche Paketsuche."}

    +
    {ARTEN.map((a) => (