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
@@ -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 { 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,23 +55,40 @@ 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
key={a.id}
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
size="sm"
disabled={aktion.isPending}
onClick={() => klick(a)}
>
{a.label}
</Button>
))}
</div>
)}
<div className="flex flex-wrap gap-2.5 pt-1">
{h.aktionen.map((a, i) => (
<Button
key={a.id}
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
size="sm"
disabled={aktion.isPending}
onClick={() => klick(a)}
>
{a.label}
</Button>
))}
{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>
)
+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(
({ 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",
+3 -1
View File
@@ -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`
+3
View File
@@ -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 {
+8 -12
View File
@@ -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>
)
}
+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. */
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({