diff --git a/frontend/src/components/cockpit/Checkliste.tsx b/frontend/src/components/cockpit/Checkliste.tsx index ad2e6ba..69f779d 100644 --- a/frontend/src/components/cockpit/Checkliste.tsx +++ b/frontend/src/components/cockpit/Checkliste.tsx @@ -2,31 +2,43 @@ import { lazy, Suspense, useState } from "react" import { cn } from "cn" import { Button } from "@/components/ui/button" import { useHinweisAktion } from "@/lib/abfragen" +import { hinweisRueckfrage } from "@/lib/anzeige" import { post } from "@/lib/api" import { melden } from "@/lib/meldungen" -import type { AktionsErgebnis, Hinweis, VerlaufEintrag } from "@/lib/typen" +import type { AktionsErgebnis, Hinweis, HinweisAktion, VerlaufEintrag } from "@/lib/typen" import { seit, vor } from "@/lib/zeit" -// Erst laden, wenn jemand ein Protokoll öffnet (Radix-Dialog gehört nicht ins Start-Bündel). +// 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() const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null) + const [frage, setFrage] = useState(null) const rot = h.stufe === "rot" + const rueckfrage = frage ? hinweisRueckfrage(h, frage) : null - async function ausloesen(aid: string, label: string) { - if (aid === "protokoll") { - try { - const e = await post(`/api/hinweise/${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) - } - return + async function protokollZeigen() { + try { + const e = await post(`/api/hinweise/${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) } - aktion.mutate({ hinweis: h.id, aktion: aid, label }) + } + + function ausfuehren(a: HinweisAktion) { + aktion.mutate({ hinweis: h.id, aktion: a.id, label: a.label }) + } + + function klick(a: HinweisAktion) { + if (a.id === "protokoll") protokollZeigen() + // Was etwas neu startet, erneut laufen lässt oder freigibt, fragt vorher nach. + else if (hinweisRueckfrage(h, a)) setFrage(a) + else ausfuehren(a) } return ( @@ -50,7 +62,7 @@ function Punkt({ h }: { h: Hinweis }) { variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"} size="sm" disabled={aktion.isPending} - onClick={() => ausloesen(a.id, a.label)} + onClick={() => klick(a)} > {a.label} @@ -62,6 +74,22 @@ function Punkt({ h }: { h: Hinweis }) { setProtokoll(null)} /> )} + {frage && rueckfrage && ( + + setFrage(null)} + onJa={() => { + ausfuehren(frage) + setFrage(null) + }} + /> + + )} ) } diff --git a/frontend/src/components/cockpit/Rueckfrage.test.tsx b/frontend/src/components/cockpit/Rueckfrage.test.tsx new file mode 100644 index 0000000..713c7bb --- /dev/null +++ b/frontend/src/components/cockpit/Rueckfrage.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" +import { hinweisRueckfrage } from "@/lib/anzeige" +import { post } from "@/lib/api" +import type { Hinweis } from "@/lib/typen" +import { rollenErsatzText } from "@/lib/wartung" +import { Bestaetigen } from "./Bestaetigen" +import { Checkliste } from "./Checkliste" + +vi.mock("@/lib/api", async (original) => ({ ...(await original()), post: vi.fn() })) + +const abgestuerzt: Hinweis = { + id: "dienst:hermes-gateway", stufe: "rot", titel: "Hermes-Gateway ist abgestürzt", text: "failed", + quelle: "hermes-gateway", seit: 1_790_000_000, zuletzt: 1_790_000_060, + aktionen: [ + { id: "neustart", label: "Neu starten", dienst: "hermes-gateway" }, + { id: "protokoll", label: "Protokoll", dienst: "hermes-gateway" }, + ], +} + +describe("hinweisRueckfrage", () => { + it("fragt vor Neustart, Wiederholen und Freigeben, nie vor dem Protokoll", () => { + expect(hinweisRueckfrage(abgestuerzt, { id: "protokoll", label: "Protokoll" })).toBeNull() + expect(hinweisRueckfrage(abgestuerzt, { id: "neustart", label: "Neu starten" })?.text) + .toBe("„Hermes-Gateway ist abgestürzt“: Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar.") + expect(hinweisRueckfrage({ id: "dienst:x", titel: "X ist gestoppt" }, { id: "neustart", label: "Starten" })?.text) + .toContain("wird gestartet") + expect(hinweisRueckfrage({ id: "timer:mc2-backup", titel: "Sicherung scheitert" }, { id: "neustart", label: "Jetzt erneut laufen lassen" })?.text) + .toContain("läuft sofort noch einmal") + expect(hinweisRueckfrage({ id: "job:1", titel: "Job „News“" }, { id: "job-wiederholen", label: "Erneut ausführen" })?.titel) + .toBe("Job erneut ausführen?") + expect(hinweisRueckfrage({ id: "pin:engine", titel: "Motor bekommt keine Updates" }, { id: "freigeben", label: "Freigeben" })?.text) + .toContain("Sonntags-Lauf") + }) +}) + +describe("Checkliste", () => { + it("startet einen Dienst erst neu, wenn die Rückfrage bestätigt ist", async () => { + vi.mocked(post).mockResolvedValue({ ok: true }) + render( + + + , + ) + fireEvent.click(screen.getByRole("button", { name: "Neu starten" })) + const dialog = await screen.findByRole("dialog", { name: "Neu starten?" }) + expect(post).not.toHaveBeenCalled() + + fireEvent.click(within(dialog).getByRole("button", { name: "Neu starten" })) + await waitFor(() => expect(post).toHaveBeenCalledWith("/api/hinweise/dienst%3Ahermes-gateway/aktion/neustart")) + }) +}) + +describe("Bestaetigen", () => { + it("lässt den Ja-Knopf nicht los, solange die Aktion schon unterwegs ist", () => { + const onJa = vi.fn() + render( {}} />) + const knopf = screen.getByRole("button", { name: "Installieren" }) + expect(knopf).toBeDisabled() + fireEvent.click(knopf) + expect(onJa).not.toHaveBeenCalled() + }) +}) + +describe("rollenErsatzText", () => { + it("sagt deutlich, welches Modell die Rolle verliert", () => { + expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true })).toBe( + "Achtung: Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“ und ersetzt damit Qwen3.8-27B-Heretic. " + + "Das bisherige Modell bleibt auf der Platte. Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell.", + ) + expect(rollenErsatzText("org/Neu-GGUF", "Coder", undefined)).toBe("Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“.") + }) +}) diff --git a/frontend/src/lib/anzeige.ts b/frontend/src/lib/anzeige.ts index 32f3dca..e57d2c6 100644 --- a/frontend/src/lib/anzeige.ts +++ b/frontend/src/lib/anzeige.ts @@ -1,7 +1,7 @@ // Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne // DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann. -import type { AktionsErgebnis, Job, Modell, Start } from "./typen" +import type { AktionsErgebnis, Hinweis, HinweisAktion, Job, Modell, Start } from "./typen" // --- Rundinstrument ----------------------------------------------------------------- // 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung. @@ -54,6 +54,53 @@ export function hauptleuchte(z: Start["zustand"]) { : { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" } } +// --- Checkliste --------------------------------------------------------------------- + +export interface Rueckfrage { + titel: string + text: string + knopf: string +} + +/** + * Braucht ein Knopf eines Hinweises eine Rückfrage? Ja bei allem, was etwas neu startet, erneut + * laufen lässt oder freigibt (services/waechter.fuehre_aktion_aus). Nur das Protokoll öffnet sofort. + */ +export function hinweisRueckfrage( + h: Pick, + a: Pick, +): Rueckfrage | null { + const betrifft = `„${h.titel}“:` + switch (a.id) { + case "protokoll": + return null + case "neustart": + if (h.id.startsWith("timer:")) { + return { titel: `${a.label}?`, text: `${betrifft} Der Dienst läuft sofort noch einmal.`, knopf: a.label } + } + return { + titel: `${a.label}?`, + text: `${betrifft} Der Dienst wird ${/^starten/i.test(a.label) ? "gestartet" : "neu gestartet"} und ist dabei kurz nicht erreichbar.`, + knopf: a.label, + } + case "job-wiederholen": + return { + titel: "Job erneut ausführen?", + text: `${betrifft} Hermes führt den Job sofort noch einmal aus, mit allem, was er sonst auch tut — etwa Nachrichten verschicken.`, + knopf: a.label, + } + case "freigeben": + return { + titel: "Update freigeben?", + text: `${betrifft} Nach dem Freigeben versucht der nächste Sonntags-Lauf das Update wieder.`, + knopf: a.label, + } + default: + // Ein Knopf, den der Wächter neu gelernt hat: lieber einmal zu oft fragen als einmal zu wenig. + return { titel: `${a.label}?`, text: `${betrifft} ${a.label}.`, knopf: a.label } + } +} + // --- Aktionen und Aufträge (auch im Start-Bündel gebraucht) ---------------------------- /** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */ diff --git a/frontend/src/views/Modelle.tsx b/frontend/src/views/Modelle.tsx index 1668371..40aec5f 100644 --- a/frontend/src/views/Modelle.tsx +++ b/frontend/src/views/Modelle.tsx @@ -1,4 +1,5 @@ -import { useState } from "react" +import { useEffect, useState } from "react" +import { useLocation } from "@tanstack/react-router" import { Search } from "lucide-react" import { Auftraege } from "@/components/cockpit/Auftraege" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" @@ -6,7 +7,7 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" import { Button } from "@/components/ui/button" import { - useAufraeumen, useAufraeumenLoeschen, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, + useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart, } from "@/lib/abfragen" import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige" @@ -365,8 +366,19 @@ function AufraeumenPanel() { export function ModelleSeite() { const modelle = useModelle() const nutzung = useNutzung() + const radar = useRadar() + const jobs = useJobs() const [suche, setSuche] = useState(false) + // „Ansehen“ im Radar-Kasten der Startseite führt nach /modelle#radar. Der Router springt beim + // Rendern selbst zum Anker — da steht hier aber noch „wird gelesen“. Also springen wir, sobald + // alles über dem Radar geladen ist und die Seite nicht mehr wächst. + const hash = useLocation({ select: (l) => l.hash }) + const bereit = !!modelle.data && !nutzung.isPending && !radar.isPending && !jobs.isPending + useEffect(() => { + if (bereit && hash === "radar") document.getElementById("radar")?.scrollIntoView({ block: "start" }) + }, [bereit, hash]) + if (modelle.isPending) return // Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen. if (!modelle.data) return diff --git a/frontend/src/views/Start.tsx b/frontend/src/views/Start.tsx index 538893d..320d3a9 100644 --- a/frontend/src/views/Start.tsx +++ b/frontend/src/views/Start.tsx @@ -92,7 +92,9 @@ export function StartSeite() { const radar = useRadar() if (isPending) return - if (error || !start) return + // Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen — die Verbindungsanzeige + // oben sagt dann „Getrennt“. + if (!start) return const offen = start.hinweise.length return (