diff --git a/frontend/src/components/cockpit/Auftraege.tsx b/frontend/src/components/cockpit/Auftraege.tsx index 722a8b6..d53f01c 100644 --- a/frontend/src/components/cockpit/Auftraege.tsx +++ b/frontend/src/components/cockpit/Auftraege.tsx @@ -1,5 +1,5 @@ import { useJobAbbrechen, useJobs } from "@/lib/abfragen" -import { jobTitel, sichtbareJobs } from "@/lib/anzeige" +import { jobTitel, sichtbareJobs } from "@/lib/wartung" import { JobKarte } from "./JobKarte" import { Panel } from "./Panel" diff --git a/frontend/src/components/cockpit/JobKarte.test.tsx b/frontend/src/components/cockpit/JobKarte.test.tsx index b789a60..85d1dfc 100644 --- a/frontend/src/components/cockpit/JobKarte.test.tsx +++ b/frontend/src/components/cockpit/JobKarte.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react" -import { sichtbareJobs } from "@/lib/anzeige" +import { sichtbareJobs } from "@/lib/wartung" import type { Job } from "@/lib/typen" import { JobKarte } from "./JobKarte" diff --git a/frontend/src/components/cockpit/JobKarte.tsx b/frontend/src/components/cockpit/JobKarte.tsx index 53072e3..a785342 100644 --- a/frontend/src/components/cockpit/JobKarte.tsx +++ b/frontend/src/components/cockpit/JobKarte.tsx @@ -1,8 +1,7 @@ import { useState } from "react" import { Button } from "@/components/ui/button" -import { - abbrechbar, fortschrittText, jobDownload, jobTitel, laeuftNoch, laufText, letzteMeldung, -} from "@/lib/anzeige" +import { laeuftNoch } from "@/lib/anzeige" +import { abbrechbar, fortschrittText, jobDownload, jobTitel, laufText, letzteMeldung } from "@/lib/wartung" import type { Job, JobZustand } from "@/lib/typen" import { flugplanZeit } from "@/lib/zeit" import { Bestaetigen } from "./Bestaetigen" diff --git a/frontend/src/lib/anzeige.ts b/frontend/src/lib/anzeige.ts index c3d1876..32f3dca 100644 --- a/frontend/src/lib/anzeige.ts +++ b/frontend/src/lib/anzeige.ts @@ -1,8 +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, UpdateDetails } from "./typen" -import { gbText } from "./zeit" +import type { AktionsErgebnis, Job, Modell, Start } from "./typen" // --- Rundinstrument ----------------------------------------------------------------- // 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung. @@ -55,44 +54,7 @@ export function hauptleuchte(z: Start["zustand"]) { : { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" } } -// --- Updates ------------------------------------------------------------------------ - -/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen (die Details sind schon geladen). */ -export function versionen(id: string, d: UpdateDetails): { laeuft: string; neu: string | null } { - if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"}` : null } - // Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand. - if (id === "hermes") { - return { - laeuft: d.installed_version ? `v${d.installed_version}` : "–", - neu: d.behind ? `+${d.behind.toLocaleString("de-DE")} Änderungen` : null, - } - } - const pre = id === "engine" ? "b" : "v" - const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–" - const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build - return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null } -} - -/** Sagen die Details, dass es Neues gibt? null = weiß man nicht (Prüfung gescheitert oder unvollständig). */ -export function detailsHabenNeues(id: string, d: UpdateDetails | undefined): boolean | null { - if (!d || d.error) return null - if (id === "os") return typeof d.count === "number" ? d.count > 0 : null - if (id === "hermes") return typeof d.behind === "number" ? d.behind > 0 : null - if (d.latest_build == null || d.installed_build == null) return null - return d.latest_build > d.installed_build -} - -/** Eine gescheiterte Update-Prüfung in Worten („Konnte nicht prüfen: …“), ohne Befehlszeilen-Kauderwelsch. */ -export function pruefFehlerText(fehler: string): string { - const f = fehler.trim() - if (/timed out|timeout|zeitüberschreitung/i.test(f)) { - return "Konnte nicht prüfen: Die Gegenstelle hat nicht rechtzeitig geantwortet. Später klappt es meist wieder." - } - if (/name resolution|name or service not known|network is unreachable|connection (refused|reset)|nicht erreichbar/i.test(f)) { - return "Konnte nicht prüfen: Die Box kommt gerade nicht ins Internet." - } - return `Konnte nicht prüfen: ${f.length > 160 ? `${f.slice(0, 157)}…` : f}` -} +// --- Aktionen und Aufträge (auch im Start-Bündel gebraucht) ---------------------------- /** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */ export function fehlerText(e: AktionsErgebnis | null | undefined): string { @@ -104,76 +66,9 @@ export function fehlerText(e: AktionsErgebnis | null | undefined): string { return e?.detail || e?.err || e?.error || e?.text || "Das hat nicht geklappt." } -// --- Aufträge (services/jobengine.py) --------------------------------------------------- - /** Wartet oder läuft der Auftrag noch? */ export const laeuftNoch = (j: Pick) => j.state === "running" || j.state === "queued" -/** - * Darf man den Auftrag abbrechen? Updates nicht: Der Abbruch beendet nur die äußere Shell, apt - * und die Update-Skripte liefen weiter, und die Karte zeigte fälschlich „Abgebrochen“. Scheitert - * ein Update, rollen Motor und llama-swap selbst zurück. - */ -export const abbrechbar = (j: Pick) => laeuftNoch(j) && j.group !== "maintenance" - -/** Die Beschriftung eines Auftrags auf Deutsch („download org/repo“ heißt im Backend noch englisch). */ -export function jobTitel(j: Pick): string { - const m = /^download\s+(.+)$/i.exec(j.label) - return m ? `Download ${m[1]}` : j.label -} - -/** Ist der Auftrag ein Modell-Download? (Bis das Backend eine Gruppe setzt, verrät es nur die Beschriftung.) */ -export const jobDownload = (j: Pick) => j.group === "download" || /^download\s/i.test(j.label) - -/** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */ -export function restzeit(s: number): string { - if (s < 60) return "noch unter einer Minute" - const min = Math.round(s / 60) - if (min < 60) return `noch etwa ${min} min` - const h = Math.floor(min / 60) - const rest = min % 60 - return rest ? `noch etwa ${h} h ${rest} min` : `noch etwa ${h} h` -} - -/** „Läuft seit 12 min“ / „Wartet seit ein paar Sekunden“ für Aufträge ohne Fortschrittsanzeige. */ -export function laufText(j: Pick, jetzt = Date.now() / 1000): string { - const s = Math.max(0, jetzt - j.started_at) - const min = Math.floor(s / 60) - const dauer = s < 60 ? "ein paar Sekunden" : min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min` - return `${j.state === "queued" ? "Wartet" : "Läuft"} seit ${dauer}` -} - -/** „42 % · 12,3 GB von 29,1 GB · noch etwa 6 min“ — oder null, wenn der Auftrag keinen Fortschritt kennt. */ -export function fortschrittText(j: Job): string | null { - if (j.progress == null) return null - const teile = [`${Math.round(j.progress)} %`] - if (j.total_bytes) teile.push(`${gbText(j.done_bytes ?? 0, 1)} von ${gbText(j.total_bytes, 1)}`) - if (j.eta_s != null && laeuftNoch(j)) teile.push(restzeit(j.eta_s)) - return teile.join(" · ") -} - -/** Letzte aussagekräftige Zeile des Protokolls (nicht die Befehlszeile „$ …“, die der Auftrag zuerst schreibt). */ -export function letzteMeldung(j: Pick): string | null { - for (let i = (j.log?.length ?? 0) - 1; i >= 0; i--) { - const z = j.log[i].trim() - if (z && !z.startsWith("$ ")) return z - } - return null -} - -/** - * Welche Aufträge die Oberfläche zeigt: alle laufenden und wartenden (egal aus welcher Gruppe), - * dazu die drei zuletzt beendeten der letzten 24 Stunden — damit man sieht, wie es ausging. - */ -export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] { - const laufend = jobs.filter(laeuftNoch).sort((a, b) => a.started_at - b.started_at) - const beendet = jobs - .filter((j) => !laeuftNoch(j) && jetzt - (j.finished_at ?? j.started_at) < 86_400) - .sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at)) - .slice(0, 3) - return [...laufend, ...beendet] -} - // --- Modelle ------------------------------------------------------------------------ /** Rolle eines Modells aus seinen llama-swap-Aliasen. */ diff --git a/frontend/src/lib/wartung.ts b/frontend/src/lib/wartung.ts new file mode 100644 index 0000000..2cec672 --- /dev/null +++ b/frontend/src/lib/wartung.ts @@ -0,0 +1,132 @@ +// Anzeige-Logik der Wartungs-Ansichten (Updates, Aufträge, Installieren). Eigene Datei, weil +// anzeige.ts im Start-Bündel liegt — was nur die nachgeladenen Seiten brauchen, gehört nicht dorthin. + +import { laeuftNoch } from "./anzeige" +import type { Job, UpdateDetails } from "./typen" +import { gbText } from "./zeit" + +// --- Updates ------------------------------------------------------------------------ + +/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen (die Details sind schon geladen). */ +export function versionen(id: string, d: UpdateDetails): { laeuft: string; neu: string | null } { + if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"}` : null } + // Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand. + if (id === "hermes") { + return { + laeuft: d.installed_version ? `v${d.installed_version}` : "–", + neu: d.behind ? `+${d.behind.toLocaleString("de-DE")} Änderungen` : null, + } + } + const pre = id === "engine" ? "b" : "v" + const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–" + const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build + return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null } +} + +/** Sagen die Details, dass es Neues gibt? null = weiß man nicht (Prüfung gescheitert oder unvollständig). */ +export function detailsHabenNeues(id: string, d: UpdateDetails | undefined): boolean | null { + if (!d || d.error) return null + if (id === "os") return typeof d.count === "number" ? d.count > 0 : null + if (id === "hermes") return typeof d.behind === "number" ? d.behind > 0 : null + if (d.latest_build == null || d.installed_build == null) return null + return d.latest_build > d.installed_build +} + +/** Eine gescheiterte Update-Prüfung in Worten („Konnte nicht prüfen: …“), ohne Befehlszeilen-Kauderwelsch. */ +export function pruefFehlerText(fehler: string): string { + const f = fehler.trim() + if (/timed out|timeout|zeitüberschreitung/i.test(f)) { + return "Konnte nicht prüfen: Die Gegenstelle hat nicht rechtzeitig geantwortet. Später klappt es meist wieder." + } + if (/name resolution|name or service not known|network is unreachable|connection (refused|reset)|nicht erreichbar/i.test(f)) { + return "Konnte nicht prüfen: Die Box kommt gerade nicht ins Internet." + } + return `Konnte nicht prüfen: ${f.length > 160 ? `${f.slice(0, 157)}…` : f}` +} + +// --- Aufträge (services/jobengine.py) --------------------------------------------------- + +/** + * Darf man den Auftrag abbrechen? Updates nicht: Der Abbruch beendet nur die äußere Shell, apt + * und die Update-Skripte liefen weiter, und die Karte zeigte fälschlich „Abgebrochen“. Scheitert + * ein Update, rollen Motor und llama-swap selbst zurück. + */ +export const abbrechbar = (j: Pick) => laeuftNoch(j) && j.group !== "maintenance" + +/** Die Beschriftung eines Auftrags auf Deutsch („download org/repo“ heißt im Backend noch englisch). */ +export function jobTitel(j: Pick): string { + const m = /^download\s+(.+)$/i.exec(j.label) + return m ? `Download ${m[1]}` : j.label +} + +/** Ist der Auftrag ein Modell-Download? (Bis das Backend eine Gruppe setzt, verrät es nur die Beschriftung.) */ +export const jobDownload = (j: Pick) => j.group === "download" || /^download\s/i.test(j.label) + +/** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */ +export function restzeit(s: number): string { + if (s < 60) return "noch unter einer Minute" + const min = Math.round(s / 60) + if (min < 60) return `noch etwa ${min} min` + const h = Math.floor(min / 60) + const rest = min % 60 + return rest ? `noch etwa ${h} h ${rest} min` : `noch etwa ${h} h` +} + +/** „Läuft seit 12 min“ / „Wartet seit ein paar Sekunden“ für Aufträge ohne Fortschrittsanzeige. */ +export function laufText(j: Pick, jetzt = Date.now() / 1000): string { + const s = Math.max(0, jetzt - j.started_at) + const min = Math.floor(s / 60) + const dauer = s < 60 ? "ein paar Sekunden" : min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min` + return `${j.state === "queued" ? "Wartet" : "Läuft"} seit ${dauer}` +} + +/** „42 % · 12,3 GB von 29,1 GB · noch etwa 6 min“ — oder null, wenn der Auftrag keinen Fortschritt kennt. */ +export function fortschrittText(j: Job): string | null { + if (j.progress == null) return null + const teile = [`${Math.round(j.progress)} %`] + if (j.total_bytes) teile.push(`${gbText(j.done_bytes ?? 0, 1)} von ${gbText(j.total_bytes, 1)}`) + if (j.eta_s != null && laeuftNoch(j)) teile.push(restzeit(j.eta_s)) + return teile.join(" · ") +} + +/** Letzte aussagekräftige Zeile des Protokolls (nicht die Befehlszeile „$ …“, die der Auftrag zuerst schreibt). */ +export function letzteMeldung(j: Pick): string | null { + for (let i = (j.log?.length ?? 0) - 1; i >= 0; i--) { + const z = j.log[i].trim() + if (z && !z.startsWith("$ ")) return z + } + return null +} + +/** + * Welche Aufträge die Oberfläche zeigt: alle laufenden und wartenden (egal aus welcher Gruppe), + * dazu die drei zuletzt beendeten der letzten 24 Stunden — damit man sieht, wie es ausging. + */ +export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] { + const laufend = jobs.filter(laeuftNoch).sort((a, b) => a.started_at - b.started_at) + const beendet = jobs + .filter((j) => !laeuftNoch(j) && jetzt - (j.finished_at ?? j.started_at) < 86_400) + .sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at)) + .slice(0, 3) + return [...laufend, ...beendet] +} + +// --- Installieren ------------------------------------------------------------------- + +/** + * Hinweis vor dem Installieren mit Rolle: Wen ersetzt das neue Modell? Nach dem Download wandert + * nur der Rollen-Alias um (services/llamaswap.set_role) — das bisherige Modell bleibt auf der Platte + * und sein Bild-Zwilling zeigt weiter auf dessen Gewichte. + */ +export function rollenErsatzText( + neu: string, + rolle: string, + bisher: { name: string; zwilling: boolean } | undefined, +): string { + if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.` + return ( + `Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` + + "Das bisherige Modell bleibt auf der Platte." + + (bisher.zwilling ? " Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell." : "") + ) +} diff --git a/frontend/src/lib/zeit.test.ts b/frontend/src/lib/zeit.test.ts index 70dfe27..01f65e6 100644 --- a/frontend/src/lib/zeit.test.ts +++ b/frontend/src/lib/zeit.test.ts @@ -1,5 +1,6 @@ import { flugplanZeit, gbText, laufzeit, seit, vor } from "./zeit" -import { rolleVon, temperaturFarbe, versionen } from "./anzeige" +import { rolleVon, temperaturFarbe } from "./anzeige" +import { versionen } from "./wartung" import type { Modell } from "./typen" // Fester Bezugspunkt: Mittwoch, 23.09.2026, 20:45 Uhr Berliner Zeit. diff --git a/frontend/src/views/ModellSuche.tsx b/frontend/src/views/ModellSuche.tsx index 0bc4748..807b908 100644 --- a/frontend/src/views/ModellSuche.tsx +++ b/frontend/src/views/ModellSuche.tsx @@ -1,21 +1,35 @@ import { useState } from "react" -import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useQuery } from "@tanstack/react-query" +import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" -import { api, post } from "@/lib/api" -import { melden } from "@/lib/meldungen" -import { zahl } from "@/lib/zeit" +import { useInstallieren } from "@/lib/abfragen" +import { api } from "@/lib/api" +import { rollenErsatzText } from "@/lib/wartung" +import { gbText, zahl } from "@/lib/zeit" interface Treffer { repo: string; downloads: number; likes: number } +/** /api/hf/quants liefert heute nur die Namen. Kommen Größen dazu, zeigen Liste und Rückfrage sie an. */ interface Quant { quant?: string; name?: string; size_bytes?: number; total_bytes?: number } +/** Rollen, die sich beim Installieren gleich vergeben lassen (Rollen-Alias → Name in der Oberfläche). */ +const ROLLEN: Record = { coder: "Coder" } + +/** Wer eine Rolle heute innehat: Modellname und ob ein Bild-Zwilling daran hängt. */ +export type RollenStand = Record + /** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */ -export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) { - const qc = useQueryClient() +export function ModellSuche({ offen, onSchliessen, rollenJetzt }: { + offen: boolean + onSchliessen: () => void + rollenJetzt: RollenStand +}) { + const installieren = useInstallieren() const [eingabe, setEingabe] = useState("") const [begriff, setBegriff] = useState("") const [repo, setRepo] = useState(null) const [rolle, setRolle] = useState("") + const [auswahl, setAuswahl] = useState<{ quant: string; bytes?: number } | null>(null) const treffer = useQuery({ queryKey: ["hf-suche", begriff], @@ -28,20 +42,24 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies enabled: !!repo, }) - async function installieren(quant: string) { - if (!repo) return - try { - await post("/api/models/install", { repo, quant, role: rolle || null }) - melden("erfolg", `${repo} (${quant}) wird heruntergeladen. Den Fortschritt siehst du unter Updates.`) - qc.invalidateQueries({ queryKey: ["jobs"] }) - qc.invalidateQueries({ queryKey: ["models"] }) - onSchliessen() - } catch (f) { - melden("fehler", (f as Error).message) - } - } - const quantListe = (quants.data?.quants ?? []).map((q) => (typeof q === "string" ? { quant: q } : q)) + const rollenName = rolle ? ROLLEN[rolle] ?? rolle : null + const ersatz = rollenName && repo ? rollenErsatzText(repo, rollenName, rollenJetzt[rolle]) : null + + function starten() { + // Die Sperre gegen den Doppelklick: Solange ein Start unterwegs ist, geht kein zweiter los. + if (!repo || !auswahl || installieren.isPending) return + installieren.mutate( + { repo, quant: auswahl.quant, rolle: rolle || null }, + { + onSuccess: (e) => { + setAuswahl(null) + if (e.ok !== false) onSchliessen() + }, + onError: () => setAuswahl(null), + }, + ) + } return ( !o && onSchliessen()}> @@ -69,32 +87,61 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
- {treffer.isFetching &&

Suche läuft …

} - {!repo && treffer.data?.results.map((t) => ( - - ))} + {!repo && ( + <> + {treffer.isFetching &&

Suche läuft …

} + {!treffer.isFetching && treffer.isError && ( +

Die Suche ging nicht: {treffer.error.message}

+ )} + {!treffer.isFetching && treffer.data?.results.length === 0 && ( +

+ Keine Treffer. Versuch einen anderen Suchbegriff — oder Hugging Face ist gerade nicht erreichbar. +

+ )} + {treffer.data?.results.map((t) => ( + + ))} + + )} {repo && (
- {repo} + {repo}
-
)}
+ setAuswahl(null)} + onJa={starten} + > + {ersatz && ( +

+ {ersatz} +

+ )} +
) diff --git a/frontend/src/views/Modelle.tsx b/frontend/src/views/Modelle.tsx index da354c9..1668371 100644 --- a/frontend/src/views/Modelle.tsx +++ b/frontend/src/views/Modelle.tsx @@ -1,5 +1,6 @@ import { useState } from "react" import { Search } from "lucide-react" +import { Auftraege } from "@/components/cockpit/Auftraege" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" @@ -9,6 +10,7 @@ import { useRadarSuche, useStart, } from "@/lib/abfragen" import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige" +import { ApiFehler } from "@/lib/api" import { useLetzteMetrik } from "@/lib/strom" import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen" import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit" @@ -98,8 +100,18 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: { ) } -function NutzungsPanel({ nutzung }: { nutzung: Nutzung | undefined }) { - if (!nutzung) return null +function NutzungsPanel({ nutzung, laedt, fehler }: { nutzung: Nutzung | undefined; laedt: boolean; fehler: string | null }) { + if (!nutzung) { + return ( + + {laedt ? ( +

Wird gelesen …

+ ) : ( +

Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}

+ )} +
+ ) + } const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen)) const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0)) const maxStunde = Math.max(1, ...stunden) @@ -226,10 +238,16 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) { const radar = useRadar() const suche = useRadarSuche() - if (radar.isError) { + if (radar.isError && !radar.data) { + // 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar. + const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404 return ( - -

Das Radar ist auf dieser Box noch nicht eingerichtet.

+ + {fehlt ? ( +

Das Radar ist auf dieser Box noch nicht eingerichtet.

+ ) : ( +

Das Radar antwortet gerade nicht: {radar.error.message}

+ )}
) } @@ -238,7 +256,12 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | suche.mutate(undefined)} disabled={suche.isPending}>Jetzt suchen} + className="scroll-mt-4" + rechts={ + + } >

Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche. @@ -281,7 +304,13 @@ function AufraeumenPanel() { const [frage, setFrage] = useState(null) const kandidaten = liste.data?.kandidaten ?? [] const summe = kandidaten.reduce((s, k) => s + k.groesse_gb, 0) - if (liste.isError) return null + if (liste.isError && !liste.data) { + return ( + +

Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}

+
+ ) + } return ( - if (modelle.error || !modelle.data) return + // Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen. + if (!modelle.data) return const liste = modelle.data.models const laufend = modelle.data.running @@ -356,19 +386,21 @@ export function ModelleSeite() { return ( <> +
- +
setSuche(true)}> Modelle selbst suchen} > + {weitere.length === 0 &&

Keine weiteren Einträge.

}
    {weitere.map((m) => (
  • @@ -385,7 +417,11 @@ export function ModelleSeite() {
- setSuche(false)} /> + setSuche(false)} + rollenJetzt={{ coder: coder ? { name: coder.name, zwilling: !!coderZwilling } : undefined }} + /> ) } diff --git a/frontend/src/views/Updates.tsx b/frontend/src/views/Updates.tsx index 766a97b..65b6d0f 100644 --- a/frontend/src/views/Updates.tsx +++ b/frontend/src/views/Updates.tsx @@ -10,7 +10,8 @@ import { type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails, useUpdateStarten, useUpdateVerlauf, useZurueckspielen, } from "@/lib/abfragen" -import { detailsHabenNeues, laeuftNoch, pruefFehlerText, versionen } from "@/lib/anzeige" +import { laeuftNoch } from "@/lib/anzeige" +import { detailsHabenNeues, pruefFehlerText, versionen } from "@/lib/wartung" import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen" import { flugplanZeit } from "@/lib/zeit" @@ -149,7 +150,7 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei was = "Aktuell." } else { was = ( - +
{fehler && {pruefFehlerText(fehler)}} {d && } {id === "os" ? ( @@ -160,7 +161,7 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei ) : summary ? ( ) : null} - +
) } @@ -179,14 +180,14 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei
{laeuftZelle} {neuZelle} - +
{fest && ( Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}. )} {was} - +
{(fest || hatUpdate) && ( {fest && (