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:
co-authored by
Claude Opus 5.5
parent
3c1d8bf88f
commit
739f27a7e5
@@ -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“.")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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"}`} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user