diff --git a/backend/routers/boxwart.py b/backend/routers/boxwart.py index 7ba376d..0cf4315 100644 --- a/backend/routers/boxwart.py +++ b/backend/routers/boxwart.py @@ -132,8 +132,10 @@ def _box() -> dict: def start() -> dict: stand = waechter.lese_stand() u = box_updates.gecacht() - rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot") - anzahl = len(stand["hinweise"]) + # Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen aber die Leuchte nicht aus. + laut = [h for h in stand["hinweise"] if h.get("zustand") != "stumm"] + rot = sum(1 for h in laut if h.get("stufe") == "rot") + anzahl = len(laut) wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60 return { "zustand": { diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index 5cc8353..c82287e 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -110,6 +110,13 @@ def hinweise() -> dict: return waechter.lese_stand() +@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}") +def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict: + """Knöpfe eines Hinweises dieser Instanz — auch „gesehen“, „stumm“ (24 h) und „laut“ (seit 25.09.2026).""" + from services import waechter + return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id)) + + @router.get("/laeufe") def laeufe() -> dict: return {"laeufe": updates.laeufe()} diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 24d3f92..7ff3477 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -14,7 +14,10 @@ gesehen. Dieser Wächter schaut deshalb breiter hin: • Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb) Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und -Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst +Knöpfen. Seit 25.09.2026 (Ausbauplan Welle 2, Punkt 10) hat jeder Hinweis einen Zustand: neu, gesehen oder stumm bis … +(24 Stunden, je Hinweis per Knopf; verschärft er sich von gelb auf rot, gilt stumm nicht mehr). Stumm heißt: keine +Telegram-Erinnerungen, in der Liste unten. Und ein Hinweis ist erst erledigt, wenn sein Befund ERLEDIGT_NACH Takte am +Stück fehlt — ein kurzer Wackler bringt kein „Erledigt“ und gleich danach keinen neuen Alarm. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst (ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen zusätzlich an Telegram und in Lucys Briefkasten. @@ -67,6 +70,12 @@ STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(DATEN_DIR / "mc2-waech # „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist. # Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos. QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(DATEN_DIR / "mc2-quittiert.json"))) +# Zustand je Hinweis (seit 25.09.2026): gesehen / stumm bis … Schreibt nur MC2 (Knöpfe), der Steward liest. +ZUSTAND_PATH = Path(os.environ.get("MC_WAECHTER_ZUSTAND", str(DATEN_DIR / "mc2-hinweis-zustand.json"))) +STUMM_S = int(os.environ.get("MC_WAECHTER_STUMM_S", str(24 * 3600))) +# So viele Takte am Stück muss ein Befund fehlen, bis sein Hinweis erledigt ist (Puffer gegen Flackern). +ERLEDIGT_NACH = int(os.environ.get("MC_WAECHTER_ERLEDIGT_NACH", "3")) +STUFE_RANG = {"gelb": 1, "rot": 2} # Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update # auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird. PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5")) @@ -717,23 +726,27 @@ def takt() -> None: hinweise: dict = _stand["hinweise"] kandidaten: dict = _stand["kandidaten"] aktuell = {b.id for b in befunde} + zustaende = hinweis_zustaende() for b in befunde: k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt}) k["takte"] += 1 if not update and b.auto: _selbst_beheben(b, jetzt) - if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER)): + # Ein Hinweis, der noch im Puffer steht (Befund kurz weg und wieder da), läuft einfach weiter. + if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER) or b.id in hinweise): continue h = hinweise.get(b.id) neu = h is None h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0} h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle, aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"]) + h.pop("fehlt", None) hinweise[b.id] = h if neu: _verlauf("neu", b.id, b.titel, b.stufe) - if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S): + 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: " _telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}") h["gemeldet"] = jetzt @@ -745,7 +758,11 @@ def takt() -> None: continue if update and hid.startswith(("dienst:", "kern:")): continue + hinweise[hid]["fehlt"] = hinweise[hid].get("fehlt", 0) + 1 + if hinweise[hid]["fehlt"] < ERLEDIGT_NACH: + continue h = hinweise.pop(hid) + h.pop("fehlt", None) _verlauf("erledigt", hid, h.get("titel", hid), h.get("stufe")) if h.get("stufe") == "rot" and h.get("gemeldet"): _telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.") @@ -772,6 +789,51 @@ async def waechter_loop() -> None: await asyncio.sleep(INTERVAL) +# --- Zustand je Hinweis: gesehen, stumm (seit 25.09.2026) -------------------------------- + +def hinweis_zustaende() -> dict[str, dict]: + try: + daten = json.loads(ZUSTAND_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return daten if isinstance(daten, dict) else {} + + +def ist_stumm(eintrag: dict | None, stufe: str | None, jetzt: float) -> bool: + """Stumm, solange die Frist läuft und der Hinweis sich nicht verschärft hat (gelb stumm geschaltet, jetzt rot: + nicht mehr stumm — Kritisches meldet sich trotzdem).""" + if not eintrag or float(eintrag.get("stumm_bis") or 0) <= jetzt: + return False + return STUFE_RANG.get(stufe or "", 0) <= STUFE_RANG.get(str(eintrag.get("stumm_stufe")), 0) + + +def hinweis_zustand_setzen(hinweis: dict, was: str, jetzt: float | None = None) -> dict: + """Die Knöpfe „Gesehen“, „24 h stumm“, „Wieder melden“ (MC2-Prozess).""" + jetzt = time.time() if jetzt is None else jetzt + daten = hinweis_zustaende() + eintrag = dict(daten.get(hinweis["id"]) or {}) + if was == "gesehen": + eintrag["gesehen"] = hinweis.get("seit") + text = "Als gesehen markiert." + elif was == "stumm": + eintrag.update(stumm_bis=jetzt + STUMM_S, stumm_stufe=hinweis.get("stufe"), gesehen=hinweis.get("seit")) + text = (f"Stumm für {STUMM_S // 3600} Stunden: keine Erinnerungen. Wird er schlimmer (rot), " + "meldet er sich trotzdem.") + else: + eintrag.pop("stumm_bis", None) + eintrag.pop("stumm_stufe", None) + text = "Meldet sich wieder." + eintrag["geaendert"] = jetzt + daten[hinweis["id"]] = eintrag + # Einträge ohne laufende Frist, die eine Woche nicht angefasst wurden, fallen weg. + daten = {k: v for k, v in daten.items() + if float(v.get("stumm_bis") or 0) > jetzt or jetzt - float(v.get("geaendert") or 0) < 7 * 86400} + tmp = ZUSTAND_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(tmp, ZUSTAND_PATH) + return {"ok": True, "text": text} + + # --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------ def lese_stand() -> dict: @@ -780,10 +842,19 @@ def lese_stand() -> dict: daten = json.loads(STORE_PATH.read_text(encoding="utf-8")) except (OSError, ValueError): daten = {} - hinweise = sorted((daten.get("hinweise") or {}).values(), - key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0))) + jetzt = time.time() + zustaende = hinweis_zustaende() + hinweise = list((daten.get("hinweise") or {}).values()) for h in hinweise: h.pop("gemeldet", None) + h.pop("fehlt", None) + z = zustaende.get(h.get("id")) or {} + if ist_stumm(z, h.get("stufe"), jetzt): + h["zustand"], h["stumm_bis"] = "stumm", z["stumm_bis"] + else: + h["zustand"] = "gesehen" if z.get("gesehen") == h.get("seit") else "neu" + # Rot vor gelb, stumm ganz unten, sonst nach Beginn. + hinweise.sort(key=lambda h: (h["zustand"] == "stumm", h.get("stufe") != "rot", h.get("seit", 0))) return { "hinweise": hinweise, "verlauf": list(reversed((daten.get("verlauf") or [])[-50:])), @@ -805,6 +876,8 @@ def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict: hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None) if hinweis is None: return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."} + if aktion_id in ("gesehen", "stumm", "laut"): # gibt es bei jedem Hinweis (seit 25.09.2026) + return hinweis_zustand_setzen(hinweis, aktion_id) aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None) if aktion is None: return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."} diff --git a/backend/tests/test_befundliste.py b/backend/tests/test_befundliste.py new file mode 100644 index 0000000..f6b3d2c --- /dev/null +++ b/backend/tests/test_befundliste.py @@ -0,0 +1,95 @@ +"""Befundliste mit Ablauf (Ausbauplan Welle 2, Punkt 10, 25.09.2026): Puffer gegen Flackern, Zustand je Hinweis +(neu, gesehen, stumm bis …) und dass Kritisches trotz „stumm“ durchkommt.""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from services import waechter + + +@pytest.fixture +def wache(tmp_path, monkeypatch): + monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json") + monkeypatch.setattr(waechter, "ZUSTAND_PATH", tmp_path / "zustand.json") + monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0}) + monkeypatch.setattr(waechter, "_update_laeuft", lambda: False) + monkeypatch.setattr(waechter, "FAIL_AFTER", 2) + meldungen: list[str] = [] + monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(f"{betreff} {text}")) + befund = {"stufe": "rot", "da": True} + + def pruefung(): + if not befund["da"]: + return [] + return [waechter.Befund(id="kern:engine", stufe=befund["stufe"], titel="Der Motor antwortet nicht", text="…", + quelle="engine")] + + monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefung,)) + return befund, meldungen + + +def test_kurzer_wackler_bringt_kein_erledigt_und_keinen_neuen_alarm(wache): + befund, meldungen = wache + waechter.takt() + waechter.takt() + seit = waechter._stand["hinweise"]["kern:engine"]["seit"] + assert len(meldungen) == 1 + befund["da"] = False + waechter.takt() # einen Takt weg … + befund["da"] = True + waechter.takt() # … und wieder da: derselbe Hinweis, keine Meldung + assert waechter._stand["hinweise"]["kern:engine"]["seit"] == seit and len(meldungen) == 1 + assert [v["art"] for v in waechter._stand["verlauf"]] == ["neu"] + + +def test_zustand_neu_gesehen_stumm(wache): + befund, meldungen = wache + befund["stufe"] = "gelb" + waechter.takt() + waechter.takt() + (h,) = waechter.lese_stand()["hinweise"] + assert h["zustand"] == "neu" and "fehlt" not in h + assert waechter.fuehre_aktion_aus("kern:engine", "gesehen") == {"ok": True, "text": "Als gesehen markiert."} + assert waechter.lese_stand()["hinweise"][0]["zustand"] == "gesehen" + antwort = waechter.fuehre_aktion_aus("kern:engine", "stumm") + assert antwort["ok"] and antwort["text"].startswith("Stumm für 24 Stunden") + h = waechter.lese_stand()["hinweise"][0] + assert h["zustand"] == "stumm" and h["stumm_bis"] > h["zuletzt"] + # Wird er rot, gilt stumm nicht mehr: Kritisches meldet sich trotzdem. + befund["stufe"] = "rot" + waechter.takt() + assert waechter.lese_stand()["hinweise"][0]["zustand"] == "gesehen" + assert len(meldungen) == 1 and "Der Motor antwortet nicht" in meldungen[0] + assert waechter.fuehre_aktion_aus("kern:engine", "laut")["text"] == "Meldet sich wieder." + + +def test_stumm_haelt_die_erinnerung_zurueck(wache, monkeypatch): + _, meldungen = wache + waechter.takt() + waechter.takt() + assert len(meldungen) == 1 + waechter.fuehre_aktion_aus("kern:engine", "stumm") + monkeypatch.setattr(waechter, "REMIND_S", 0) # jede Erinnerung wäre fällig … + waechter.takt() + assert len(meldungen) == 1 # … aber stumm + waechter.fuehre_aktion_aus("kern:engine", "laut") + waechter.takt() + assert len(meldungen) == 2 and "Immer noch" in meldungen[1] + + +def test_stumme_stehen_unten_und_der_knopf_geht_auch_im_homelab(wache, monkeypatch): + from routers import homelab + befund, _ = wache + befund["stufe"] = "gelb" + waechter.takt() + waechter.takt() + waechter._stand["hinweise"]["zweiter"] = {"id": "zweiter", "stufe": "gelb", "titel": "B", "text": "", "quelle": "x", + "seit": 1.0, "zuletzt": 2.0, "aktionen": []} + waechter._speichere() + waechter.fuehre_aktion_aus("zweiter", "stumm") + assert [h["id"] for h in waechter.lese_stand()["hinweise"]] == ["kern:engine", "zweiter"] + app = FastAPI() + app.include_router(homelab.router) + client = TestClient(app) + assert client.post("/api/homelab/hinweise/kern:engine/aktion/stumm").json()["ok"] is True + assert client.post("/api/homelab/hinweise/weg/aktion/stumm").status_code == 409 diff --git a/backend/tests/test_partner.py b/backend/tests/test_partner.py index d222ee6..a5eb452 100644 --- a/backend/tests/test_partner.py +++ b/backend/tests/test_partner.py @@ -156,7 +156,8 @@ def test_waechter_meldet_den_partner_erst_nach_geduld(monkeypatch, tmp_path): assert len(meldungen) == 1 and "Proxmox-PC (http://pve:9001) ist nicht erreichbar" in meldungen[0] erreichbar[0] = True - waechter.takt() + for _ in range(waechter.ERLEDIGT_NACH): # Puffer gegen Flackern (seit 25.09.2026) + waechter.takt() assert waechter._stand["hinweise"] == {} assert meldungen[-1] == "Erledigt: Homelab antwortet nicht." diff --git a/backend/tests/test_waechter.py b/backend/tests/test_waechter.py index b027d10..1389b6f 100644 --- a/backend/tests/test_waechter.py +++ b/backend/tests/test_waechter.py @@ -95,7 +95,11 @@ def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp assert "kern:engine" in waechter._stand["hinweise"] assert meldungen == ["[Box-Problem]"] + # Seit 25.09.2026 mit Puffer: erst nach ERLEDIGT_NACH Takten ohne Befund ist der Hinweis erledigt. defekt[0] = False + for _ in range(waechter.ERLEDIGT_NACH - 1): + waechter.takt() + assert "kern:engine" in waechter._stand["hinweise"] and meldungen == ["[Box-Problem]"] waechter.takt() assert waechter._stand["hinweise"] == {} assert meldungen == ["[Box-Problem]", "[Box wieder ok]"] diff --git a/frontend/src/components/cockpit/Befundliste.test.tsx b/frontend/src/components/cockpit/Befundliste.test.tsx new file mode 100644 index 0000000..947c2e6 --- /dev/null +++ b/frontend/src/components/cockpit/Befundliste.test.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { post } from "@/lib/api" +import type { Hinweis } from "@/lib/typen" +import { Checkliste } from "./Checkliste" + +vi.mock("@/lib/api", async (original) => ({ ...(await original()), post: vi.fn() })) + +// Befundliste mit Ablauf (Ausbauplan Welle 2, Punkt 10, 25.09.2026): Zustand je Hinweis und die Knöpfe dafür. +const jetzt = Math.floor(Date.now() / 1000) +const hinweis: Hinweis = { + id: "ablauf:timer:mc2-radar", stufe: "gelb", titel: "Modell-Radar ist überfällig", text: "Zuletzt vor 30 Stunden.", + quelle: "mc2-radar", seit: jetzt - 3600, zuletzt: jetzt - 30, aktionen: [], zustand: "neu", +} + +function zeigen(h: Hinweis, basis?: string) { + vi.mocked(post).mockResolvedValue({ ok: true, text: "Als gesehen markiert." }) + render( + + + , + ) +} + +describe("Befundliste", () => { + it("neue Hinweise lassen sich als gesehen markieren oder 24 h stumm schalten — im Homelab beim Homelab-Teil", async () => { + zeigen(hinweis, "/api/homelab/hinweise") + expect(screen.getByText("· neu")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "24 h stumm" })) + await waitFor(() => expect(post).toHaveBeenCalledWith("/api/homelab/hinweise/ablauf%3Atimer%3Amc2-radar/aktion/stumm")) + expect(screen.getByRole("button", { name: "Gesehen" })).toBeInTheDocument() + }) + + it("stumme stehen gedämpft da und lassen sich wieder laut schalten", async () => { + zeigen({ ...hinweis, zustand: "stumm", stumm_bis: jetzt + 20 * 3600 }) + expect(screen.getByText(/stumm bis/)).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "24 h stumm" })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Wieder melden" })) + await waitFor(() => expect(post).toHaveBeenCalledWith("/api/hinweise/ablauf%3Atimer%3Amc2-radar/aktion/laut")) + }) +}) diff --git a/frontend/src/components/cockpit/Checkliste.tsx b/frontend/src/components/cockpit/Checkliste.tsx index 8736d31..50300af 100644 --- a/frontend/src/components/cockpit/Checkliste.tsx +++ b/frontend/src/components/cockpit/Checkliste.tsx @@ -6,16 +6,17 @@ import { hinweisRueckfrage } from "@/lib/anzeige" import { post } from "@/lib/api" import { melden } from "@/lib/meldungen" import type { AktionsErgebnis, Hinweis, HinweisAktion, VerlaufEintrag } from "@/lib/typen" -import { seit, vor } from "@/lib/zeit" +import { flugplanZeit, seit, vor } from "@/lib/zeit" // Erst laden, wenn jemand ein Protokoll öffnet oder eine Rückfrage kommt — die Radix-Dialog-Kette // gehört nicht ins Start-Bündel. const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster }))) const Bestaetigen = lazy(() => import("./Bestaetigen").then((m) => ({ default: m.Bestaetigen }))) -/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */ -function Punkt({ h }: { h: Hinweis }) { - const aktion = useHinweisAktion() +/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. Seit 25.09.2026 mit Zustand + * (neu, gesehen, stumm bis …) und den Knöpfen dafür; stumme stehen gedämpft unten. */ +function Punkt({ h, basis }: { h: Hinweis; basis: string }) { + const aktion = useHinweisAktion(basis) const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null) const [frage, setFrage] = useState(null) const rot = h.stufe === "rot" @@ -23,7 +24,7 @@ function Punkt({ h }: { h: Hinweis }) { async function protokollZeigen() { try { - const e = await post(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`) + const e = await post(`${basis}/${encodeURIComponent(h.id)}/aktion/protokoll`) setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." }) } catch (f) { melden("fehler", (f as Error).message) @@ -41,9 +42,10 @@ function Punkt({ h }: { h: Hinweis }) { else ausfuehren(a) } + const stumm = h.zustand === "stumm" return (
+ stumm ? "bg-white/[0.03] opacity-70 ring-white/10" : rot ? "bg-rot/[0.07] ring-rot/35" : "bg-bernstein/[0.06] ring-bernstein/30")}>

{h.titel}

{seit(h.seit)} · zuletzt {vor(h.zuletzt)} + {h.zustand === "neu" && · neu} + {stumm && h.stumm_bis && ( + · stumm bis {flugplanZeit(new Date(h.stumm_bis * 1000).toISOString()).toLowerCase()} + )}

{h.text &&

{h.text}

} - {h.aktionen.length > 0 && ( -
- {h.aktionen.map((a, i) => ( - - ))} -
- )} +
+ {h.aktionen.map((a, i) => ( + + ))} + {h.zustand === "neu" && ( + + )} + {h.zustand && !stumm && ( + + )} + {stumm && ( + + )} +
{protokoll && ( setProtokoll(null)} /> @@ -95,7 +114,12 @@ function Punkt({ h }: { h: Hinweis }) { ) } -export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf: VerlaufEintrag[] }) { +export function Checkliste({ hinweise, verlauf, basis = "/api/hinweise" }: { + hinweise: Hinweis[] + verlauf: VerlaufEintrag[] + /** Wohin die Knöpfe gehen: KI-Box oder Homelab-Teil (seit 25.09.2026). */ + basis?: string +}) { const heute = new Date().toDateString() const erledigtHeute = verlauf.filter( (v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute, @@ -119,7 +143,7 @@ export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf return (
{hinweise.map((h) => ( - + ))}
) diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 1908a21..a63c332 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -190,17 +190,23 @@ function useAktion>( }) } -export const useHinweisAktion = () => +/** Knöpfe eines Hinweises. basis: „/api/hinweise“ (KI-Box) oder „/api/homelab/hinweise“ (seit 25.09.2026). Die + * Knöpfe für den Zustand (gesehen, stumm, laut) melden sich mit dem Satz des Wächters. */ +export const useHinweisAktion = (basis = "/api/hinweise") => useAktion( ({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) => - post(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`), + post(`${basis}/${encodeURIComponent(hinweis)}/aktion/${aktion}`), { erfolg: (v, e) => - v.aktion === "freigeben" && e.text ? e.text : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, - neuLaden: ["start"], + (v.aktion === "freigeben" || ZUSTAND_KNOEPFE.has(v.aktion)) && e.text + ? e.text + : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, + neuLaden: basis === "/api/hinweise" ? ["start"] : ["homelab-hinweise"], }, ) +const ZUSTAND_KNOEPFE = new Set(["gesehen", "stumm", "laut"]) + const UPDATE_PFADE: Record = { alle: "/api/maintenance/update-all", pruefen: "/api/maintenance/check-updates", diff --git a/frontend/src/lib/homelab.ts b/frontend/src/lib/homelab.ts index fd7e2dd..4b0cb94 100644 --- a/frontend/src/lib/homelab.ts +++ b/frontend/src/lib/homelab.ts @@ -137,7 +137,9 @@ export interface Lage { const hinweisZahl = (n: number) => `${n} Hinweis${n === 1 ? "" : "e"}` /** Die große Leuchte oben links — Störung vor Hinweis vor laufendem Update vor offenen Updates. */ -export function lage(stand: HomelabStand, hinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage { +export function lage(stand: HomelabStand, alleHinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage { + // Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen die Leuchte aber nicht aus. + const hinweise = alleHinweise.filter((h) => h.zustand !== "stumm") const stumm = stand.ziele.filter((z) => z.erreichbar === false) if (stumm.length > 0) { const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm` diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index f45a41b..794dbf0 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -22,6 +22,9 @@ export interface Hinweis { zuletzt: number takte?: number aktionen: HinweisAktion[] + /** Seit 25.09.2026: neu, gesehen oder stumm (bis stumm_bis, Unix-Sekunden). */ + zustand?: "neu" | "gesehen" | "stumm" + stumm_bis?: number } export interface VerlaufEintrag { diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index 226a731..9f515d6 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -1,5 +1,6 @@ import { Link, useNavigate } from "@tanstack/react-router" import { ArrowRight } from "lucide-react" +import { Checkliste } from "@/components/cockpit/Checkliste" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" import { DockerDienste } from "@/components/homelab/DockerDienste" @@ -85,20 +86,15 @@ function HomelabFehlt({ partner }: { partner: PartnerStatus }) { } /** Hinweise des Homelab-Wächters (die der KI-Box stehen in deren Cockpit). */ +/** Die Hinweise des Homelab-Wächters — seit 25.09.2026 dieselbe Liste wie bei der KI-Box, mit Knöpfen (auch + * „Gesehen“ und „24 h stumm“), die an den Homelab-Teil gehen. */ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) { - const rot = hinweise.some((h) => h.stufe === "rot") + const laut = hinweise.filter((h) => h.zustand !== "stumm") + const rot = laut.some((h) => h.stufe === "rot") return ( - {hinweise.length} offen}> -
    - {hinweise.map((h) => ( -
  • - - {h.titel} - - {h.text} -
  • - ))} -
+ {laut.length ? `${laut.length} offen` : "alle stumm"}}> + ) } diff --git a/frontend/src/views/Uebersicht.tsx b/frontend/src/views/Uebersicht.tsx index 8e3cb53..0c6ad87 100644 --- a/frontend/src/views/Uebersicht.tsx +++ b/frontend/src/views/Uebersicht.tsx @@ -230,7 +230,9 @@ function Bereichskarte({ bereich, verbindung, unterzeile, i, children }: { } /** Hinweise stehen in der Karte ganz oben — was Aufmerksamkeit braucht, zuerst. */ -function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" | "/homelab" }) { +function Hinweisliste({ hinweise: alle, ziel }: { hinweise: Hinweis[]; ziel: "/box" | "/homelab" }) { + // Stumm geschaltete (seit 25.09.2026) stehen im Cockpit, nicht auf der Übersicht. + const hinweise = alle.filter((h) => h.zustand !== "stumm") if (hinweise.length === 0) return null const sortiert = [...hinweise].sort((a, b) => Number(b.stufe === "rot") - Number(a.stufe === "rot")) return ( @@ -589,6 +591,7 @@ export function UebersichtSeite() { const geraete = homelab.data?.ziele ?? [] const stumm = geraete.filter((z) => z.erreichbar === false).map((z) => z.name) const alleHinweise = [...(start.data?.hinweise ?? []), ...(labHinweise.data?.hinweise ?? [])] + .filter((h) => h.zustand !== "stumm") const rot = alleHinweise.some((h) => h.stufe === "rot") const updates = stand.box.updates + stand.homelab.updates const lage = gesamtlage({