From bc1106ddc8e5b43c482d456b949a5e504c5b4dc3 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 17:44:40 +0200 Subject: [PATCH] durchsicht: Rueckfragen passen aufs kleine Handy, ehrlichere Texte, Abbrechen nur wo es stoppt Befunde der Schlussdurchsicht (Attrappe des Backends, 320 x 568 bis 1440 px): - Rueckfragen: Bei 320 x 568 lagen "Installieren"/"Abbrechen" unter dem Bildschirmrand und liessen sich nicht erreichen (Dialog 791 px hoch, kein Scrollen). Jetzt hoechstens Bildschirmhoehe, der Text scrollt, die Knoepfe bleiben unten stehen. Gilt fuer alle. - Installieren: Die Rueckfrage sagt jetzt, dass llama-swap beim Eintragen sofort neu laedt (--watch-config): laufende Antworten von Lucy/OpenChamber brechen ab, mit Rolle nach dem Download noch einmal. Der Ersatz-Hinweis nennt, dass das bisherige Modell "heavy" behaelt. - Abbrechen nur noch bei Downloads (eigener Prozess). Updates und die Suche laufen als Shell-Kette, ein Abbruch stoppt dort nicht sicher alles. - Auftragstitel in den Worten der Oberflaeche ("Update Motor (llama.cpp)" statt "Engine-Update (llama.cpp Vulkan)"); "Laeuft seit ... min" zaehlt weiter, auch wenn der Auftrag nichts schreibt; Ueberschriftenebene passend zum Panel. - Updates: "Alles jetzt aktualisieren" richtet sich wie die Zeilen nach den frischen Details (vorher nur nach dem bis zu 10 min alten Start-Stand); "Nach Neuem suchen" sperrt, solange eine Suche laeuft (zwei apt-get update stolpern ueber die Sperre); Rueckfragetext sagt genau, was gesichert wird. - Nach Auftragsende nur nachladen, was betroffen ist (Download: Modelle/Platte; sonst Versionen/Verlauf/Sicherungen) - die Update-Pruefung fragt GitHub, apt und git. - Radar "Uebernehmen"/"Verwerfen" und "Endgueltig loeschen": Ja-Knopf gesperrt, solange die Aktion laeuft, und ein zweiter Klick waehrend des Schliessens loest nichts mehr aus. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/cockpit/Auftraege.tsx | 1 + .../src/components/cockpit/Bestaetigen.tsx | 7 +- .../src/components/cockpit/JobKarte.test.tsx | 21 ++++- frontend/src/components/cockpit/JobKarte.tsx | 26 +++++- .../components/cockpit/Rueckfrage.test.tsx | 15 +++- frontend/src/lib/abfragen.ts | 42 ++++++--- frontend/src/lib/wartung.ts | 86 ++++++++++++++----- frontend/src/views/ModellSuche.tsx | 12 ++- frontend/src/views/Modelle.tsx | 11 ++- frontend/src/views/Updates.tsx | 29 ++++--- 10 files changed, 188 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/cockpit/Auftraege.tsx b/frontend/src/components/cockpit/Auftraege.tsx index 2eaa696..7fb74e3 100644 --- a/frontend/src/components/cockpit/Auftraege.tsx +++ b/frontend/src/components/cockpit/Auftraege.tsx @@ -20,6 +20,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) { abbrechen.mutate({ id: job.id, titel: jobTitel(job) })} /> diff --git a/frontend/src/components/cockpit/Bestaetigen.tsx b/frontend/src/components/cockpit/Bestaetigen.tsx index 230ec01..2b7014a 100644 --- a/frontend/src/components/cockpit/Bestaetigen.tsx +++ b/frontend/src/components/cockpit/Bestaetigen.tsx @@ -23,13 +23,16 @@ export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen" }) { return ( !o && onNein()}> - + {/* Nie höher als der Bildschirm: Auf kleinen Handys (320 × 568) lagen die Knöpfe sonst außerhalb + und ließen sich nicht erreichen. Der Text scrollt, die Knöpfe bleiben unten stehen — bündig, + darum ohne Bodenpolster (sticky rechnet das Polster ein, darunter schien sonst Text durch). */} + {titel} {text} {children} - + diff --git a/frontend/src/components/cockpit/JobKarte.test.tsx b/frontend/src/components/cockpit/JobKarte.test.tsx index 85d1dfc..9fea111 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/wartung" +import { jobTitel, sichtbareJobs } from "@/lib/wartung" import type { Job } from "@/lib/typen" import { JobKarte } from "./JobKarte" @@ -44,10 +44,15 @@ describe("JobKarte", () => { expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument() }) - it("bietet bei Updates kein Abbrechen an und zeigt bei Fehlern die letzte Meldung", () => { + it("bietet bei Updates und der Suche kein Abbrechen an und zeigt bei Fehlern die letzte Meldung", () => { const { rerender } = render( {}} />, ) + expect(screen.getByText("Update Hermes")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument() + + // Die Suche läuft als Shell-Kette wie die Updates: kein Abbrechen, das womöglich nichts stoppt. + rerender( {}} />) expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument() rerender( @@ -63,6 +68,18 @@ describe("JobKarte", () => { }) }) +describe("jobTitel", () => { + it("nennt die Bausteine wie die Oberfläche und lässt Unbekanntes stehen", () => { + expect(jobTitel({ label: "Engine-Update (llama.cpp Vulkan)" })).toBe("Update Motor (llama.cpp)") + expect(jobTitel({ label: "Router-Update (llama-swap)" })).toBe("Update llama-swap") + expect(jobTitel({ label: "OS-Update (apt)" })).toBe("Update Betriebssystem") + expect(jobTitel({ label: "Alle aktualisieren (Engine (llama.cpp) → Router (llama-swap) → Hermes-Agent → OS (apt))" })) + .toBe("Alle aktualisieren (Motor → llama-swap → Hermes → Betriebssystem)") + expect(jobTitel({ label: "download unsloth/Qwen3.8-27B-GGUF" })).toBe("Download unsloth/Qwen3.8-27B-GGUF") + expect(jobTitel({ label: "Nach Updates suchen" })).toBe("Nach Updates suchen") + }) +}) + describe("sichtbareJobs", () => { it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => { const liste = sichtbareJobs( diff --git a/frontend/src/components/cockpit/JobKarte.tsx b/frontend/src/components/cockpit/JobKarte.tsx index c404221..94796c2 100644 --- a/frontend/src/components/cockpit/JobKarte.tsx +++ b/frontend/src/components/cockpit/JobKarte.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { cn } from "cn" import { Button } from "@/components/ui/button" import { laeuftNoch } from "@/lib/anzeige" @@ -22,14 +22,30 @@ const BALKEN: Record = { const zeitVon = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString()) +/** + * Die Uhrzeit in Sekunden, alle 30 s neu, solange `aktiv`. Ohne sie bliebe „Läuft seit 5 min“ stehen, + * wenn ein Auftrag eine Weile nichts schreibt: Die Karte zeichnet sonst nur neu, wenn sich der Auftrag ändert. + */ +function useJetzt(aktiv: boolean) { + const [jetzt, setJetzt] = useState(() => Date.now() / 1000) + useEffect(() => { + if (!aktiv) return + const t = window.setInterval(() => setJetzt(Date.now() / 1000), 30_000) + return () => window.clearInterval(t) + }, [aktiv]) + return jetzt +} + /** * Ein Hintergrund-Auftrag als Karte: Titel und Zustand, bei Downloads der Fortschritt, bei Fehlern * die letzte Meldung, auf Wunsch das Protokoll. Abbrechen nur, wo es wirklich stoppt (`abbrechbar`). + * `ebene` ist die Überschriftenebene (unter einem Panel-Titel h3, unter einer Zwischenüberschrift h4). */ -export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: { +export function JobKarte({ job, onAbbrechen, abbrechenGesperrt, ebene = 4 }: { job: Job onAbbrechen?: (job: Job) => void abbrechenGesperrt?: boolean + ebene?: 3 | 4 }) { const [frage, setFrage] = useState(false) const [protokoll, setProtokoll] = useState(false) @@ -38,13 +54,15 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: { const fortschritt = fortschrittText(job) const meldung = letzteMeldung(job) const laeuft = laeuftNoch(job) + const jetzt = useJetzt(laeuft) const darfAbbrechen = !!onAbbrechen && abbrechbar(job) const protokollZeilen = (job.log ?? []).slice(-12) + const Titel = ebene === 3 ? "h3" : "h4" return (
-

{titel}

+ {titel} {zustand.text}
@@ -66,7 +84,7 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {

{laeuft - ? fortschritt ?? laufText(job) + ? fortschritt ?? laufText(job, jetzt) : `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}

diff --git a/frontend/src/components/cockpit/Rueckfrage.test.tsx b/frontend/src/components/cockpit/Rueckfrage.test.tsx index 713c7bb..b9271f6 100644 --- a/frontend/src/components/cockpit/Rueckfrage.test.tsx +++ b/frontend/src/components/cockpit/Rueckfrage.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { hinweisRueckfrage } from "@/lib/anzeige" import { post } from "@/lib/api" import type { Hinweis } from "@/lib/typen" -import { rollenErsatzText } from "@/lib/wartung" +import { installierenUnterbrechung, rollenErsatzText } from "@/lib/wartung" import { Bestaetigen } from "./Bestaetigen" import { Checkliste } from "./Checkliste" @@ -63,11 +63,18 @@ describe("Bestaetigen", () => { }) describe("rollenErsatzText", () => { - it("sagt deutlich, welches Modell die Rolle verliert", () => { - expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true })).toBe( + it("sagt deutlich, welches Modell die Rolle verliert und was es behält", () => { + expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true, weitere: ["heavy"] })).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.", + "Das bisherige Modell bleibt auf der Platte. Die Rolle „heavy“ behält es. " + + "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“.") }) + + it("warnt vor der Unterbrechung durch das Neuladen von llama-swap", () => { + expect(installierenUnterbrechung(false)).toContain("bricht ab") + expect(installierenUnterbrechung(false)).not.toContain("noch einmal") + expect(installierenUnterbrechung(true)).toContain("Mit Rolle passiert das nach dem Download noch einmal.") + }) }) diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 87df48e..8ff964d 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -3,7 +3,7 @@ // Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur // Sicherheitsnetz und laufen fünfmal seltener. -import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { type QueryClient, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query" import { api, post } from "./api" import { fehlerText, laeuftNoch } from "./anzeige" import { melden } from "./meldungen" @@ -15,14 +15,24 @@ import { gbText } from "./zeit" const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms) -/** Was ein beendeter Auftrag verändert haben kann: Versionen, Modelle, Sicherungen, Platte. */ -const NACH_AUFTRAG = ["start", "update-details", "updates-verlauf", "models", "sicherungen", "aufraeumen"] - -/** Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu. */ +/** + * Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu: + * nach einem Download die Modelle und die Platte, nach Updates und der Suche die Versionen, der + * Verlauf und die Sicherungen. Gezielt, weil die Update-Prüfung teuer ist (GitHub, apt, git fetch). + */ function auftragsEndeMelden(qc: QueryClient, neu: Job[]) { const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? [] - const ebenFertig = neu.some((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a))) - if (ebenFertig) for (const k of NACH_AUFTRAG) qc.invalidateQueries({ queryKey: [k] }) + const ebenFertig = neu.filter((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a))) + if (ebenFertig.length === 0) return + const schluessel = new Set(["start"]) + for (const j of ebenFertig) { + // Wie wartung.jobDownload — hier ausgeschrieben, damit wartung.ts nicht ins Start-Bündel wandert. + const download = j.group === "download" || /^download\s/i.test(j.label) + for (const k of download ? ["models", "aufraeumen"] : ["update-details", "updates-verlauf", "sicherungen"]) { + schluessel.add(k) + } + } + for (const k of schluessel) qc.invalidateQueries({ queryKey: [k] }) } export const useStart = () => @@ -66,12 +76,18 @@ export const useSicherungen = () => queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"), }) -export const useUpdateDetails = (art: string | null) => - useQuery({ - queryKey: ["update-details", art], - queryFn: () => api(`/api/maintenance/update-details?kind=${art}`), - enabled: !!art, - staleTime: 600_000, +/** + * Was die Updates der Bausteine bringen würden — je Baustein eine eigene Abfrage, damit ein langsamer + * (Hermes: git fetch bis 25 s) die anderen nicht aufhält. Die Prüfung fragt GitHub, apt und git, + * darum gilt ein Ergebnis zehn Minuten. + */ +export const useUpdateDetails = (arten: readonly string[]) => + useQueries({ + queries: arten.map((art) => ({ + queryKey: ["update-details", art], + queryFn: () => api(`/api/maintenance/update-details?kind=${encodeURIComponent(art)}`), + staleTime: 600_000, + })), }) export interface UpdateLauf { diff --git a/frontend/src/lib/wartung.ts b/frontend/src/lib/wartung.ts index 2cec672..19cd586 100644 --- a/frontend/src/lib/wartung.ts +++ b/frontend/src/lib/wartung.ts @@ -46,22 +46,45 @@ export function pruefFehlerText(fehler: string): string { // --- 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) +/** Ist der Auftrag die Suche nach Updates (apt-get update)? Auch sie hat im Backend noch keine Gruppe. */ +export const jobSuche = (j: Pick) => j.group === "pruefen" || j.label === "Nach Updates suchen" + +/** + * Darf man den Auftrag abbrechen? Nur Downloads: Sie laufen als eigener Prozess (`hf download`), den + * der Abbruch wirklich beendet. Updates und die Suche laufen als Shell-Kette (`bash -c …`), und ob + * ein Abbruch dort alles stoppt, ist nicht sicher — bei Updates liefen apt und die Update-Skripte + * weiter, während die Karte „Abgebrochen“ zeigte. Scheitert ein Update, rollen Motor und llama-swap + * selbst zurück. + */ +export const abbrechbar = (j: Pick) => laeuftNoch(j) && jobDownload(j) + +// Die Beschriftungen setzt das Backend (services/maintenance.py, routers/models.py) mit technischen +// Namen; die Oberfläche sagt Motor, llama-swap, Hermes und Betriebssystem. +const TITEL: [RegExp, string][] = [ + [/^download\s+(.+)$/i, "Download $1"], + [/^OS-Update \(apt\)$/, "Update Betriebssystem"], + [/^Engine-Update \(llama\.cpp Vulkan\)$/, "Update Motor (llama.cpp)"], + [/^Router-Update \(llama-swap\)$/, "Update llama-swap"], + [/^Hermes-Agent-Update$/, "Update Hermes"], +] +const TEILE: [string, string][] = [ + ["Engine (llama.cpp)", "Motor"], + ["Router (llama-swap)", "llama-swap"], + ["Hermes-Agent", "Hermes"], + ["OS (apt)", "Betriebssystem"], +] + +/** Die Beschriftung eines Auftrags in den Worten der Oberfläche. Unbekanntes bleibt, wie es ist. */ +export function jobTitel(j: Pick): string { + for (const [muster, ersatz] of TITEL) if (muster.test(j.label)) return j.label.replace(muster, ersatz) + // „Alle aktualisieren (Engine (llama.cpp) → Router (llama-swap) → Hermes-Agent → OS (apt))“ + if (j.label.startsWith("Alle aktualisieren")) return TEILE.reduce((t, [alt, neu]) => t.split(alt).join(neu), j.label) + return 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" @@ -113,20 +136,41 @@ export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] { // --- Installieren ------------------------------------------------------------------- +/** Wer eine Rolle heute innehat — für den Hinweis vor dem Installieren. */ +export interface RollenInhaber { + name: string + /** Hängt ein Bild-Zwilling an seinen Gewichten (vision, coder-bild)? */ + zwilling: boolean + /** Rollen, die das Modell außerdem trägt (der Coder auch „heavy“) — die behält es. */ + weitere: string[] +} + /** * 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. + * nur dieser eine Rollen-Alias um (services/llamaswap.set_role_alias): Das bisherige Modell bleibt + * auf der Platte, behält seine übrigen Rollen, und sein Bild-Zwilling zeigt weiter auf dessen Gewichte. */ -export function rollenErsatzText( - neu: string, - rolle: string, - bisher: { name: string; zwilling: boolean } | undefined, -): string { +export function rollenErsatzText(neu: string, rolle: string, bisher: RollenInhaber | undefined): string { if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.` + const behaelt = bisher.weitere.length + ? ` Die Rolle${bisher.weitere.length === 1 ? "" : "n"} „${bisher.weitere.join("“, „")}“ behält es.` + : "" return ( `Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` + - "Das bisherige Modell bleibt auf der Platte." + + `Das bisherige Modell bleibt auf der Platte.${behaelt}` + (bisher.zwilling ? " Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell." : "") ) } + +/** + * Was das Eintragen in llama-swap unterwegs anrichtet: llama-swap läuft mit `--watch-config` und lädt + * bei jeder Änderung sofort neu (services/warmer.py, deploy/coder-vollkontext.sh) — laufende Antworten + * brechen ab, geladene Modelle fliegen raus. Mit Rolle schreibt das Backend nach dem Download noch einmal. + */ +export function installierenUnterbrechung(mitRolle: boolean): string { + return ( + "Kurze Unterbrechung: Beim Eintragen lädt llama-swap sofort neu. Was Lucy oder OpenChamber gerade " + + "schreiben, bricht ab, und die Modelle werden neu geladen." + + (mitRolle ? " Mit Rolle passiert das nach dem Download noch einmal." : "") + ) +} diff --git a/frontend/src/views/ModellSuche.tsx b/frontend/src/views/ModellSuche.tsx index b6e152a..b8780fb 100644 --- a/frontend/src/views/ModellSuche.tsx +++ b/frontend/src/views/ModellSuche.tsx @@ -5,18 +5,21 @@ import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { useInstallieren } from "@/lib/abfragen" import { api } from "@/lib/api" -import { rollenErsatzText } from "@/lib/wartung" +import { installierenUnterbrechung, type RollenInhaber, 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). */ +/** + * Rollen, die sich beim Installieren gleich vergeben lassen (Rollen-Alias → Name in der Oberfläche). + * Das Hirn steht hier (wie bisher) nicht zur Wahl; Hirn-Kandidaten testet das Radar nachts, bevor man sie übernimmt. + */ const ROLLEN: Record = { coder: "Coder" } -/** Wer eine Rolle heute innehat: Modellname und ob ein Bild-Zwilling daran hängt. */ -export type RollenStand = Record +/** Wer die Rollen heute innehat (Schlüssel = Rollen-Alias). */ +export type RollenStand = Record /** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: { @@ -188,6 +191,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: { {ersatz}

)} +

{installierenUnterbrechung(!!rolle)}

diff --git a/frontend/src/views/Modelle.tsx b/frontend/src/views/Modelle.tsx index 40aec5f..a706399 100644 --- a/frontend/src/views/Modelle.tsx +++ b/frontend/src/views/Modelle.tsx @@ -214,8 +214,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde ? `${k.name} wird Lucys Hirn${heute ? ` und löst ${heute} ab` : ""}. NerdQuiz und OpenChamber nutzen ab dann ebenfalls das neue Modell. Hermes startet dafür kurz neu, Lucy ist etwa eine Minute nicht erreichbar. Das alte Modell bleibt auf der Platte.` : `${k.name} wird der Coder${heute ? ` und löst ${heute} ab` : ""}. OpenChamber plant und baut ab dann damit. Das alte Modell bleibt auf der Platte.`} knopf="Übernehmen" + gesperrt={aktion.isPending} onNein={() => setFrage(null)} onJa={() => { + if (frage !== "uebernehmen") return // zweiter Klick eines Doppelklicks, der Dialog schließt schon aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name }) setFrage(null) }} @@ -226,8 +228,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde titel={`${k.name} verwerfen?`} text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal." knopf="Verwerfen" + gesperrt={aktion.isPending} onNein={() => setFrage(null)} onJa={() => { + if (frage !== "verwerfen") return aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name }) setFrage(null) }} @@ -353,6 +357,7 @@ function AufraeumenPanel() { titel={`${frage?.name ?? ""} löschen?`} text={`${frage?.hinweis ?? ""} Es werden ${frage?.groesse_gb.toLocaleString("de-DE") ?? ""} GB frei. Das lässt sich nicht rückgängig machen — zurück ginge es nur mit einem neuen Download.`} knopf="Endgültig löschen" + gesperrt={loeschen.isPending} onNein={() => setFrage(null)} onJa={() => { if (frage) loeschen.mutate(frage) @@ -432,7 +437,11 @@ export function ModelleSeite() { setSuche(false)} - rollenJetzt={{ coder: coder ? { name: coder.name, zwilling: !!coderZwilling } : undefined }} + rollenJetzt={{ + coder: coder + ? { name: coder.name, zwilling: !!coderZwilling, weitere: coder.aliases.filter((a) => a.toLowerCase() !== "coder") } + : undefined, + }} /> ) diff --git a/frontend/src/views/Updates.tsx b/frontend/src/views/Updates.tsx index 8487c1d..8afc0ba 100644 --- a/frontend/src/views/Updates.tsx +++ b/frontend/src/views/Updates.tsx @@ -1,4 +1,5 @@ import { useState } from "react" +import type { UseQueryResult } from "@tanstack/react-query" import { ChevronRight } from "lucide-react" import { cn } from "cn" import { Auftraege } from "@/components/cockpit/Auftraege" @@ -11,7 +12,7 @@ import { useUpdateStarten, useUpdateVerlauf, useZurueckspielen, } from "@/lib/abfragen" import { laeuftNoch } from "@/lib/anzeige" -import { detailsHabenNeues, pruefFehlerText, versionen } from "@/lib/wartung" +import { detailsHabenNeues, jobSuche, pruefFehlerText, versionen } from "@/lib/wartung" import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen" import { flugplanZeit } from "@/lib/zeit" @@ -110,9 +111,12 @@ function zusammenfassung(summary: string | undefined): string | null { return s } -function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFreigeben }: { +function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gesperrt, onStart, onFreigeben }: { id: string name: string + details: UseQueryResult + /** Gibt es Neues? Frische Details schlagen den gecachten Stand der Startseite (siehe UpdatesSeite). */ + hatUpdate: boolean /** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */ neuLautStart: string | undefined fest: Festgehalten | undefined @@ -120,13 +124,10 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei onStart: () => void onFreigeben: () => void }) { - const details = useUpdateDetails(id) const d = details.data const laedt = details.isPending const fehler = details.isError ? details.error.message : d?.error const v = d ? versionen(id, d) : null - // Frische Details schlagen den gecachten Stand der Startseite; wissen sie nichts, gilt der Start. - const hatUpdate = detailsHabenNeues(id, d) ?? neuLautStart != null const neu = hatUpdate ? v?.neu ?? neuLautStart ?? null : null const summary = zusammenfassung(d?.summary) @@ -269,12 +270,16 @@ export function UpdatesSeite() { const neuLautStart = new Map((start.data?.updates.bausteine ?? []).map((b) => [b.id, b.neu])) const festgehalten = new Map((start.data?.updates.festgehalten ?? []).map((f) => [f.baustein, f])) + // /api/start kennt nur einen bis zu zehn Minuten alten Stand. Die Details prüfen frisch — sagen + // sie etwas, gilt das (für die Zeilen UND für „Alles jetzt aktualisieren“), sonst der Start. + const details = useUpdateDetails(BAUSTEINE.map((b) => b.id)) + const hatNeues = BAUSTEINE.map((b, i) => detailsHabenNeues(b.id, details[i].data) ?? neuLautStart.has(b.id)) const alleJobs = jobs.data?.jobs ?? [] // Nur ein Update gleichzeitig (Riegel im Backend) — auch der Sonntags-Lauf zählt. const updateLaeuft = alleJobs.some((j) => j.group === "maintenance" && laeuftNoch(j)) || (start.data?.zustand.update_laeuft ?? false) - // Die Suche hat im Backend (noch) keine Gruppe; sobald sie "pruefen" heißt, sperrt der Knopf auch hier. - const suchtGerade = alleJobs.some((j) => j.group === "pruefen" && laeuftNoch(j)) + // Zwei Suchen gleichzeitig stolpern über die apt-Sperre — solange eine läuft, bleibt der Knopf zu. + const suchtGerade = alleJobs.some((j) => jobSuche(j) && laeuftNoch(j)) const gesperrt = updateLaeuft || updateStarten.isPending const zurueckMoeglich = sicherungen.data?.available ?? false @@ -296,11 +301,13 @@ export function UpdatesSeite() { Was sich ändert Aktion - {BAUSTEINE.map((b) => ( + {BAUSTEINE.map((b, i) => ( updateStarten.mutate("pruefen")} disabled={updateStarten.isPending || suchtGerade} > - Nach Neuem suchen + {suchtGerade ? "Sucht …" : "Nach Neuem suchen"} - @@ -413,7 +420,7 @@ export function UpdatesSeite() { setFrage(null)}