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:
co-authored by
Claude Opus 5.5
parent
83941847ab
commit
bc1106ddc8
@@ -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(
|
||||
|
||||
@@ -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.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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." : "")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user