updates: Urteil und Paketliste sichtbar, ehrliche Lade- und Fehlerzustaende, Rueckfragen, Auftraege aller Gruppen

- Update-Details zeigen jetzt das Urteil des Hirns ("Nichts zu tun" / "Du musst
  etwas tun" samt Text), die Zusammenfassung ist aufklappbar statt auf drei Zeilen
  gekappt, das Betriebssystem zeigt seine Paketliste (und was Ubuntu zurueckhaelt).
- Scheitert eine Pruefung (z. B. git fetch bei Hermes), steht "Konnte nicht pruefen: ..."
  statt "- -> neu"; waehrend des Ladens ein Platzhalter statt "..." und kein voreiliges "neu".
- Einzel-Update und Freigeben fragen vorher nach; der Ja-Knopf sperrt gegen Doppelklick.
- Neue Job-Karte (Fortschritt, Abbrechen mit Rueckfrage, letzte Meldung, Protokoll) fuer
  alle laufenden Auftraege, egal welche Gruppe. Updates lassen sich bewusst nicht abbrechen.
- Job-Typen an jobengine.py angeglichen ("canceled", group, started_at/finished_at):
  abgebrochene Auftraege erschienen bisher als "Laeuft".
- Fehler aus "Jetzt sichern" (Feld error) und "Es laeuft schon ein Update" (status busy)
  werden gemeldet; ist ein Auftrag fertig, lesen Versionen, Modelle und Sicherungen neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:13:27 +02:00
co-authored by Claude Opus 5.5
parent c8718fcde0
commit 5ddc952ecd
8 changed files with 789 additions and 159 deletions
@@ -0,0 +1,116 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
abbrechbar, fortschrittText, jobDownload, jobTitel, laeuftNoch, laufText, letzteMeldung,
} from "@/lib/anzeige"
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 zeitVon = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
/**
* 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`).
*/
export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
job: Job
onAbbrechen?: (job: Job) => void
abbrechenGesperrt?: boolean
}) {
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 darfAbbrechen = !!onAbbrechen && abbrechbar(job)
const protokollZeilen = (job.log ?? []).slice(-12)
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>
<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={job.state === "failed" ? "h-full bg-rot" : "h-full 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)
: `${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={() => {
setFrage(false)
onAbbrechen?.(job)
}}
/>
)}
</article>
)
}