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 { cn } from "cn"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useHinweisAktion } from "@/lib/abfragen" import { useHinweisAktion } from "@/lib/abfragen"
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, VerlaufEintrag } from "@/lib/typen" import type { AktionsErgebnis, Hinweis, HinweisAktion, VerlaufEintrag } from "@/lib/typen"
import { seit, vor } from "@/lib/zeit" 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 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. */ /** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
function Punkt({ h }: { h: Hinweis }) { function Punkt({ h }: { h: Hinweis }) {
const aktion = useHinweisAktion() const aktion = useHinweisAktion()
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 rot = h.stufe === "rot" const rot = h.stufe === "rot"
const rueckfrage = frage ? hinweisRueckfrage(h, frage) : null
async function ausloesen(aid: string, label: string) { async function protokollZeigen() {
if (aid === "protokoll") { try {
try { const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
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." })
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)
}
return
} }
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 ( return (
@@ -50,7 +62,7 @@ function Punkt({ h }: { h: Hinweis }) {
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={() => ausloesen(a.id, a.label)} onClick={() => klick(a)}
> >
{a.label} {a.label}
</Button> </Button>
@@ -62,6 +74,22 @@ function Punkt({ h }: { h: Hinweis }) {
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} /> <Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
</Suspense> </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> </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“.")
})
})
+48 -1
View File
@@ -1,7 +1,7 @@
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne // 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. // 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 ----------------------------------------------------------------- // --- Rundinstrument -----------------------------------------------------------------
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung. // 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" } : { 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<Hinweis, "id" | "titel">,
a: Pick<HinweisAktion, "id" | "label">,
): 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) ---------------------------- // --- Aktionen und Aufträge (auch im Start-Bündel gebraucht) ----------------------------
/** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */ /** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */
+14 -2
View File
@@ -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 { Search } from "lucide-react"
import { Auftraege } from "@/components/cockpit/Auftraege" import { Auftraege } from "@/components/cockpit/Auftraege"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" 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 { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
useAufraeumen, useAufraeumenLoeschen, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
useRadarSuche, useStart, useRadarSuche, useStart,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige" import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
@@ -365,8 +366,19 @@ function AufraeumenPanel() {
export function ModelleSeite() { export function ModelleSeite() {
const modelle = useModelle() const modelle = useModelle()
const nutzung = useNutzung() const nutzung = useNutzung()
const radar = useRadar()
const jobs = useJobs()
const [suche, setSuche] = useState(false) 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 <Zustandsfeld text="Modelle werden gelesen …" /> if (modelle.isPending) return <Zustandsfeld text="Modelle werden gelesen …" />
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen. // Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen.
if (!modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} /> if (!modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
+3 -1
View File
@@ -92,7 +92,9 @@ export function StartSeite() {
const radar = useRadar() const radar = useRadar()
if (isPending) return <Zustandsfeld text="Die Box meldet sich gleich …" /> if (isPending) return <Zustandsfeld text="Die Box meldet sich gleich …" />
if (error || !start) return <Zustandsfeld fehler text={`MC2 antwortet nicht: ${error?.message ?? "keine Daten"}`} /> // Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen — die Verbindungsanzeige
// oben sagt dann „Getrennt“.
if (!start) return <Zustandsfeld fehler text={`MC2 antwortet nicht: ${error?.message ?? "keine Daten"}`} />
const offen = start.hinweise.length const offen = start.hinweise.length
return ( return (