From 5ddc952ecd31a04730a8ff28933e701a26dc0931 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 15:13:27 +0200 Subject: [PATCH] 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 --- frontend/src/components/cockpit/Auftraege.tsx | 42 ++ .../src/components/cockpit/Bestaetigen.tsx | 23 +- .../src/components/cockpit/JobKarte.test.tsx | 82 ++++ frontend/src/components/cockpit/JobKarte.tsx | 116 +++++ frontend/src/lib/abfragen.ts | 93 ++-- frontend/src/lib/anzeige.ts | 112 ++++- frontend/src/lib/typen.ts | 61 ++- frontend/src/views/Updates.tsx | 419 +++++++++++++----- 8 files changed, 789 insertions(+), 159 deletions(-) create mode 100644 frontend/src/components/cockpit/Auftraege.tsx create mode 100644 frontend/src/components/cockpit/JobKarte.test.tsx create mode 100644 frontend/src/components/cockpit/JobKarte.tsx diff --git a/frontend/src/components/cockpit/Auftraege.tsx b/frontend/src/components/cockpit/Auftraege.tsx new file mode 100644 index 0000000..722a8b6 --- /dev/null +++ b/frontend/src/components/cockpit/Auftraege.tsx @@ -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 = ( +
+ {liste.map((j) => ( + abbrechen.mutate({ id: job.id, titel: jobTitel(job) })} + /> + ))} +
+ ) + } else if (jobs.isError && !jobs.data) { + inhalt =

Die Aufträge lassen sich gerade nicht lesen: {jobs.error.message}

+ } else { + return null + } + + if (alsPanel) return {inhalt} + return ( +
+

Aufträge

+ {inhalt} +
+ ) +} diff --git a/frontend/src/components/cockpit/Bestaetigen.tsx b/frontend/src/components/cockpit/Bestaetigen.tsx index f7d5116..230ec01 100644 --- a/frontend/src/components/cockpit/Bestaetigen.tsx +++ b/frontend/src/components/cockpit/Bestaetigen.tsx @@ -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 !o && onNein()}> - {titel} + {titel} {text} {children} - diff --git a/frontend/src/components/cockpit/JobKarte.test.tsx b/frontend/src/components/cockpit/JobKarte.test.tsx new file mode 100644 index 0000000..b789a60 --- /dev/null +++ b/frontend/src/components/cockpit/JobKarte.test.tsx @@ -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 { + 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( + , + ) + 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( {}} />) + 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( + {}} />, + ) + expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument() + + rerender( + , + ) + 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"]) + }) +}) diff --git a/frontend/src/components/cockpit/JobKarte.tsx b/frontend/src/components/cockpit/JobKarte.tsx new file mode 100644 index 0000000..53072e3 --- /dev/null +++ b/frontend/src/components/cockpit/JobKarte.tsx @@ -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 = { + 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 ( +
+
+

{titel}

+ {zustand.text} +
+ + {job.progress != null && ( +
+
+
+ )} + +

+ {laeuft + ? fortschritt ?? laufText(job) + : `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`} +

+ + {/* 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)) && ( +

+ {job.state === "failed" ? `Letzte Meldung: ${meldung}` : meldung} +

+ )} + + {(darfAbbrechen || protokollZeilen.length > 1) && ( +
+ {darfAbbrechen && ( + + )} + {protokollZeilen.length > 1 && ( + + )} +
+ )} + + {protokoll && ( +
+          {protokollZeilen.join("\n")}
+        
+ )} + + {darfAbbrechen && ( + setFrage(false)} + onJa={() => { + setFrage(false) + onAbbrechen?.(job) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index ac13732..87df48e 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -3,16 +3,28 @@ // Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur // Sicherheitsnetz und laufen fünfmal seltener. -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { api, post } from "./api" +import { fehlerText, laeuftNoch } from "./anzeige" import { melden } from "./meldungen" import { stromVerbunden } from "./strom" import type { - AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start, + AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start, UpdateDetails, } from "./typen" +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. */ +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] }) +} + export const useStart = () => useQuery({ queryKey: ["start"], @@ -34,14 +46,19 @@ export const useNutzung = () => export const useRadar = () => useQuery({ queryKey: ["radar"], queryFn: () => api("/api/radar"), retry: false, refetchInterval: 300_000 }) -export const useJobs = () => - useQuery({ +export const useJobs = () => { + const qc = useQueryClient() + return useQuery({ queryKey: ["jobs"], - queryFn: () => api<{ jobs: Job[] }>("/api/jobs"), + queryFn: async () => { + const daten = await api<{ jobs: Job[] }>("/api/jobs") + auftragsEndeMelden(qc, daten.jobs) + return daten + }, // Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom. - refetchInterval: (q) => - q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(), + refetchInterval: (q) => (q.state.data?.jobs.some(laeuftNoch) ? 2000 : takt(30_000)()), }) +} export const useSicherungen = () => useQuery({ @@ -49,21 +66,6 @@ export const useSicherungen = () => queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"), }) -export interface UpdateDetails { - kind: string - installed_version?: string | null - behind?: number - installed_build?: number | null - latest_build?: number | null - latest_tag?: string | null - count?: number - packages?: { name: string; current: string; candidate: string }[] - summary?: string - action_needed?: boolean | null - action_text?: string - error?: string -} - export const useUpdateDetails = (art: string | null) => useQuery({ queryKey: ["update-details", art], @@ -108,7 +110,12 @@ export const useDienste = () => function useAktion>( fn: (v: V) => Promise, - opt: { erfolg: (v: V, e: E) => string; neuLaden: string[] }, + opt: { + erfolg: (v: V, e: E) => string + /** Eigener Text, wenn die Box mit ok: false antwortet (sonst der Grund aus der Antwort). */ + fehler?: (v: V, e: AktionsErgebnis) => string + neuLaden: string[] + }, ) { const qc = useQueryClient() return useMutation({ @@ -116,7 +123,7 @@ function useAktion>( onSuccess: (e, v) => { const ergebnis = e as AktionsErgebnis if (ergebnis && ergebnis.ok === false) { - melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.") + melden("fehler", opt.fehler?.(v, ergebnis) ?? fehlerText(ergebnis)) } else { melden("erfolg", opt.erfolg(v, e)) } @@ -130,7 +137,11 @@ export const useHinweisAktion = () => useAktion( ({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) => post(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`), - { erfolg: (v) => `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: ["start"] }, + { + erfolg: (v, e) => + v.aktion === "freigeben" && e.text ? e.text : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, + neuLaden: ["start"], + }, ) const UPDATE_PFADE: Record = { @@ -144,10 +155,34 @@ const UPDATE_PFADE: Record = { export const useUpdateStarten = () => useAktion((art: string) => post(UPDATE_PFADE[art]), { - erfolg: (art) => (art === "pruefen" ? "Suche nach Neuem läuft." : "Update läuft. Den Fortschritt siehst du unten."), + erfolg: (art) => + art === "pruefen" + ? "Die Suche nach Neuem läuft. Den Fortschritt siehst du unten bei den Aufträgen." + : "Das Update läuft. Den Fortschritt siehst du unten bei den Aufträgen.", neuLaden: ["jobs", "start"], }) +/** Einen laufenden Auftrag abbrechen (nur Downloads und die Suche, siehe `abbrechbar`). */ +export const useJobAbbrechen = () => + useAktion(({ id }: { id: string; titel: string }) => post(`/api/jobs/${encodeURIComponent(id)}/cancel`), { + erfolg: (v) => `${v.titel}: wird abgebrochen.`, + fehler: (v) => `${v.titel} lief schon nicht mehr.`, + neuLaden: ["jobs"], + }) + +/** Ein Modell von Hugging Face holen (Hintergrund-Auftrag, mit optionaler Rolle nach dem Download). */ +export const useInstallieren = () => + useAktion( + ({ repo, quant, rolle }: { repo: string; quant: string; rolle: string | null }) => + post("/api/models/install", { repo, quant, role: rolle }), + { + erfolg: (v, e) => + `${v.repo} (${v.quant}) wird heruntergeladen${e.total_bytes ? `, ${gbText(e.total_bytes, 1)}` : ""}. ` + + "Den Fortschritt siehst du unter Aufträge.", + neuLaden: ["jobs", "models"], + }, + ) + export const useAufraeumen = () => useQuery({ queryKey: ["aufraeumen"], @@ -168,8 +203,10 @@ export const useFreigeben = () => ) export const useJetztSichern = () => - useAktion(() => post("/api/system/backup"), { - erfolg: () => "Sicherung erstellt.", + useAktion(() => post("/api/system/backup"), { + erfolg: (_, e) => + e.size_mb != null ? `Sicherung erstellt (${e.size_mb.toLocaleString("de-DE")} MB).` : "Sicherung erstellt.", + fehler: (_, e) => `Die Sicherung ging nicht: ${fehlerText(e)}`, neuLaden: ["sicherungen", "start"], }) diff --git a/frontend/src/lib/anzeige.ts b/frontend/src/lib/anzeige.ts index 01df897..c3d1876 100644 --- a/frontend/src/lib/anzeige.ts +++ b/frontend/src/lib/anzeige.ts @@ -1,8 +1,8 @@ // Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne // DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann. -import type { UpdateDetails } from "./abfragen" -import type { Modell, Start } from "./typen" +import type { AktionsErgebnis, Job, Modell, Start, UpdateDetails } from "./typen" +import { gbText } from "./zeit" // --- Rundinstrument ----------------------------------------------------------------- // 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung. @@ -57,10 +57,9 @@ export function hauptleuchte(z: Start["zustand"]) { // --- Updates ------------------------------------------------------------------------ -/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */ -export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } { - if (!d) return { laeuft: "…", neu: null } - if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null } +/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen (die Details sind schon geladen). */ +export function versionen(id: string, d: UpdateDetails): { laeuft: string; neu: string | null } { + if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"}` : null } // Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand. if (id === "hermes") { return { @@ -74,6 +73,107 @@ export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: s return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null } } +/** Sagen die Details, dass es Neues gibt? null = weiß man nicht (Prüfung gescheitert oder unvollständig). */ +export function detailsHabenNeues(id: string, d: UpdateDetails | undefined): boolean | null { + if (!d || d.error) return null + if (id === "os") return typeof d.count === "number" ? d.count > 0 : null + if (id === "hermes") return typeof d.behind === "number" ? d.behind > 0 : null + if (d.latest_build == null || d.installed_build == null) return null + return d.latest_build > d.installed_build +} + +/** Eine gescheiterte Update-Prüfung in Worten („Konnte nicht prüfen: …“), ohne Befehlszeilen-Kauderwelsch. */ +export function pruefFehlerText(fehler: string): string { + const f = fehler.trim() + if (/timed out|timeout|zeitüberschreitung/i.test(f)) { + return "Konnte nicht prüfen: Die Gegenstelle hat nicht rechtzeitig geantwortet. Später klappt es meist wieder." + } + if (/name resolution|name or service not known|network is unreachable|connection (refused|reset)|nicht erreichbar/i.test(f)) { + return "Konnte nicht prüfen: Die Box kommt gerade nicht ins Internet." + } + return `Konnte nicht prüfen: ${f.length > 160 ? `${f.slice(0, 157)}…` : f}` +} + +/** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */ +export function fehlerText(e: AktionsErgebnis | null | undefined): string { + if (e?.status === "busy") { + return e.running + ? `Es läuft schon ein Update („${e.running}“). Warte, bis es fertig ist.` + : "Es läuft schon ein Update. Warte, bis es fertig ist." + } + return e?.detail || e?.err || e?.error || e?.text || "Das hat nicht geklappt." +} + +// --- Aufträge (services/jobengine.py) --------------------------------------------------- + +/** Wartet oder läuft der Auftrag noch? */ +export const laeuftNoch = (j: Pick) => j.state === "running" || j.state === "queued" + +/** + * 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) => 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): 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) => j.group === "download" || /^download\s/i.test(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" + const min = Math.round(s / 60) + if (min < 60) return `noch etwa ${min} min` + const h = Math.floor(min / 60) + const rest = min % 60 + return rest ? `noch etwa ${h} h ${rest} min` : `noch etwa ${h} h` +} + +/** „Läuft seit 12 min“ / „Wartet seit ein paar Sekunden“ für Aufträge ohne Fortschrittsanzeige. */ +export function laufText(j: Pick, jetzt = Date.now() / 1000): string { + const s = Math.max(0, jetzt - j.started_at) + const min = Math.floor(s / 60) + const dauer = s < 60 ? "ein paar Sekunden" : min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min` + return `${j.state === "queued" ? "Wartet" : "Läuft"} seit ${dauer}` +} + +/** „42 % · 12,3 GB von 29,1 GB · noch etwa 6 min“ — oder null, wenn der Auftrag keinen Fortschritt kennt. */ +export function fortschrittText(j: Job): string | null { + if (j.progress == null) return null + const teile = [`${Math.round(j.progress)} %`] + if (j.total_bytes) teile.push(`${gbText(j.done_bytes ?? 0, 1)} von ${gbText(j.total_bytes, 1)}`) + if (j.eta_s != null && laeuftNoch(j)) teile.push(restzeit(j.eta_s)) + return teile.join(" · ") +} + +/** Letzte aussagekräftige Zeile des Protokolls (nicht die Befehlszeile „$ …“, die der Auftrag zuerst schreibt). */ +export function letzteMeldung(j: Pick): string | null { + for (let i = (j.log?.length ?? 0) - 1; i >= 0; i--) { + const z = j.log[i].trim() + if (z && !z.startsWith("$ ")) return z + } + return null +} + +/** + * Welche Aufträge die Oberfläche zeigt: alle laufenden und wartenden (egal aus welcher Gruppe), + * dazu die drei zuletzt beendeten der letzten 24 Stunden — damit man sieht, wie es ausging. + */ +export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] { + const laufend = jobs.filter(laeuftNoch).sort((a, b) => a.started_at - b.started_at) + const beendet = jobs + .filter((j) => !laeuftNoch(j) && jetzt - (j.finished_at ?? j.started_at) < 86_400) + .sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at)) + .slice(0, 3) + return [...laufend, ...beendet] +} + // --- Modelle ------------------------------------------------------------------------ /** Rolle eines Modells aus seinen llama-swap-Aliasen. */ diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 26bbd4a..0b5e66f 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -1,5 +1,6 @@ // Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py, -// services/waechter.py, modell_nutzung.py, zeitplan.py und routers/radar.py. +// services/waechter.py, modell_nutzung.py, zeitplan.py, routers/radar.py sowie +// services/jobengine.py (Aufträge) und services/maintenance.py (Update-Details). export type Stufe = "rot" | "gelb" export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler" @@ -90,12 +91,48 @@ export interface AufraeumKandidat { hinweis: string } +/** Antwort einer Aktion. Je nach Endpunkt steht der Fehler in einem anderen Feld. */ export interface AktionsErgebnis { ok: boolean text?: string out?: string err?: string detail?: string + /** „Jetzt sichern“ (/api/system/backup) legt seinen Fehler hier ab. */ + error?: string + /** "busy" (es läuft schon ein Update), "nothing", "password_required" … */ + status?: string + /** Bei status "busy": welcher Auftrag gerade läuft. */ + running?: string + job_id?: string +} + +/** Was ein Update eines Bausteins bringen würde (GET /api/maintenance/update-details?kind=…). */ +export interface UpdateDetails { + kind: string + /** Die Prüfung selbst ist gescheitert, z. B. „git fetch“ lief in eine Zeitüberschreitung. */ + error?: string + // Betriebssystem + count?: number + packages?: { name: string; current: string; candidate: string }[] + /** Pakete, die Ubuntu noch zurückhält: "phasing" (schrittweise Verteilung) oder "kept_back". */ + held_back?: { name: string; reason: string }[] + // Motor und llama-swap + installed_build?: number | null + latest_build?: number | null + latest_tag?: string | null + name?: string | null + url?: string | null + // Hermes + branch?: string | null + installed_version?: string | null + behind?: number + commits?: { hash: string; subject: string; when: string }[] + // Zusammenfassung durchs Hirn (Motor, llama-swap, Hermes) — nur, wenn es Neues gibt + summary?: string + /** Musst du etwas tun? false = nein, true = ja (siehe action_text), null = unbekannt. */ + action_needed?: boolean | null + action_text?: string } export interface Nutzung { @@ -120,14 +157,28 @@ export interface Modell { capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record } +/** Zustände eines Auftrags, wie services/jobengine.py sie schreibt („canceled“ mit einem l). */ +export type JobZustand = "queued" | "running" | "done" | "failed" | "canceled" + +/** Ein Hintergrund-Auftrag (Update, Modell-Download, Suche) aus services/jobengine.py. */ export interface Job { id: string label: string - state: "running" | "done" | "failed" | "cancelled" | string + state: JobZustand + /** "maintenance" für Updates (davon läuft immer nur eins). Downloads und die Suche haben keine. */ + group: string | null + log: string[] + returncode: number | null + /** Unix-Sekunden */ + started_at: number + finished_at: number | null + canceled?: boolean + /** Nur bei Downloads: Fortschritt 0…100 und die Bytes dazu. */ progress?: number | null - log?: string[] - started?: number - finished?: number | null + total_bytes?: number + done_bytes?: number + rate_bps?: number + eta_s?: number } export interface Sicherung { diff --git a/frontend/src/views/Updates.tsx b/frontend/src/views/Updates.tsx index 04d6cc7..766a97b 100644 --- a/frontend/src/views/Updates.tsx +++ b/frontend/src/views/Updates.tsx @@ -1,5 +1,7 @@ import { useState } from "react" +import { ChevronRight } from "lucide-react" import { cn } from "cn" +import { Auftraege } from "@/components/cockpit/Auftraege" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" @@ -8,8 +10,8 @@ import { type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails, useUpdateStarten, useUpdateVerlauf, useZurueckspielen, } from "@/lib/abfragen" -import { versionen } from "@/lib/anzeige" -import type { Festgehalten, Job, Sicherung } from "@/lib/typen" +import { detailsHabenNeues, laeuftNoch, pruefFehlerText, versionen } from "@/lib/anzeige" +import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen" import { flugplanZeit } from "@/lib/zeit" const BAUSTEINE = [ @@ -19,46 +21,171 @@ const BAUSTEINE = [ { id: "hermes", name: "Hermes" }, ] as const -function BausteinZeile({ id, name, gelesen, hatUpdate, fest, onStart, onFreigeben, laeuftGerade }: { +/** Was ein einzelnes Update tut — steht in der Rückfrage, bevor es losgeht. */ +const EINZEL_TEXT: Record = { + os: "Die Ubuntu-Pakete werden eingespielt, danach prüft die Box, ob die Modelle noch antworten. Das kann einige Minuten dauern.", + engine: "Der Motor wird ersetzt, llama-swap startet dafür neu und die Modelle sind kurz nicht erreichbar. Klappt die Prüfung danach nicht, holt die Box den alten Stand von selbst zurück.", + swap: "llama-swap wird ersetzt und startet neu, die Modelle sind kurz nicht erreichbar. Klappt die Prüfung danach nicht, holt die Box den alten Stand von selbst zurück.", + hermes: "Vorher wird gesichert. Dann wird Hermes aktualisiert und startet neu — Lucy ist dabei ein paar Minuten nicht erreichbar.", +} + +/** Platzhalter, solange die Box noch nachsieht — statt „…“ oder eines voreiligen „neu“. */ +function Skelett({ breit = "w-16" }: { breit?: string }) { + return ( + + + wird geprüft + + ) +} + +/** Die Zusammenfassung des Hirns: drei Zeilen sichtbar, der Rest auf Knopfdruck. */ +function Zusammenfassung({ text }: { text: string }) { + const [offen, setOffen] = useState(false) + const lang = text.split("\n").length > 3 || text.length > 240 + return ( +
+

{text}

+ {lang && ( + + )} +
+ ) +} + +/** Die Paketliste des Betriebssystems, aufklappbar — dazu, was Ubuntu noch zurückhält. */ +function Paketliste({ d }: { d: UpdateDetails }) { + const pakete = d.packages ?? [] + const zurueck = d.held_back ?? [] + if (pakete.length === 0 && zurueck.length === 0) return null + return ( +
+ + + {pakete.length === 1 ? "Das Paket ansehen" : `Alle ${pakete.length} Pakete ansehen`} + +
    + {pakete.map((p) => ( +
  • + {p.name}{" "} + {p.current} → {p.candidate} +
  • + ))} +
+ {zurueck.length > 0 && ( +

+ Dazu {zurueck.length === 1 ? "kommt ein Paket" : `kommen ${zurueck.length} Pakete`}, die Ubuntu noch + zurückhält ({zurueck.map((p) => p.name).join(", ")}). Die kommen von selbst, wenn Ubuntu sie freigibt. +

+ )} +
+ ) +} + +/** Das Urteil des Hirns aus den Versionshinweisen: Muss der Besitzer etwas tun? */ +function Urteil({ d }: { d: UpdateDetails }) { + if (d.action_needed === false) return Nichts zu tun + if (d.action_needed !== true) return null + return ( + + Du musst etwas tun + {d.action_text && {d.action_text}} + + ) +} + +function zusammenfassung(summary: string | undefined): string | null { + const s = summary?.trim() + if (!s) return null + // Das Backend meldet ein nicht antwortendes Hirn als „(Zusammenfassung nicht verfügbar: )“. + if (s.startsWith("(Zusammenfassung nicht verfügbar")) return "Die Zusammenfassung ist gerade nicht verfügbar." + return s +} + +function BausteinZeile({ id, name, neuLautStart, fest, gesperrt, onStart, onFreigeben }: { id: string name: string - gelesen: boolean - hatUpdate: boolean + /** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */ + neuLautStart: string | undefined fest: Festgehalten | undefined + gesperrt: boolean onStart: () => void onFreigeben: () => void - laeuftGerade: boolean }) { - const { data, isPending } = useUpdateDetails(id) - const v = versionen(id, data) - const neu = hatUpdate ? v.neu ?? "neu" : null - const was = !gelesen - ? "Wird gerade geprüft …" - : !hatUpdate - ? "Aktuell." - : data?.summary?.trim() || (id === "os" && data?.count ? "Paketliste unten in den Details." : isPending ? "Wird gelesen …" : "") - const neuText = neu ? {neu} : + 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) + + const laeuftZelle = laedt ? : <>{v?.laeuft ?? "–"} + const neuZelle = laedt ? ( + + ) : neu ? ( + {neu} + ) : fehler ? ( + nicht geprüft + ) : ( + + ) + + let was + if (laedt) { + was = Wird geprüft … + } else if (fehler && !hatUpdate) { + was = {pruefFehlerText(fehler)} + } else if (!hatUpdate) { + was = "Aktuell." + } else { + was = ( + + {fehler && {pruefFehlerText(fehler)}} + {d && } + {id === "os" ? ( + <> + {d?.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"} aus Ubuntu.` : "Neue Pakete aus Ubuntu."} + {d && } + + ) : summary ? ( + + ) : null} + + ) + } + // Am PC eine Tabellenzeile, am Handy ein Block: Name und Versionen oben, darunter was sich // ändert, darunter die Knöpfe — ohne seitliches Scrollen. return (
{name} - {v.laeuft} - {neu && <> → {neuText}} + {laeuftZelle} + {!laedt && (neu || fehler) && <> {neu ? "→" : "·"} {neuZelle}} - {v.laeuft} - {neuText} + {laeuftZelle} + {neuZelle} {fest && ( Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}. )} - {was} + {was} {(fest || hatUpdate) && ( @@ -68,7 +195,7 @@ function BausteinZeile({ id, name, gelesen, hatUpdate, fest, onStart, onFreigebe )} {hatUpdate && ( - )} @@ -89,17 +216,22 @@ const PUNKT: Record = { function LaufEintrag({ lauf }: { lauf: UpdateLauf }) { return (
  • -
    - {flugplanZeit(lauf.start)} - {lauf.anlass} - {lauf.titel} + {/* Handy: Zeit und Ergebnis oben, der Anlass darunter über die volle Breite. */} +
    + {flugplanZeit(lauf.start)} + + {lauf.anlass} + + + {lauf.titel} +
    {lauf.stufe !== "grau" && lauf.zeilen.length > 0 && (
      {lauf.zeilen.map((z) => (
    • - + {z.baustein} · {z.text}
    • @@ -110,28 +242,17 @@ function LaufEintrag({ lauf }: { lauf: UpdateLauf }) { ) } -function JobKasten({ job }: { job: Job & { group?: string; log?: string[] } }) { - const art = job.state === "done" ? "gruen" : job.state === "failed" ? "rot" : "cyan" - const text = job.state === "done" ? "Fertig" : job.state === "failed" ? "Fehler" : job.state === "queued" ? "Wartet" : "Läuft" - return ( -
      -
      - {job.label} - {text} -
      - {job.log && job.log.length > 0 && ( -
      -          {job.log.slice(-8).join("\n")}
      -        
      - )} -
      - ) -} - function sicherungsZeit(s: Sicherung) { return s.mtime ? flugplanZeit(new Date(s.mtime * 1000).toISOString()) : s.snapshot } +type Frage = + | null + | { art: "alle" } + | { art: "einzeln"; id: string; name: string } + | { art: "freigeben"; id: string; name: string; grund: string } + | { art: "zurueck"; datei: string; zeit: string } + export function UpdatesSeite() { const start = useStart() const jobs = useJobs() @@ -141,12 +262,100 @@ export function UpdatesSeite() { const freigeben = useFreigeben() const sichern = useJetztSichern() const zurueck = useZurueckspielen() - const [frage, setFrage] = useState(null) + const [frage, setFrage] = useState(null) - const mitUpdate = new Set(start.data?.updates.bausteine.map((b) => b.id) ?? []) + 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 wartungsJobs = (jobs.data?.jobs ?? []).filter((j) => (j as { group?: string }).group === "maintenance") - const laeuft = wartungsJobs.some((j) => j.state === "running" || j.state === "queued") + 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)) + const gesperrt = updateLaeuft || updateStarten.isPending + const zurueckMoeglich = sicherungen.data?.available ?? false + + let tabelle + if (start.isPending) { + tabelle = + } else if (start.isError && !start.data) { + tabelle = + } else { + tabelle = ( +
      +
      + Baustein + Läuft + Neu + Was sich ändert + Aktion +
      + {BAUSTEINE.map((b) => ( + setFrage({ art: "einzeln", id: b.id, name: b.name })} + onFreigeben={() => { + const f = festgehalten.get(b.id) + if (f) setFrage({ art: "freigeben", id: b.id, name: b.name, grund: f.grund }) + }} + /> + ))} +
      + ) + } + + let verlaufInhalt + if (verlauf.isPending) { + verlaufInhalt =

      Wird gelesen …

      + } else if (verlauf.isError && !verlauf.data) { + verlaufInhalt =

      Der Verlauf lässt sich gerade nicht lesen: {verlauf.error.message}

      + } else if (!verlauf.data?.laeufe.length) { + verlaufInhalt =

      Noch keine Update-Läufe aufgezeichnet.

      + } else { + verlaufInhalt = ( +
        + {verlauf.data.laeufe.map((l) => )} +
      + ) + } + + let sicherungsInhalt + if (sicherungen.isPending) { + sicherungsInhalt =

      Wird gelesen …

      + } else if (sicherungen.isError && !sicherungen.data) { + sicherungsInhalt =

      Die Sicherungen lassen sich gerade nicht lesen: {sicherungen.error.message}

      + } else if (!sicherungen.data?.backups.length) { + sicherungsInhalt =

      Noch keine Sicherung vorhanden.

      + } else { + sicherungsInhalt = ( +
        + {sicherungen.data.backups.slice(0, 5).map((s) => ( +
      • + {sicherungsZeit(s)} + + {s.size_mb.toLocaleString("de-DE")} MB + + +
      • + ))} +
      + ) + } return ( <> @@ -154,10 +363,14 @@ export function UpdatesSeite() { titel="Updates" rechts={
      - -
      @@ -166,103 +379,79 @@ export function UpdatesSeite() {

      Jeden Sonntag um 04:30 automatisch. Vorher wird gesichert, danach geprüft.

      - {start.isPending ? ( - - ) : ( -
      -
      - Baustein - Läuft - Neu - Was sich ändert - Aktion -
      - {BAUSTEINE.map((b) => ( - updateStarten.mutate(b.id)} - onFreigeben={() => freigeben.mutate(b.id)} - /> - ))} -
      - )} - {wartungsJobs.length > 0 && ( -
      -

      Laufende und letzte Aufträge

      - {wartungsJobs.slice(-3).reverse().map((j) => )} -
      + {updateLaeuft && ( +

      + Gerade läuft ein Update. Weitere lassen sich erst starten, wenn es fertig ist. +

      )} + {tabelle} +
      - - {verlauf.data?.laeufe.length ? ( -
        - {verlauf.data.laeufe.map((l) => )} -
      - ) : ( -

      - {verlauf.isPending ? "Wird gelesen …" : "Noch keine Update-Läufe aufgezeichnet."} -

      - )} -
      + {verlaufInhalt} sichern.mutate(undefined)} disabled={sichern.isPending}> - Jetzt sichern + {sichern.isPending ? "Sichert …" : "Jetzt sichern"} } >

      Täglich um 03:35, mit Kopie auf einem zweiten Gerät.

      -
        - {(sicherungen.data?.backups ?? []).slice(0, 5).map((s) => ( -
      • - {sicherungsZeit(s)} - - {s.size_mb.toLocaleString("de-DE")} MB - - -
      • - ))} -
      + {sicherungen.data && !zurueckMoeglich && ( +

      Zurückspielen geht nur auf der Box selbst.

      + )} + {sicherungsInhalt}
      setFrage(null)} onJa={() => { updateStarten.mutate("alle") setFrage(null) }} /> + setFrage(null)} + onJa={() => { + if (frage?.art === "einzeln") updateStarten.mutate(frage.id) + setFrage(null) + }} + /> + setFrage(null)} + onJa={() => { + if (frage?.art === "freigeben") freigeben.mutate(frage.id) + setFrage(null) + }} + /> setFrage(null)} onJa={() => { if (frage?.art === "zurueck") zurueck.mutate(frage.datei)