start: Radar-Kasten springt wirklich zum Radar, Checkliste fragt vor Neustart und Freigeben

- "Ansehen" im Radar-Kasten fuehrt nach /modelle#radar. Der Router sprang beim Rendern
  zum Anker, als dort noch "wird gelesen" stand; jetzt springt die Modelle-Seite, sobald
  alles ueber dem Radar geladen ist.
- Knoepfe der Checkliste, die einen Dienst (neu) starten, einen Job erneut laufen lassen
  oder ein Update freigeben, fragen vorher nach. Das Protokoll oeffnet weiter sofort; der
  Dialog wird erst bei Bedarf geladen und bleibt aus dem Start-Buendel.
- Scheitert nur ein spaeteres Nachladen, bleibt der letzte Stand der Startseite stehen
  statt einer Fehlerflaeche.
- Test fuer die Rueckfrage-Logik (wann gefragt wird, Checkliste erst nach "Ja",
  gesperrter Ja-Knopf, Ersatz-Hinweis beim Installieren).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:19:03 +02:00
co-authored by Claude Opus 5.5
parent 3c1d8bf88f
commit 739f27a7e5
5 changed files with 179 additions and 17 deletions
+41 -13
View File
@@ -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<HinweisAktion | null>(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<AktionsErgebnis>(`/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<AktionsErgebnis>(`/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}
</Button>
@@ -62,6 +74,22 @@ function Punkt({ h }: { h: Hinweis }) {
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
</Suspense>
)}
{frage && rueckfrage && (
<Suspense fallback={null}>
<Bestaetigen
offen
titel={rueckfrage.titel}
text={rueckfrage.text}
knopf={rueckfrage.knopf}
gesperrt={aktion.isPending}
onNein={() => setFrage(null)}
onJa={() => {
ausfuehren(frage)
setFrage(null)
}}
/>
</Suspense>
)}
</article>
)
}
@@ -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<typeof import("@/lib/api")>()), 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(
<QueryClientProvider client={new QueryClient()}>
<Checkliste hinweise={[abgestuerzt]} verlauf={[]} />
</QueryClientProvider>,
)
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(<Bestaetigen offen titel="Installieren?" text="…" knopf="Installieren" gesperrt onJa={onJa} onNein={() => {}} />)
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“.")
})
})