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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 17:44:40 +02:00
co-authored by Claude Opus 5.5
parent 83941847ab
commit bc1106ddc8
10 changed files with 188 additions and 62 deletions
@@ -20,6 +20,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
<JobKarte
key={j.id}
job={j}
ebene={alsPanel ? 3 : 4}
abbrechenGesperrt={abbrechen.isPending}
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
/>
@@ -23,13 +23,16 @@ export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen"
}) {
return (
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
<DialogContent className="border-linie bg-panel sm:max-w-lg">
{/* 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). */}
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto border-linie bg-panel pb-0 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle>
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader>
{children}
<DialogFooter className="gap-2 sm:gap-2">
<DialogFooter className="sticky bottom-0 mb-0 gap-2 bg-erhaben sm:gap-2">
<Button variant="outline" onClick={onNein}>
{neinKnopf}
</Button>
@@ -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(
<JobKarte job={job({ label: "Hermes-Agent-Update", group: "maintenance" })} onAbbrechen={() => {}} />,
)
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(<JobKarte job={job({ label: "Nach Updates suchen" })} onAbbrechen={() => {}} />)
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(
+22 -4
View File
@@ -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<JobZustand, string> = {
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 (
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5">
<div className="flex items-start justify-between gap-3">
<h4 className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</h4>
<Titel className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</Titel>
<Chip art={zustand.art}>{zustand.text}</Chip>
</div>
@@ -66,7 +84,7 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
<p className="ziffern m-0 text-sm text-text-2">
{laeuft
? fortschritt ?? laufText(job)
? fortschritt ?? laufText(job, jetzt)
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
</p>
@@ -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.")
})
})
+27 -11
View File
@@ -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({
/**
* 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<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`),
enabled: !!art,
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${encodeURIComponent(art)}`),
staleTime: 600_000,
})),
})
export interface UpdateLauf {
+65 -21
View File
@@ -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<Job, "state" | "group">) => 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<Job, "label">): 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<Job, "label" | "group">) => 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<Job, "label" | "group">) => 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<Job, "state" | "label" | "group">) => 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<Job, "label">): 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." : "")
)
}
+8 -4
View File
@@ -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<string, string> = { coder: "Coder" }
/** Wer eine Rolle heute innehat: Modellname und ob ein Bild-Zwilling daran hängt. */
export type RollenStand = Record<string, { name: string; zwilling: boolean } | undefined>
/** Wer die Rollen heute innehat (Schlüssel = Rollen-Alias). */
export type RollenStand = Record<string, RollenInhaber | undefined>
/** 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}
</p>
)}
<p className="m-0 text-[15px] leading-relaxed text-text-2">{installierenUnterbrechung(!!rolle)}</p>
</Bestaetigen>
</SheetContent>
</Sheet>
+10 -1
View File
@@ -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() {
<ModellSuche
offen={suche}
onSchliessen={() => 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,
}}
/>
</>
)
+18 -11
View File
@@ -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<UpdateDetails, Error>
/** 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() {
<span role="columnheader">Was sich ändert</span>
<span role="columnheader" className="sr-only">Aktion</span>
</div>
{BAUSTEINE.map((b) => (
{BAUSTEINE.map((b, i) => (
<BausteinZeile
key={b.id}
id={b.id}
name={b.name}
details={details[i]}
hatUpdate={hatNeues[i]}
neuLautStart={neuLautStart.get(b.id)}
fest={festgehalten.get(b.id)}
gesperrt={gesperrt}
@@ -371,9 +378,9 @@ export function UpdatesSeite() {
onClick={() => updateStarten.mutate("pruefen")}
disabled={updateStarten.isPending || suchtGerade}
>
Nach Neuem suchen
{suchtGerade ? "Sucht …" : "Nach Neuem suchen"}
</Button>
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || neuLautStart.size === 0}>
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || !hatNeues.some(Boolean)}>
Alles jetzt aktualisieren
</Button>
</div>
@@ -413,7 +420,7 @@ export function UpdatesSeite() {
<Bestaetigen
offen={frage?.art === "alle"}
titel="Alles jetzt aktualisieren?"
text="Motor, llama-swap, Hermes und Betriebssystem werden nacheinander aktualisiert, soweit es Neues gibt. Vorher wird gesichert, danach geprüft. Lucy ist dabei ein paar Minuten nicht erreichbar."
text="Motor, llama-swap, Hermes und Betriebssystem werden nacheinander aktualisiert, soweit es Neues gibt, und danach geprüft. Motor und llama-swap holen bei Problemen ihren alten Stand von selbst zurück, vor Hermes wird gesichert. Lucy ist dabei ein paar Minuten nicht erreichbar."
knopf="Aktualisieren"
gesperrt={gesperrt}
onNein={() => setFrage(null)}