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>
140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
import { useEffect, useState } from "react"
|
|
import { cn } from "cn"
|
|
import { Button } from "@/components/ui/button"
|
|
import { laeuftNoch } from "@/lib/anzeige"
|
|
import { abbrechbar, fortschrittText, jobDownload, jobTitel, laufText, letzteMeldung } from "@/lib/wartung"
|
|
import type { Job, JobZustand } from "@/lib/typen"
|
|
import { flugplanZeit } from "@/lib/zeit"
|
|
import { Bestaetigen } from "./Bestaetigen"
|
|
import { Chip, type ChipArt } from "./Chip"
|
|
|
|
const ZUSTAND: Record<JobZustand, { text: string; art: ChipArt }> = {
|
|
queued: { text: "Wartet", art: "cyan" },
|
|
running: { text: "Läuft", art: "cyan" },
|
|
done: { text: "Fertig", art: "gruen" },
|
|
failed: { text: "Fehler", art: "rot" },
|
|
canceled: { text: "Abgebrochen", art: "grau" },
|
|
}
|
|
|
|
const BALKEN: Record<JobZustand, string> = {
|
|
queued: "bg-cyan", running: "bg-cyan", done: "bg-gruen", failed: "bg-rot", canceled: "bg-text-3",
|
|
}
|
|
|
|
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, ebene = 4 }: {
|
|
job: Job
|
|
onAbbrechen?: (job: Job) => void
|
|
abbrechenGesperrt?: boolean
|
|
ebene?: 3 | 4
|
|
}) {
|
|
const [frage, setFrage] = useState(false)
|
|
const [protokoll, setProtokoll] = useState(false)
|
|
const zustand = ZUSTAND[job.state] ?? { text: job.state, art: "grau" as const }
|
|
const titel = jobTitel(job)
|
|
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">
|
|
<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>
|
|
|
|
{job.progress != null && (
|
|
<div
|
|
role="progressbar"
|
|
aria-label={`Fortschritt: ${titel}`}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={Math.round(job.progress)}
|
|
className="h-2 overflow-hidden rounded-full bg-linie"
|
|
>
|
|
<div
|
|
className={cn("h-full", BALKEN[job.state] ?? "bg-cyan")}
|
|
style={{ width: `${Math.max(2, Math.min(100, job.progress))}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<p className="ziffern m-0 text-sm text-text-2">
|
|
{laeuft
|
|
? fortschritt ?? laufText(job, jetzt)
|
|
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
|
|
</p>
|
|
|
|
{/* Bei Fehlern steht der Grund meist in der letzten Zeile; ohne Fortschrittsbalken zeigt sie, woran der Auftrag gerade ist. */}
|
|
{meldung && (job.state === "failed" || (laeuft && job.progress == null)) && (
|
|
<p className={job.state === "failed" ? "m-0 text-sm break-words text-rot-text" : "ziffern m-0 line-clamp-2 text-xs break-all text-text-3"}>
|
|
{job.state === "failed" ? `Letzte Meldung: ${meldung}` : meldung}
|
|
</p>
|
|
)}
|
|
|
|
{(darfAbbrechen || protokollZeilen.length > 1) && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{darfAbbrechen && (
|
|
<Button variant="outline" size="sm" disabled={abbrechenGesperrt} onClick={() => setFrage(true)}>
|
|
Abbrechen
|
|
</Button>
|
|
)}
|
|
{protokollZeilen.length > 1 && (
|
|
<Button variant="ghost" size="sm" aria-expanded={protokoll} onClick={() => setProtokoll(!protokoll)}>
|
|
{protokoll ? "Protokoll zu" : "Protokoll"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{protokoll && (
|
|
<pre className="ziffern m-0 max-h-56 overflow-auto rounded-md bg-[#0b0d0f] p-2 text-xs whitespace-pre-wrap break-all text-text-2">
|
|
{protokollZeilen.join("\n")}
|
|
</pre>
|
|
)}
|
|
|
|
{darfAbbrechen && (
|
|
<Bestaetigen
|
|
offen={frage}
|
|
gefahr
|
|
titel="Auftrag abbrechen?"
|
|
text={jobDownload(job)
|
|
? `Der ${titel} stoppt. Du kannst ihn später über „Modelle selbst suchen“ neu starten.`
|
|
: `„${titel}“ wird abgebrochen.`}
|
|
knopf="Ja, abbrechen"
|
|
neinKnopf="Weiterlaufen lassen"
|
|
onNein={() => setFrage(false)}
|
|
onJa={() => {
|
|
if (!frage) return // zweiter Klick eines Doppelklicks
|
|
setFrage(false)
|
|
onAbbrechen?.(job)
|
|
}}
|
|
/>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|