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:
co-authored by
Claude Opus 5.5
parent
45f11bc3dc
commit
995a0f34ee
@@ -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": {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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."}
|
||||
|
||||
@@ -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
|
||||
@@ -156,6 +156,7 @@ 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
|
||||
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."
|
||||
|
||||
@@ -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]"]
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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<HinweisAktion | null>(null)
|
||||
const rot = h.stufe === "rot"
|
||||
@@ -23,7 +24,7 @@ function Punkt({ h }: { h: Hinweis }) {
|
||||
|
||||
async function protokollZeigen() {
|
||||
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." })
|
||||
} 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 (
|
||||
<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">
|
||||
<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",
|
||||
@@ -53,9 +55,12 @@ function Punkt({ h }: { h: Hinweis }) {
|
||||
</div>
|
||||
<p className="ziffern text-[13px] text-text-3">
|
||||
{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>
|
||||
{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">
|
||||
{h.aktionen.map((a, i) => (
|
||||
<Button
|
||||
@@ -68,8 +73,22 @@ function Punkt({ h }: { h: Hinweis }) {
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{h.zustand === "neu" && (
|
||||
<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 && (
|
||||
<Suspense fallback={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 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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{hinweise.map((h) => (
|
||||
<Punkt key={h.id} h={h} />
|
||||
<Punkt key={h.id} h={h} basis={basis} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
({ 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) =>
|
||||
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<string, string> = {
|
||||
alle: "/api/maintenance/update-all",
|
||||
pruefen: "/api/maintenance/check-updates",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{hinweise.map((h) => (
|
||||
<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 titel="Hinweise" id="hinweise"
|
||||
rechts={<Chip art={rot ? "rot" : laut.length ? "bernstein" : "grau"}>{laut.length ? `${laut.length} offen` : "alle stumm"}</Chip>}>
|
||||
<Checkliste hinweise={hinweise} verlauf={[]} basis="/api/homelab/hinweise" />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user