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:
co-authored by
Claude Opus 5.5
parent
c8718fcde0
commit
5ddc952ecd
@@ -0,0 +1,42 @@
|
||||
import { useJobAbbrechen, useJobs } from "@/lib/abfragen"
|
||||
import { jobTitel, sichtbareJobs } from "@/lib/anzeige"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
/**
|
||||
* Laufende und zuletzt beendete Aufträge — egal, woher sie kommen (Update, Modell-Download, Suche
|
||||
* nach Neuem). Solange nichts läuft und nichts frisch beendet ist, zeigt sie gar nichts.
|
||||
*/
|
||||
export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
|
||||
const jobs = useJobs()
|
||||
const abbrechen = useJobAbbrechen()
|
||||
const liste = sichtbareJobs(jobs.data?.jobs ?? [])
|
||||
|
||||
let inhalt
|
||||
if (liste.length > 0) {
|
||||
inhalt = (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{liste.map((j) => (
|
||||
<JobKarte
|
||||
key={j.id}
|
||||
job={j}
|
||||
abbrechenGesperrt={abbrechen.isPending}
|
||||
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (jobs.isError && !jobs.data) {
|
||||
inhalt = <p role="alert" className="text-sm text-rot-text">Die Aufträge lassen sich gerade nicht lesen: {jobs.error.message}</p>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (alsPanel) return <Panel titel="Aufträge">{inhalt}</Panel>
|
||||
return (
|
||||
<section aria-label="Aufträge" className="flex flex-col gap-2.5">
|
||||
<h3 className="schild m-0 text-sm text-text-3">Aufträge</h3>
|
||||
{inhalt}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,13 +4,19 @@ import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
|
||||
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
|
||||
/**
|
||||
* Rückfrage vor Schritten, die die Box verändern (Updates, Freigeben, Zurückspielen, Neustarts,
|
||||
* Installieren, Abbrechen). `gesperrt` hält den Ja-Knopf fest, solange die Aktion schon unterwegs
|
||||
* ist — ein Doppelklick startet sie so nicht zweimal.
|
||||
*/
|
||||
export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen", gefahr, gesperrt, onJa, onNein, children }: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: string
|
||||
knopf: string
|
||||
neinKnopf?: string
|
||||
gefahr?: boolean
|
||||
gesperrt?: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
children?: ReactNode
|
||||
@@ -19,15 +25,22 @@ export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, c
|
||||
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
|
||||
<DialogContent className="border-linie bg-panel sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
|
||||
<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">
|
||||
<Button variant="outline" onClick={onNein}>
|
||||
Abbrechen
|
||||
{neinKnopf}
|
||||
</Button>
|
||||
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
|
||||
<Button
|
||||
variant={gefahr ? "gefahr" : "default"}
|
||||
disabled={gesperrt}
|
||||
aria-busy={gesperrt || undefined}
|
||||
onClick={() => {
|
||||
if (!gesperrt) onJa()
|
||||
}}
|
||||
>
|
||||
{knopf}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { sichtbareJobs } from "@/lib/anzeige"
|
||||
import type { Job } from "@/lib/typen"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
|
||||
const JETZT = 1_790_000_000 // Unix-Sekunden, fester Bezugspunkt
|
||||
|
||||
function job(teil: Partial<Job>): Job {
|
||||
return {
|
||||
id: "j1", label: "Nach Updates suchen", state: "running", group: null,
|
||||
log: ["$ bash -c 'sudo apt-get update'"], returncode: null,
|
||||
started_at: JETZT - 120, finished_at: null, ...teil,
|
||||
}
|
||||
}
|
||||
|
||||
describe("JobKarte", () => {
|
||||
it("zeigt bei einem Download Fortschritt und fragt vor dem Abbrechen nach", () => {
|
||||
const onAbbrechen = vi.fn()
|
||||
render(
|
||||
<JobKarte
|
||||
job={job({
|
||||
label: "download unsloth/Qwen3.8-27B-GGUF", progress: 42,
|
||||
done_bytes: 12 * 1024 ** 3, total_bytes: 29 * 1024 ** 3, eta_s: 360,
|
||||
})}
|
||||
onAbbrechen={onAbbrechen}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Download unsloth/Qwen3.8-27B-GGUF")).toBeInTheDocument()
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42")
|
||||
expect(screen.getByText("42 % · 12,0 GB von 29,0 GB · noch etwa 6 min")).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abbrechen" }))
|
||||
expect(screen.getByRole("dialog", { name: "Auftrag abbrechen?" })).toBeInTheDocument()
|
||||
expect(onAbbrechen).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ja, abbrechen" }))
|
||||
expect(onAbbrechen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("nennt einen abgebrochenen Auftrag „Abgebrochen“ und nicht „Läuft“", () => {
|
||||
render(<JobKarte job={job({ state: "canceled", finished_at: JETZT })} onAbbrechen={() => {}} />)
|
||||
expect(screen.getByText("Abgebrochen")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Läuft")).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("bietet bei Updates 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.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<JobKarte
|
||||
job={job({
|
||||
label: "Hermes-Agent-Update", group: "maintenance", state: "failed", finished_at: JETZT,
|
||||
log: ["$ bash -c …", "✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)", ""],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Fehler")).toBeInTheDocument()
|
||||
expect(screen.getByText(/Letzte Meldung: ✗ HERMES-UPDATE FEHLGESCHLAGEN/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("sichtbareJobs", () => {
|
||||
it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => {
|
||||
const liste = sichtbareJobs(
|
||||
[
|
||||
job({ id: "alt", state: "done", finished_at: JETZT - 2 * 86_400 }),
|
||||
job({ id: "f1", state: "done", finished_at: JETZT - 50 }),
|
||||
job({ id: "f2", state: "failed", finished_at: JETZT - 40 }),
|
||||
job({ id: "f3", state: "canceled", finished_at: JETZT - 30 }),
|
||||
job({ id: "f4", state: "done", finished_at: JETZT - 20 }),
|
||||
job({ id: "download", label: "download a/b", state: "running", group: null }),
|
||||
job({ id: "update", group: "maintenance", state: "queued", started_at: JETZT - 10 }),
|
||||
],
|
||||
JETZT,
|
||||
)
|
||||
expect(liste.map((j) => j.id)).toEqual(["download", "update", "f4", "f3", "f2"])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user