Welle 2 · Befundliste mit Ablauf: neu, gesehen, 24 h stumm; Puffer gegen Flackern

Jeder Hinweis hat jetzt einen Zustand (neu, gesehen, stumm bis …) mit Knöpfen in beiden Cockpits; das Homelab-Cockpit
nutzt dieselbe Liste wie die KI-Box (Knöpfe gehen an den Homelab-Teil, neue Route). Stumm heißt: keine
Telegram-Erinnerungen, unten in der Liste, keine Warnleuchte — verschärft er sich von gelb auf rot, gilt stumm nicht
mehr. Ein Hinweis ist erst erledigt, wenn sein Befund drei Takte am Stück fehlt: Kurze Wackler bringen kein „Erledigt“
und gleich danach keinen neuen Alarm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 17:54:56 +02:00
co-authored by Claude Opus 5.5
parent 45f11bc3dc
commit 995a0f34ee
13 changed files with 306 additions and 49 deletions
+4 -2
View File
@@ -132,8 +132,10 @@ def _box() -> dict:
def start() -> dict: def start() -> dict:
stand = waechter.lese_stand() stand = waechter.lese_stand()
u = box_updates.gecacht() u = box_updates.gecacht()
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot") # Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen aber die Leuchte nicht aus.
anzahl = len(stand["hinweise"]) 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 wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
return { return {
"zustand": { "zustand": {
+7
View File
@@ -110,6 +110,13 @@ def hinweise() -> dict:
return waechter.lese_stand() 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") @router.get("/laeufe")
def laeufe() -> dict: def laeufe() -> dict:
return {"laeufe": updates.laeufe()} return {"laeufe": updates.laeufe()}
+78 -5
View File
@@ -14,7 +14,10 @@ gesehen. Dieser Wächter schaut deshalb breiter hin:
• Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb) • 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 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 (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 bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
zusätzlich an Telegram und in Lucys Briefkasten. 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. # „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. # 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"))) 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 # Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update
# auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird. # auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird.
PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5")) PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5"))
@@ -717,23 +726,27 @@ def takt() -> None:
hinweise: dict = _stand["hinweise"] hinweise: dict = _stand["hinweise"]
kandidaten: dict = _stand["kandidaten"] kandidaten: dict = _stand["kandidaten"]
aktuell = {b.id for b in befunde} aktuell = {b.id for b in befunde}
zustaende = hinweis_zustaende()
for b in befunde: for b in befunde:
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt}) k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
k["takte"] += 1 k["takte"] += 1
if not update and b.auto: if not update and b.auto:
_selbst_beheben(b, jetzt) _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 continue
h = hinweise.get(b.id) h = hinweise.get(b.id)
neu = h is None neu = h is None
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0} 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, h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"]) aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
h.pop("fehlt", None)
hinweise[b.id] = h hinweise[b.id] = h
if neu: if neu:
_verlauf("neu", b.id, b.titel, b.stufe) _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: " vorsatz = "" if neu else "Immer noch: "
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}") _telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
h["gemeldet"] = jetzt h["gemeldet"] = jetzt
@@ -745,7 +758,11 @@ def takt() -> None:
continue continue
if update and hid.startswith(("dienst:", "kern:")): if update and hid.startswith(("dienst:", "kern:")):
continue continue
hinweise[hid]["fehlt"] = hinweise[hid].get("fehlt", 0) + 1
if hinweise[hid]["fehlt"] < ERLEDIGT_NACH:
continue
h = hinweise.pop(hid) 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"))
if h.get("stufe") == "rot" and h.get("gemeldet"): if h.get("stufe") == "rot" and h.get("gemeldet"):
_telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.") _telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.")
@@ -772,6 +789,51 @@ async def waechter_loop() -> None:
await asyncio.sleep(INTERVAL) 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) ------------------------------------------------ # --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
def lese_stand() -> dict: def lese_stand() -> dict:
@@ -780,10 +842,19 @@ def lese_stand() -> dict:
daten = json.loads(STORE_PATH.read_text(encoding="utf-8")) daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError): except (OSError, ValueError):
daten = {} daten = {}
hinweise = sorted((daten.get("hinweise") or {}).values(), jetzt = time.time()
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0))) zustaende = hinweis_zustaende()
hinweise = list((daten.get("hinweise") or {}).values())
for h in hinweise: for h in hinweise:
h.pop("gemeldet", None) 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 { return {
"hinweise": hinweise, "hinweise": hinweise,
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])), "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) hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
if hinweis is None: if hinweis is None:
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."} 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) aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
if aktion is None: if aktion is None:
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."} return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
+95
View File
@@ -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
+2 -1
View File
@@ -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] assert len(meldungen) == 1 and "Proxmox-PC (http://pve:9001) ist nicht erreichbar" in meldungen[0]
erreichbar[0] = True 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 waechter._stand["hinweise"] == {}
assert meldungen[-1] == "Erledigt: Homelab antwortet nicht." assert meldungen[-1] == "Erledigt: Homelab antwortet nicht."
+4
View File
@@ -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 "kern:engine" in waechter._stand["hinweise"]
assert meldungen == ["[Box-Problem]"] assert meldungen == ["[Box-Problem]"]
# Seit 25.09.2026 mit Puffer: erst nach ERLEDIGT_NACH Takten ohne Befund ist der Hinweis erledigt.
defekt[0] = False 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() waechter.takt()
assert waechter._stand["hinweise"] == {} assert waechter._stand["hinweise"] == {}
assert meldungen == ["[Box-Problem]", "[Box wieder ok]"] assert meldungen == ["[Box-Problem]", "[Box wieder ok]"]
@@ -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<typeof import("@/lib/api")>()), 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(
<QueryClientProvider client={new QueryClient()}>
<Checkliste hinweise={[h]} verlauf={[]} basis={basis} />
</QueryClientProvider>,
)
}
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"))
})
})
+47 -23
View File
@@ -6,16 +6,17 @@ import { hinweisRueckfrage } from "@/lib/anzeige"
import { post } from "@/lib/api" import { post } from "@/lib/api"
import { melden } from "@/lib/meldungen" import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis, Hinweis, HinweisAktion, VerlaufEintrag } from "@/lib/typen" 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 // Erst laden, wenn jemand ein Protokoll öffnet oder eine Rückfrage kommt — die Radix-Dialog-Kette
// gehört nicht ins Start-Bündel. // gehört nicht ins Start-Bündel.
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster }))) const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
const Bestaetigen = lazy(() => import("./Bestaetigen").then((m) => ({ default: m.Bestaetigen }))) const Bestaetigen = lazy(() => import("./Bestaetigen").then((m) => ({ default: m.Bestaetigen })))
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */ /** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. Seit 25.09.2026 mit Zustand
function Punkt({ h }: { h: Hinweis }) { * (neu, gesehen, stumm bis …) und den Knöpfen dafür; stumme stehen gedämpft unten. */
const aktion = useHinweisAktion() function Punkt({ h, basis }: { h: Hinweis; basis: string }) {
const aktion = useHinweisAktion(basis)
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null) const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
const [frage, setFrage] = useState<HinweisAktion | null>(null) const [frage, setFrage] = useState<HinweisAktion | null>(null)
const rot = h.stufe === "rot" const rot = h.stufe === "rot"
@@ -23,7 +24,7 @@ function Punkt({ h }: { h: Hinweis }) {
async function protokollZeigen() { async function protokollZeigen() {
try { try {
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`) const e = await post<AktionsErgebnis>(`${basis}/${encodeURIComponent(h.id)}/aktion/protokoll`)
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." }) setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
} catch (f) { } catch (f) {
melden("fehler", (f as Error).message) melden("fehler", (f as Error).message)
@@ -41,9 +42,10 @@ function Punkt({ h }: { h: Hinweis }) {
else ausfuehren(a) else ausfuehren(a)
} }
const stumm = h.zustand === "stumm"
return ( return (
<article className={cn("flex flex-col gap-2.5 rounded-2xl px-4 py-3.5 ring-1 ring-inset", <article className={cn("flex flex-col gap-2.5 rounded-2xl px-4 py-3.5 ring-1 ring-inset",
rot ? "bg-rot/[0.07] ring-rot/35" : "bg-bernstein/[0.06] ring-bernstein/30")}> 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")}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 font-sans text-[16.5px] leading-snug font-semibold">{h.titel}</h3> <h3 className="min-w-0 flex-1 font-sans text-[16.5px] leading-snug font-semibold">{h.titel}</h3>
<span className={cn("etikett shrink-0 rounded-full px-2.5 py-0.5 text-[10.5px] tracking-[0.12em] ring-1 ring-inset", <span className={cn("etikett shrink-0 rounded-full px-2.5 py-0.5 text-[10.5px] tracking-[0.12em] ring-1 ring-inset",
@@ -53,23 +55,40 @@ function Punkt({ h }: { h: Hinweis }) {
</div> </div>
<p className="ziffern text-[13px] text-text-3"> <p className="ziffern text-[13px] text-text-3">
{seit(h.seit)} · zuletzt {vor(h.zuletzt)} {seit(h.seit)} · zuletzt {vor(h.zuletzt)}
{h.zustand === "neu" && <span className="font-sans text-cyan-text"> · neu</span>}
{stumm && h.stumm_bis && (
<span className="font-sans"> · stumm bis {flugplanZeit(new Date(h.stumm_bis * 1000).toISOString()).toLowerCase()}</span>
)}
</p> </p>
{h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>} {h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>}
{h.aktionen.length > 0 && ( <div className="flex flex-wrap gap-2.5 pt-1">
<div className="flex flex-wrap gap-2.5 pt-1"> {h.aktionen.map((a, i) => (
{h.aktionen.map((a, i) => ( <Button
<Button key={a.id}
key={a.id} variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"} size="sm"
size="sm" disabled={aktion.isPending}
disabled={aktion.isPending} onClick={() => klick(a)}
onClick={() => klick(a)} >
> {a.label}
{a.label} </Button>
</Button> ))}
))} {h.zustand === "neu" && (
</div> <Button variant="ghost" size="sm" disabled={aktion.isPending} onClick={() => ausfuehren({ id: "gesehen", label: "Gesehen" })}>
)} Gesehen
</Button>
)}
{h.zustand && !stumm && (
<Button variant="ghost" size="sm" disabled={aktion.isPending} onClick={() => ausfuehren({ id: "stumm", label: "24 h stumm" })}>
24 h stumm
</Button>
)}
{stumm && (
<Button variant="ghost" size="sm" disabled={aktion.isPending} onClick={() => ausfuehren({ id: "laut", label: "Wieder melden" })}>
Wieder melden
</Button>
)}
</div>
{protokoll && ( {protokoll && (
<Suspense fallback={null}> <Suspense fallback={null}>
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} /> <Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => 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 heute = new Date().toDateString()
const erledigtHeute = verlauf.filter( const erledigtHeute = verlauf.filter(
(v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute, (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 ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{hinweise.map((h) => ( {hinweise.map((h) => (
<Punkt key={h.id} h={h} /> <Punkt key={h.id} h={h} basis={basis} />
))} ))}
</div> </div>
) )
+10 -4
View File
@@ -190,17 +190,23 @@ function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
}) })
} }
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( useAktion(
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) => ({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`), post<AktionsErgebnis>(`${basis}/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
{ {
erfolg: (v, e) => erfolg: (v, e) =>
v.aktion === "freigeben" && e.text ? e.text : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, (v.aktion === "freigeben" || ZUSTAND_KNOEPFE.has(v.aktion)) && e.text
neuLaden: ["start"], ? 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<string, string> = { const UPDATE_PFADE: Record<string, string> = {
alle: "/api/maintenance/update-all", alle: "/api/maintenance/update-all",
pruefen: "/api/maintenance/check-updates", pruefen: "/api/maintenance/check-updates",
+3 -1
View File
@@ -137,7 +137,9 @@ export interface Lage {
const hinweisZahl = (n: number) => `${n} Hinweis${n === 1 ? "" : "e"}` 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. */ /** 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) const stumm = stand.ziele.filter((z) => z.erreichbar === false)
if (stumm.length > 0) { if (stumm.length > 0) {
const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm` const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm`
+3
View File
@@ -22,6 +22,9 @@ export interface Hinweis {
zuletzt: number zuletzt: number
takte?: number takte?: number
aktionen: HinweisAktion[] 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 { export interface VerlaufEintrag {
+8 -12
View File
@@ -1,5 +1,6 @@
import { Link, useNavigate } from "@tanstack/react-router" import { Link, useNavigate } from "@tanstack/react-router"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
import { Checkliste } from "@/components/cockpit/Checkliste"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel" import { Panel } from "@/components/cockpit/Panel"
import { DockerDienste } from "@/components/homelab/DockerDienste" 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). */ /** 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[] }) { 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 ( return (
<Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}> <Panel titel="Hinweise" id="hinweise"
<ul className="m-0 flex list-none flex-col p-0"> rechts={<Chip art={rot ? "rot" : laut.length ? "bernstein" : "grau"}>{laut.length ? `${laut.length} offen` : "alle stumm"}</Chip>}>
{hinweise.map((h) => ( <Checkliste hinweise={hinweise} verlauf={[]} basis="/api/homelab/hinweise" />
<li key={h.id} className="flex flex-col gap-1 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-0">
<span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}>
{h.titel}
</span>
<span className="text-sm text-text-2">{h.text}</span>
</li>
))}
</ul>
</Panel> </Panel>
) )
} }
+4 -1
View File
@@ -230,7 +230,9 @@ function Bereichskarte({ bereich, verbindung, unterzeile, i, children }: {
} }
/** Hinweise stehen in der Karte ganz oben — was Aufmerksamkeit braucht, zuerst. */ /** 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 if (hinweise.length === 0) return null
const sortiert = [...hinweise].sort((a, b) => Number(b.stufe === "rot") - Number(a.stufe === "rot")) const sortiert = [...hinweise].sort((a, b) => Number(b.stufe === "rot") - Number(a.stufe === "rot"))
return ( return (
@@ -589,6 +591,7 @@ export function UebersichtSeite() {
const geraete = homelab.data?.ziele ?? [] const geraete = homelab.data?.ziele ?? []
const stumm = geraete.filter((z) => z.erreichbar === false).map((z) => z.name) const stumm = geraete.filter((z) => z.erreichbar === false).map((z) => z.name)
const alleHinweise = [...(start.data?.hinweise ?? []), ...(labHinweise.data?.hinweise ?? [])] const alleHinweise = [...(start.data?.hinweise ?? []), ...(labHinweise.data?.hinweise ?? [])]
.filter((h) => h.zustand !== "stumm")
const rot = alleHinweise.some((h) => h.stufe === "rot") const rot = alleHinweise.some((h) => h.stufe === "rot")
const updates = stand.box.updates + stand.homelab.updates const updates = stand.box.updates + stand.homelab.updates
const lage = gesamtlage({ const lage = gesamtlage({