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 <JobKarte
key={j.id} key={j.id}
job={j} job={j}
ebene={alsPanel ? 3 : 4}
abbrechenGesperrt={abbrechen.isPending} abbrechenGesperrt={abbrechen.isPending}
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })} onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
/> />
@@ -23,13 +23,16 @@ export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen"
}) { }) {
return ( return (
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}> <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> <DialogHeader>
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle> <DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle>
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription> <DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader> </DialogHeader>
{children} {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}> <Button variant="outline" onClick={onNein}>
{neinKnopf} {neinKnopf}
</Button> </Button>
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react" 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 type { Job } from "@/lib/typen"
import { JobKarte } from "./JobKarte" import { JobKarte } from "./JobKarte"
@@ -44,10 +44,15 @@ describe("JobKarte", () => {
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument() 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( const { rerender } = render(
<JobKarte job={job({ label: "Hermes-Agent-Update", group: "maintenance" })} onAbbrechen={() => {}} />, <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() expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
rerender( 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", () => { describe("sichtbareJobs", () => {
it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => { it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => {
const liste = sichtbareJobs( 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 { cn } from "cn"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { laeuftNoch } from "@/lib/anzeige" import { laeuftNoch } from "@/lib/anzeige"
@@ -22,14 +22,30 @@ const BALKEN: Record<JobZustand, string> = {
const zeitVon = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString()) 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 * 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`). * 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 job: Job
onAbbrechen?: (job: Job) => void onAbbrechen?: (job: Job) => void
abbrechenGesperrt?: boolean abbrechenGesperrt?: boolean
ebene?: 3 | 4
}) { }) {
const [frage, setFrage] = useState(false) const [frage, setFrage] = useState(false)
const [protokoll, setProtokoll] = useState(false) const [protokoll, setProtokoll] = useState(false)
@@ -38,13 +54,15 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
const fortschritt = fortschrittText(job) const fortschritt = fortschrittText(job)
const meldung = letzteMeldung(job) const meldung = letzteMeldung(job)
const laeuft = laeuftNoch(job) const laeuft = laeuftNoch(job)
const jetzt = useJetzt(laeuft)
const darfAbbrechen = !!onAbbrechen && abbrechbar(job) const darfAbbrechen = !!onAbbrechen && abbrechbar(job)
const protokollZeilen = (job.log ?? []).slice(-12) const protokollZeilen = (job.log ?? []).slice(-12)
const Titel = ebene === 3 ? "h3" : "h4"
return ( return (
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5"> <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"> <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> <Chip art={zustand.art}>{zustand.text}</Chip>
</div> </div>
@@ -66,7 +84,7 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
<p className="ziffern m-0 text-sm text-text-2"> <p className="ziffern m-0 text-sm text-text-2">
{laeuft {laeuft
? fortschritt ?? laufText(job) ? fortschritt ?? laufText(job, jetzt)
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`} : `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
</p> </p>
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { hinweisRueckfrage } from "@/lib/anzeige" import { hinweisRueckfrage } from "@/lib/anzeige"
import { post } from "@/lib/api" import { post } from "@/lib/api"
import type { Hinweis } from "@/lib/typen" import type { Hinweis } from "@/lib/typen"
import { rollenErsatzText } from "@/lib/wartung" import { installierenUnterbrechung, rollenErsatzText } from "@/lib/wartung"
import { Bestaetigen } from "./Bestaetigen" import { Bestaetigen } from "./Bestaetigen"
import { Checkliste } from "./Checkliste" import { Checkliste } from "./Checkliste"
@@ -63,11 +63,18 @@ describe("Bestaetigen", () => {
}) })
describe("rollenErsatzText", () => { describe("rollenErsatzText", () => {
it("sagt deutlich, welches Modell die Rolle verliert", () => { 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 })).toBe( 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. " + "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“.") 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 // Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
// Sicherheitsnetz und laufen fünfmal seltener. // 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 { api, post } from "./api"
import { fehlerText, laeuftNoch } from "./anzeige" import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen" import { melden } from "./meldungen"
@@ -15,14 +15,24 @@ import { gbText } from "./zeit"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms) 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:
* nach einem Download die Modelle und die Platte, nach Updates und der Suche die Versionen, der
/** Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu. */ * Verlauf und die Sicherungen. Gezielt, weil die Update-Prüfung teuer ist (GitHub, apt, git fetch).
*/
function auftragsEndeMelden(qc: QueryClient, neu: Job[]) { function auftragsEndeMelden(qc: QueryClient, neu: Job[]) {
const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? [] const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? []
const ebenFertig = neu.some((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a))) const ebenFertig = neu.filter((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a)))
if (ebenFertig) for (const k of NACH_AUFTRAG) qc.invalidateQueries({ queryKey: [k] }) 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 = () => export const useStart = () =>
@@ -66,12 +76,18 @@ export const useSicherungen = () =>
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"), 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], queryKey: ["update-details", art],
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`), queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${encodeURIComponent(art)}`),
enabled: !!art,
staleTime: 600_000, staleTime: 600_000,
})),
}) })
export interface UpdateLauf { export interface UpdateLauf {
+65 -21
View File
@@ -46,22 +46,45 @@ export function pruefFehlerText(fehler: string): string {
// --- Aufträge (services/jobengine.py) --------------------------------------------------- // --- 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.) */ /** 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) 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. */ /** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */
export function restzeit(s: number): string { export function restzeit(s: number): string {
if (s < 60) return "noch unter einer Minute" if (s < 60) return "noch unter einer Minute"
@@ -113,20 +136,41 @@ export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] {
// --- Installieren ------------------------------------------------------------------- // --- 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 * 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 * nur dieser eine Rollen-Alias um (services/llamaswap.set_role_alias): Das bisherige Modell bleibt
* und sein Bild-Zwilling zeigt weiter auf dessen Gewichte. * auf der Platte, behält seine übrigen Rollen, und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
*/ */
export function rollenErsatzText( export function rollenErsatzText(neu: string, rolle: string, bisher: RollenInhaber | undefined): string {
neu: string,
rolle: string,
bisher: { name: string; zwilling: boolean } | undefined,
): string {
if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.` 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 ( return (
`Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` + `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." : "") (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 { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { useInstallieren } from "@/lib/abfragen" import { useInstallieren } from "@/lib/abfragen"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { rollenErsatzText } from "@/lib/wartung" import { installierenUnterbrechung, type RollenInhaber, rollenErsatzText } from "@/lib/wartung"
import { gbText, zahl } from "@/lib/zeit" import { gbText, zahl } from "@/lib/zeit"
interface Treffer { repo: string; downloads: number; likes: number } 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. */ /** /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 } 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" } const ROLLEN: Record<string, string> = { coder: "Coder" }
/** Wer eine Rolle heute innehat: Modellname und ob ein Bild-Zwilling daran hängt. */ /** Wer die Rollen heute innehat (Schlüssel = Rollen-Alias). */
export type RollenStand = Record<string, { name: string; zwilling: boolean } | undefined> export type RollenStand = Record<string, RollenInhaber | undefined>
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */ /** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
export function ModellSuche({ offen, onSchliessen, rollenJetzt }: { export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
@@ -188,6 +191,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
{ersatz} {ersatz}
</p> </p>
)} )}
<p className="m-0 text-[15px] leading-relaxed text-text-2">{installierenUnterbrechung(!!rolle)}</p>
</Bestaetigen> </Bestaetigen>
</SheetContent> </SheetContent>
</Sheet> </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 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.`} : `${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" knopf="Übernehmen"
gesperrt={aktion.isPending}
onNein={() => setFrage(null)} onNein={() => setFrage(null)}
onJa={() => { onJa={() => {
if (frage !== "uebernehmen") return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name }) aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
setFrage(null) setFrage(null)
}} }}
@@ -226,8 +228,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
titel={`${k.name} verwerfen?`} titel={`${k.name} verwerfen?`}
text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal." text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal."
knopf="Verwerfen" knopf="Verwerfen"
gesperrt={aktion.isPending}
onNein={() => setFrage(null)} onNein={() => setFrage(null)}
onJa={() => { onJa={() => {
if (frage !== "verwerfen") return
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name }) aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
setFrage(null) setFrage(null)
}} }}
@@ -353,6 +357,7 @@ function AufraeumenPanel() {
titel={`${frage?.name ?? ""} löschen?`} 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.`} 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" knopf="Endgültig löschen"
gesperrt={loeschen.isPending}
onNein={() => setFrage(null)} onNein={() => setFrage(null)}
onJa={() => { onJa={() => {
if (frage) loeschen.mutate(frage) if (frage) loeschen.mutate(frage)
@@ -432,7 +437,11 @@ export function ModelleSeite() {
<ModellSuche <ModellSuche
offen={suche} offen={suche}
onSchliessen={() => setSuche(false)} 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 { useState } from "react"
import type { UseQueryResult } from "@tanstack/react-query"
import { ChevronRight } from "lucide-react" import { ChevronRight } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { Auftraege } from "@/components/cockpit/Auftraege" import { Auftraege } from "@/components/cockpit/Auftraege"
@@ -11,7 +12,7 @@ import {
useUpdateStarten, useUpdateVerlauf, useZurueckspielen, useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { laeuftNoch } from "@/lib/anzeige" 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 type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
@@ -110,9 +111,12 @@ function zusammenfassung(summary: string | undefined): string | null {
return s return s
} }
function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFreigeben }: { function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gesperrt, onStart, onFreigeben }: {
id: string id: string
name: 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. */ /** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */
neuLautStart: string | undefined neuLautStart: string | undefined
fest: Festgehalten | undefined fest: Festgehalten | undefined
@@ -120,13 +124,10 @@ function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFrei
onStart: () => void onStart: () => void
onFreigeben: () => void onFreigeben: () => void
}) { }) {
const details = useUpdateDetails(id)
const d = details.data const d = details.data
const laedt = details.isPending const laedt = details.isPending
const fehler = details.isError ? details.error.message : d?.error const fehler = details.isError ? details.error.message : d?.error
const v = d ? versionen(id, d) : null 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 neu = hatUpdate ? v?.neu ?? neuLautStart ?? null : null
const summary = zusammenfassung(d?.summary) 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 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])) 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 ?? [] const alleJobs = jobs.data?.jobs ?? []
// Nur ein Update gleichzeitig (Riegel im Backend) — auch der Sonntags-Lauf zählt. // Nur ein Update gleichzeitig (Riegel im Backend) — auch der Sonntags-Lauf zählt.
const updateLaeuft = const updateLaeuft =
alleJobs.some((j) => j.group === "maintenance" && laeuftNoch(j)) || (start.data?.zustand.update_laeuft ?? false) 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. // Zwei Suchen gleichzeitig stolpern über die apt-Sperre — solange eine läuft, bleibt der Knopf zu.
const suchtGerade = alleJobs.some((j) => j.group === "pruefen" && laeuftNoch(j)) const suchtGerade = alleJobs.some((j) => jobSuche(j) && laeuftNoch(j))
const gesperrt = updateLaeuft || updateStarten.isPending const gesperrt = updateLaeuft || updateStarten.isPending
const zurueckMoeglich = sicherungen.data?.available ?? false const zurueckMoeglich = sicherungen.data?.available ?? false
@@ -296,11 +301,13 @@ export function UpdatesSeite() {
<span role="columnheader">Was sich ändert</span> <span role="columnheader">Was sich ändert</span>
<span role="columnheader" className="sr-only">Aktion</span> <span role="columnheader" className="sr-only">Aktion</span>
</div> </div>
{BAUSTEINE.map((b) => ( {BAUSTEINE.map((b, i) => (
<BausteinZeile <BausteinZeile
key={b.id} key={b.id}
id={b.id} id={b.id}
name={b.name} name={b.name}
details={details[i]}
hatUpdate={hatNeues[i]}
neuLautStart={neuLautStart.get(b.id)} neuLautStart={neuLautStart.get(b.id)}
fest={festgehalten.get(b.id)} fest={festgehalten.get(b.id)}
gesperrt={gesperrt} gesperrt={gesperrt}
@@ -371,9 +378,9 @@ export function UpdatesSeite() {
onClick={() => updateStarten.mutate("pruefen")} onClick={() => updateStarten.mutate("pruefen")}
disabled={updateStarten.isPending || suchtGerade} disabled={updateStarten.isPending || suchtGerade}
> >
Nach Neuem suchen {suchtGerade ? "Sucht …" : "Nach Neuem suchen"}
</Button> </Button>
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || neuLautStart.size === 0}> <Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || !hatNeues.some(Boolean)}>
Alles jetzt aktualisieren Alles jetzt aktualisieren
</Button> </Button>
</div> </div>
@@ -413,7 +420,7 @@ export function UpdatesSeite() {
<Bestaetigen <Bestaetigen
offen={frage?.art === "alle"} offen={frage?.art === "alle"}
titel="Alles jetzt aktualisieren?" 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" knopf="Aktualisieren"
gesperrt={gesperrt} gesperrt={gesperrt}
onNein={() => setFrage(null)} onNein={() => setFrage(null)}