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,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, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog" } 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 offen: boolean
titel: string titel: string
text: string text: string
knopf: string knopf: string
neinKnopf?: string
gefahr?: boolean gefahr?: boolean
gesperrt?: boolean
onJa: () => void onJa: () => void
onNein: () => void onNein: () => void
children?: ReactNode children?: ReactNode
@@ -19,15 +25,22 @@ export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, c
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}> <Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
<DialogContent className="border-linie bg-panel sm:max-w-lg"> <DialogContent className="border-linie bg-panel sm:max-w-lg">
<DialogHeader> <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> <DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader> </DialogHeader>
{children} {children}
<DialogFooter className="gap-2 sm:gap-2"> <DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={onNein}> <Button variant="outline" onClick={onNein}>
Abbrechen {neinKnopf}
</Button> </Button>
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}> <Button
variant={gefahr ? "gefahr" : "default"}
disabled={gesperrt}
aria-busy={gesperrt || undefined}
onClick={() => {
if (!gesperrt) onJa()
}}
>
{knopf} {knopf}
</Button> </Button>
</DialogFooter> </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>
)
}
+65 -28
View File
@@ -3,16 +3,28 @@
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur // Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
// Sicherheitsnetz und laufen fünfmal seltener. // 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 { api, post } from "./api"
import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen" import { melden } from "./meldungen"
import { stromVerbunden } from "./strom" import { stromVerbunden } from "./strom"
import type { import type {
AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start, AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start, UpdateDetails,
} from "./typen" } from "./typen"
import { gbText } from "./zeit"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms) 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 = () => export const useStart = () =>
useQuery({ useQuery({
queryKey: ["start"], queryKey: ["start"],
@@ -34,14 +46,19 @@ export const useNutzung = () =>
export const useRadar = () => export const useRadar = () =>
useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 }) useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 })
export const useJobs = () => export const useJobs = () => {
useQuery({ const qc = useQueryClient()
return useQuery({
queryKey: ["jobs"], 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. // Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom.
refetchInterval: (q) => refetchInterval: (q) => (q.state.data?.jobs.some(laeuftNoch) ? 2000 : takt(30_000)()),
q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(),
}) })
}
export const useSicherungen = () => export const useSicherungen = () =>
useQuery({ useQuery({
@@ -49,21 +66,6 @@ export const useSicherungen = () =>
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"), 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) => export const useUpdateDetails = (art: string | null) =>
useQuery({ useQuery({
queryKey: ["update-details", art], queryKey: ["update-details", art],
@@ -108,7 +110,12 @@ export const useDienste = () =>
function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>( function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
fn: (v: V) => Promise<E>, fn: (v: V) => Promise<E>,
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() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -116,7 +123,7 @@ function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
onSuccess: (e, v) => { onSuccess: (e, v) => {
const ergebnis = e as AktionsErgebnis const ergebnis = e as AktionsErgebnis
if (ergebnis && ergebnis.ok === false) { if (ergebnis && ergebnis.ok === false) {
melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.") melden("fehler", opt.fehler?.(v, ergebnis) ?? fehlerText(ergebnis))
} else { } else {
melden("erfolg", opt.erfolg(v, e)) melden("erfolg", opt.erfolg(v, e))
} }
@@ -130,7 +137,11 @@ export const useHinweisAktion = () =>
useAktion( useAktion(
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) => ({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`), post<AktionsErgebnis>(`/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<string, string> = { const UPDATE_PFADE: Record<string, string> = {
@@ -144,10 +155,34 @@ const UPDATE_PFADE: Record<string, string> = {
export const useUpdateStarten = () => export const useUpdateStarten = () =>
useAktion((art: string) => post<AktionsErgebnis>(UPDATE_PFADE[art]), { useAktion((art: string) => post<AktionsErgebnis>(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"], neuLaden: ["jobs", "start"],
}) })
/** Einen laufenden Auftrag abbrechen (nur Downloads und die Suche, siehe `abbrechbar`). */
export const useJobAbbrechen = () =>
useAktion(({ id }: { id: string; titel: string }) => post<AktionsErgebnis>(`/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<AktionsErgebnis & { total_bytes?: number }>("/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 = () => export const useAufraeumen = () =>
useQuery({ useQuery({
queryKey: ["aufraeumen"], queryKey: ["aufraeumen"],
@@ -168,8 +203,10 @@ export const useFreigeben = () =>
) )
export const useJetztSichern = () => export const useJetztSichern = () =>
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), { useAktion(() => post<AktionsErgebnis & { size_mb?: number }>("/api/system/backup"), {
erfolg: () => "Sicherung erstellt.", 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"], neuLaden: ["sicherungen", "start"],
}) })
+106 -6
View File
@@ -1,8 +1,8 @@
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne // 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. // DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann.
import type { UpdateDetails } from "./abfragen" import type { AktionsErgebnis, Job, Modell, Start, UpdateDetails } from "./typen"
import type { Modell, Start } from "./typen" import { gbText } from "./zeit"
// --- Rundinstrument ----------------------------------------------------------------- // --- Rundinstrument -----------------------------------------------------------------
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung. // 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
@@ -57,10 +57,9 @@ export function hauptleuchte(z: Start["zustand"]) {
// --- Updates ------------------------------------------------------------------------ // --- Updates ------------------------------------------------------------------------
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */ /** „Läuft“ und „Neu“ aus den Update-Details lesbar machen (die Details sind schon geladen). */
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } { export function versionen(id: string, d: UpdateDetails): { laeuft: string; neu: string | null } {
if (!d) return { laeuft: "…", neu: null } if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"}` : null }
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
// Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand. // Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand.
if (id === "hermes") { if (id === "hermes") {
return { return {
@@ -74,6 +73,107 @@ export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: s
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null } 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<Job, "state">) => 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<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)
/** „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<Job, "state" | "started_at">, 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<Job, "log">): 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 ------------------------------------------------------------------------ // --- Modelle ------------------------------------------------------------------------
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */ /** Rolle eines Modells aus seinen llama-swap-Aliasen. */
+56 -5
View File
@@ -1,5 +1,6 @@
// Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py, // 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 Stufe = "rot" | "gelb"
export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler" export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler"
@@ -90,12 +91,48 @@ export interface AufraeumKandidat {
hinweis: string hinweis: string
} }
/** Antwort einer Aktion. Je nach Endpunkt steht der Fehler in einem anderen Feld. */
export interface AktionsErgebnis { export interface AktionsErgebnis {
ok: boolean ok: boolean
text?: string text?: string
out?: string out?: string
err?: string err?: string
detail?: 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 { export interface Nutzung {
@@ -120,14 +157,28 @@ export interface Modell {
capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown> capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown>
} }
/** 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 { export interface Job {
id: string id: string
label: 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 progress?: number | null
log?: string[] total_bytes?: number
started?: number done_bytes?: number
finished?: number | null rate_bps?: number
eta_s?: number
} }
export interface Sicherung { export interface Sicherung {
+291 -102
View File
@@ -1,5 +1,7 @@
import { useState } from "react" import { useState } from "react"
import { ChevronRight } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { Auftraege } from "@/components/cockpit/Auftraege"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel" import { Panel } from "@/components/cockpit/Panel"
@@ -8,8 +10,8 @@ import {
type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails, type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails,
useUpdateStarten, useUpdateVerlauf, useZurueckspielen, useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { versionen } from "@/lib/anzeige" import { detailsHabenNeues, laeuftNoch, pruefFehlerText, versionen } from "@/lib/anzeige"
import type { Festgehalten, Job, Sicherung } from "@/lib/typen" import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
const BAUSTEINE = [ const BAUSTEINE = [
@@ -19,46 +21,171 @@ const BAUSTEINE = [
{ id: "hermes", name: "Hermes" }, { id: "hermes", name: "Hermes" },
] as const ] 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<string, string> = {
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 (
<span className="inline-flex items-center">
<span aria-hidden className={cn("inline-block h-3.5 rounded bg-linie motion-safe:animate-pulse", breit)} />
<span className="sr-only">wird geprüft</span>
</span>
)
}
/** 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 (
<div className="flex flex-col items-start">
<p className={cn("m-0 whitespace-pre-line", lang && !offen && "line-clamp-3")}>{text}</p>
{lang && (
<button
type="button"
aria-expanded={offen}
onClick={() => setOffen(!offen)}
className="-mb-2 inline-flex h-11 items-center text-sm text-cyan hover:underline"
>
{offen ? "Weniger zeigen" : "Ganze Zusammenfassung zeigen"}
</button>
)}
</div>
)
}
/** 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 (
<details className="group">
<summary className="inline-flex h-11 cursor-pointer items-center gap-1 text-sm text-cyan hover:underline">
<ChevronRight aria-hidden className="size-4 transition-transform group-open:rotate-90" />
{pakete.length === 1 ? "Das Paket ansehen" : `Alle ${pakete.length} Pakete ansehen`}
</summary>
<ul className="ziffern m-0 flex max-h-64 list-none flex-col gap-1 overflow-auto rounded-md border border-linie bg-[#0b0d0f] p-2.5 text-xs">
{pakete.map((p) => (
<li key={p.name} className="break-all">
<span className="text-foreground">{p.name}</span>{" "}
<span className="text-text-3">{p.current} → {p.candidate}</span>
</li>
))}
</ul>
{zurueck.length > 0 && (
<p className="mt-2 text-xs text-text-3">
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.
</p>
)}
</details>
)
}
/** Das Urteil des Hirns aus den Versionshinweisen: Muss der Besitzer etwas tun? */
function Urteil({ d }: { d: UpdateDetails }) {
if (d.action_needed === false) return <Chip art="gruen">Nichts zu tun</Chip>
if (d.action_needed !== true) return null
return (
<span className="flex flex-col items-start gap-1.5">
<Chip art="bernstein">Du musst etwas tun</Chip>
{d.action_text && <span className="text-bernstein">{d.action_text}</span>}
</span>
)
}
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: <Fehler>)“.
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 id: string
name: string name: string
gelesen: boolean /** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */
hatUpdate: boolean neuLautStart: string | undefined
fest: Festgehalten | undefined fest: Festgehalten | undefined
gesperrt: boolean
onStart: () => void onStart: () => void
onFreigeben: () => void onFreigeben: () => void
laeuftGerade: boolean
}) { }) {
const { data, isPending } = useUpdateDetails(id) const details = useUpdateDetails(id)
const v = versionen(id, data) const d = details.data
const neu = hatUpdate ? v.neu ?? "neu" : null const laedt = details.isPending
const was = !gelesen const fehler = details.isError ? details.error.message : d?.error
? "Wird gerade geprüft …" const v = d ? versionen(id, d) : null
: !hatUpdate // Frische Details schlagen den gecachten Stand der Startseite; wissen sie nichts, gilt der Start.
? "Aktuell." const hatUpdate = detailsHabenNeues(id, d) ?? neuLautStart != null
: data?.summary?.trim() || (id === "os" && data?.count ? "Paketliste unten in den Details." : isPending ? "Wird gelesen …" : "") const neu = hatUpdate ? v?.neu ?? neuLautStart ?? null : null
const neuText = neu ? <span className="text-cyan">{neu}</span> : <span className="text-text-3">–</span> const summary = zusammenfassung(d?.summary)
const laeuftZelle = laedt ? <Skelett /> : <>{v?.laeuft ?? "–"}</>
const neuZelle = laedt ? (
<Skelett breit="w-20" />
) : neu ? (
<span className="text-cyan">{neu}</span>
) : fehler ? (
<span className="text-bernstein">nicht geprüft</span>
) : (
<span className="text-text-3">–</span>
)
let was
if (laedt) {
was = <span className="text-text-3">Wird geprüft …</span>
} else if (fehler && !hatUpdate) {
was = <span className="text-bernstein">{pruefFehlerText(fehler)}</span>
} else if (!hatUpdate) {
was = "Aktuell."
} else {
was = (
<span className="flex flex-col items-start gap-2">
{fehler && <span className="text-bernstein">{pruefFehlerText(fehler)}</span>}
{d && <Urteil d={d} />}
{id === "os" ? (
<>
<span>{d?.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"} aus Ubuntu.` : "Neue Pakete aus Ubuntu."}</span>
{d && <Paketliste d={d} />}
</>
) : summary ? (
<Zusammenfassung text={summary} />
) : null}
</span>
)
}
// Am PC eine Tabellenzeile, am Handy ein Block: Name und Versionen oben, darunter was sich // Am PC eine Tabellenzeile, am Handy ein Block: Name und Versionen oben, darunter was sich
// ändert, darunter die Knöpfe — ohne seitliches Scrollen. // ändert, darunter die Knöpfe — ohne seitliches Scrollen.
return ( return (
<div <div
role="row" role="row"
aria-busy={laedt || undefined}
className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-4 gap-y-2 border-t border-linie py-3.5 md:grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] md:items-start" className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-4 gap-y-2 border-t border-linie py-3.5 md:grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] md:items-start"
> >
<span role="rowheader" className="text-[15px] font-semibold">{name}</span> <span role="rowheader" className="text-[15px] font-semibold">{name}</span>
<span role="cell" className="ziffern text-right text-sm text-text-2 md:hidden"> <span role="cell" className="ziffern text-right text-sm text-text-2 md:hidden">
{v.laeuft} {laeuftZelle}
{neu && <> → {neuText}</>} {!laedt && (neu || fehler) && <> {neu ? "→" : "·"} {neuZelle}</>}
</span> </span>
<span role="cell" className="ziffern hidden text-sm text-text-2 md:block">{v.laeuft}</span> <span role="cell" className="ziffern hidden text-sm text-text-2 md:block">{laeuftZelle}</span>
<span role="cell" className="ziffern hidden text-sm md:block">{neuText}</span> <span role="cell" className="ziffern hidden text-sm md:block">{neuZelle}</span>
<span role="cell" className="col-span-2 text-sm leading-relaxed text-text-2 md:col-span-1"> <span role="cell" className="col-span-2 text-sm leading-relaxed text-text-2 md:col-span-1">
{fest && ( {fest && (
<span className="mb-1.5 block text-bernstein"> <span className="mb-1.5 block text-bernstein">
Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}. Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}.
</span> </span>
)} )}
<span className="line-clamp-3 whitespace-pre-line">{was}</span> {was}
</span> </span>
{(fest || hatUpdate) && ( {(fest || hatUpdate) && (
<span role="cell" className="col-span-2 flex flex-wrap gap-2 md:col-span-1 md:flex-col md:items-end"> <span role="cell" className="col-span-2 flex flex-wrap gap-2 md:col-span-1 md:flex-col md:items-end">
@@ -68,7 +195,7 @@ function BausteinZeile({ id, name, gelesen, hatUpdate, fest, onStart, onFreigebe
</Button> </Button>
)} )}
{hatUpdate && ( {hatUpdate && (
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}> <Button variant="outline" size="sm" onClick={onStart} disabled={gesperrt}>
Aktualisieren Aktualisieren
</Button> </Button>
)} )}
@@ -89,17 +216,22 @@ const PUNKT: Record<UpdateLauf["stufe"], string> = {
function LaufEintrag({ lauf }: { lauf: UpdateLauf }) { function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
return ( return (
<li className="flex flex-col gap-2 border-t border-linie py-3"> <li className="flex flex-col gap-2 border-t border-linie py-3">
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 md:grid-cols-[112px_minmax(0,1fr)_auto]"> {/* Handy: Zeit und Ergebnis oben, der Anlass darunter über die volle Breite. */}
<span className="ziffern text-sm text-text-3">{flugplanZeit(lauf.start)}</span> <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 md:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="min-w-0 text-[15px]">{lauf.anlass}</span> <span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(lauf.start)}</span>
<span className="col-span-2 row-start-2 min-w-0 text-[15px] break-words md:col-span-1 md:col-start-2 md:row-start-1">
{lauf.anlass}
</span>
<span className="col-start-2 row-start-1 justify-self-end md:col-start-3">
<Chip art={LAUF_ART[lauf.stufe]}>{lauf.titel}</Chip> <Chip art={LAUF_ART[lauf.stufe]}>{lauf.titel}</Chip>
</span>
</div> </div>
{lauf.stufe !== "grau" && lauf.zeilen.length > 0 && ( {lauf.stufe !== "grau" && lauf.zeilen.length > 0 && (
<ul className="m-0 flex list-none flex-col gap-1 p-0 md:pl-[124px]"> <ul className="m-0 flex list-none flex-col gap-1 p-0 md:pl-[124px]">
{lauf.zeilen.map((z) => ( {lauf.zeilen.map((z) => (
<li key={`${z.baustein}-${z.text}`} className="flex items-baseline gap-2 text-sm leading-snug text-text-2"> <li key={`${z.baustein}-${z.text}`} className="flex items-baseline gap-2 text-sm leading-snug text-text-2">
<span aria-hidden className={cn("size-1.5 shrink-0 -translate-y-px rounded-full", PUNKT[z.stufe])} /> <span aria-hidden className={cn("size-1.5 shrink-0 -translate-y-px rounded-full", PUNKT[z.stufe])} />
<span className="min-w-0"> <span className="min-w-0 break-words">
<span className="text-foreground">{z.baustein}</span> · {z.text} <span className="text-foreground">{z.baustein}</span> · {z.text}
</span> </span>
</li> </li>
@@ -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 (
<div className="flex flex-col gap-2 rounded-xl border border-linie bg-erhaben/50 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[15px] font-semibold">{job.label}</span>
<Chip art={art}>{text}</Chip>
</div>
{job.log && job.log.length > 0 && (
<pre className="ziffern max-h-40 overflow-auto rounded-md bg-[#0b0d0f] p-2 text-xs whitespace-pre-wrap text-text-2">
{job.log.slice(-8).join("\n")}
</pre>
)}
</div>
)
}
function sicherungsZeit(s: Sicherung) { function sicherungsZeit(s: Sicherung) {
return s.mtime ? flugplanZeit(new Date(s.mtime * 1000).toISOString()) : s.snapshot 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() { export function UpdatesSeite() {
const start = useStart() const start = useStart()
const jobs = useJobs() const jobs = useJobs()
@@ -141,34 +262,26 @@ export function UpdatesSeite() {
const freigeben = useFreigeben() const freigeben = useFreigeben()
const sichern = useJetztSichern() const sichern = useJetztSichern()
const zurueck = useZurueckspielen() const zurueck = useZurueckspielen()
const [frage, setFrage] = useState<null | { art: "alle" } | { art: "zurueck"; datei: string; zeit: string }>(null) const [frage, setFrage] = useState<Frage>(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 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 alleJobs = jobs.data?.jobs ?? []
const laeuft = wartungsJobs.some((j) => j.state === "running" || j.state === "queued") // 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
return ( let tabelle
<> if (start.isPending) {
<Panel tabelle = <Zustandsfeld text="Updates werden gelesen …" />
titel="Updates" } else if (start.isError && !start.data) {
rechts={ tabelle = <Zustandsfeld fehler text={`Die Box antwortet nicht: ${start.error.message}`} />
<div className="flex flex-wrap gap-2.5"> } else {
<Button variant="outline" onClick={() => updateStarten.mutate("pruefen")} disabled={updateStarten.isPending}> tabelle = (
Nach Neuem suchen
</Button>
<Button onClick={() => setFrage({ art: "alle" })} disabled={laeuft || mitUpdate.size === 0}>
Alles jetzt aktualisieren
</Button>
</div>
}
>
<p className="-mt-2 text-[15px] text-text-2">
Jeden Sonntag um 04:30 automatisch. Vorher wird gesichert, danach geprüft.
</p>
{start.isPending ? (
<Zustandsfeld text="Updates werden gelesen …" />
) : (
<div role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col"> <div role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col">
<div <div
role="row" role="row"
@@ -185,48 +298,46 @@ export function UpdatesSeite() {
key={b.id} key={b.id}
id={b.id} id={b.id}
name={b.name} name={b.name}
gelesen={start.data?.updates.gelesen ?? false} neuLautStart={neuLautStart.get(b.id)}
hatUpdate={mitUpdate.has(b.id)}
fest={festgehalten.get(b.id)} fest={festgehalten.get(b.id)}
laeuftGerade={laeuft} gesperrt={gesperrt}
onStart={() => updateStarten.mutate(b.id)} onStart={() => setFrage({ art: "einzeln", id: b.id, name: b.name })}
onFreigeben={() => freigeben.mutate(b.id)} onFreigeben={() => {
const f = festgehalten.get(b.id)
if (f) setFrage({ art: "freigeben", id: b.id, name: b.name, grund: f.grund })
}}
/> />
))} ))}
</div> </div>
)} )
{wartungsJobs.length > 0 && ( }
<div className="flex flex-col gap-2.5">
<h3 className="schild text-sm text-text-3">Laufende und letzte Aufträge</h3>
{wartungsJobs.slice(-3).reverse().map((j) => <JobKasten key={j.id} job={j} />)}
</div>
)}
</Panel>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]"> let verlaufInhalt
<Panel titel="Verlauf"> if (verlauf.isPending) {
{verlauf.data?.laeufe.length ? ( verlaufInhalt = <p className="text-[15px] text-text-2">Wird gelesen …</p>
} else if (verlauf.isError && !verlauf.data) {
verlaufInhalt = <p role="alert" className="text-[15px] text-rot-text">Der Verlauf lässt sich gerade nicht lesen: {verlauf.error.message}</p>
} else if (!verlauf.data?.laeufe.length) {
verlaufInhalt = <p className="text-[15px] text-text-2">Noch keine Update-Läufe aufgezeichnet.</p>
} else {
verlaufInhalt = (
<ol className="m-0 flex list-none flex-col p-0"> <ol className="m-0 flex list-none flex-col p-0">
{verlauf.data.laeufe.map((l) => <LaufEintrag key={l.start} lauf={l} />)} {verlauf.data.laeufe.map((l) => <LaufEintrag key={l.start} lauf={l} />)}
</ol> </ol>
) : ( )
<p className="text-[15px] text-text-2">
{verlauf.isPending ? "Wird gelesen …" : "Noch keine Update-Läufe aufgezeichnet."}
</p>
)}
</Panel>
<Panel
titel="Sicherungen"
rechts={
<Button variant="outline" size="sm" onClick={() => sichern.mutate(undefined)} disabled={sichern.isPending}>
Jetzt sichern
</Button>
} }
>
<p className="-mt-2 text-[15px] text-text-2">Täglich um 03:35, mit Kopie auf einem zweiten Gerät.</p> let sicherungsInhalt
if (sicherungen.isPending) {
sicherungsInhalt = <p className="text-[15px] text-text-2">Wird gelesen …</p>
} else if (sicherungen.isError && !sicherungen.data) {
sicherungsInhalt = <p role="alert" className="text-[15px] text-rot-text">Die Sicherungen lassen sich gerade nicht lesen: {sicherungen.error.message}</p>
} else if (!sicherungen.data?.backups.length) {
sicherungsInhalt = <p className="text-[15px] text-text-2">Noch keine Sicherung vorhanden.</p>
} else {
sicherungsInhalt = (
<ul className="m-0 flex list-none flex-col p-0"> <ul className="m-0 flex list-none flex-col p-0">
{(sicherungen.data?.backups ?? []).slice(0, 5).map((s) => ( {sicherungen.data.backups.slice(0, 5).map((s) => (
<li key={s.file} className="flex items-center justify-between gap-3 border-t border-linie py-2.5"> <li key={s.file} className="flex items-center justify-between gap-3 border-t border-linie py-2.5">
<span className="ziffern text-sm">{sicherungsZeit(s)}</span> <span className="ziffern text-sm">{sicherungsZeit(s)}</span>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
@@ -234,7 +345,7 @@ export function UpdatesSeite() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
disabled={!sicherungen.data?.available} disabled={!zurueckMoeglich || zurueck.isPending}
onClick={() => setFrage({ art: "zurueck", datei: s.file, zeit: sicherungsZeit(s) })} onClick={() => setFrage({ art: "zurueck", datei: s.file, zeit: sicherungsZeit(s) })}
> >
Zurückspielen Zurückspielen
@@ -243,26 +354,104 @@ export function UpdatesSeite() {
</li> </li>
))} ))}
</ul> </ul>
)
}
return (
<>
<Panel
titel="Updates"
rechts={
<div className="flex flex-wrap gap-2.5">
<Button
variant="outline"
onClick={() => updateStarten.mutate("pruefen")}
disabled={updateStarten.isPending || suchtGerade}
>
Nach Neuem suchen
</Button>
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || neuLautStart.size === 0}>
Alles jetzt aktualisieren
</Button>
</div>
}
>
<p className="-mt-2 text-[15px] text-text-2">
Jeden Sonntag um 04:30 automatisch. Vorher wird gesichert, danach geprüft.
</p>
{updateLaeuft && (
<p role="status" className="text-[15px] text-cyan">
Gerade läuft ein Update. Weitere lassen sich erst starten, wenn es fertig ist.
</p>
)}
{tabelle}
<Auftraege />
</Panel>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<Panel titel="Verlauf">{verlaufInhalt}</Panel>
<Panel
titel="Sicherungen"
rechts={
<Button variant="outline" size="sm" onClick={() => sichern.mutate(undefined)} disabled={sichern.isPending}>
{sichern.isPending ? "Sichert …" : "Jetzt sichern"}
</Button>
}
>
<p className="-mt-2 text-[15px] text-text-2">Täglich um 03:35, mit Kopie auf einem zweiten Gerät.</p>
{sicherungen.data && !zurueckMoeglich && (
<p className="text-sm text-text-3">Zurückspielen geht nur auf der Box selbst.</p>
)}
{sicherungsInhalt}
</Panel> </Panel>
</div> </div>
<Bestaetigen <Bestaetigen
offen={frage?.art === "alle"} offen={frage?.art === "alle"}
titel="Alles jetzt aktualisieren?" titel="Alles jetzt aktualisieren?"
text="Betriebssystem, Motor, llama-swap und Hermes werden nacheinander aktualisiert. 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. Vorher wird gesichert, danach geprüft. Lucy ist dabei ein paar Minuten nicht erreichbar."
knopf="Aktualisieren" knopf="Aktualisieren"
gesperrt={gesperrt}
onNein={() => setFrage(null)} onNein={() => setFrage(null)}
onJa={() => { onJa={() => {
updateStarten.mutate("alle") updateStarten.mutate("alle")
setFrage(null) setFrage(null)
}} }}
/> />
<Bestaetigen
offen={frage?.art === "einzeln"}
titel={frage?.art === "einzeln" ? `${frage.name} aktualisieren?` : ""}
text={frage?.art === "einzeln" ? EINZEL_TEXT[frage.id] ?? "" : ""}
knopf="Aktualisieren"
gesperrt={gesperrt}
onNein={() => setFrage(null)}
onJa={() => {
if (frage?.art === "einzeln") updateStarten.mutate(frage.id)
setFrage(null)
}}
/>
<Bestaetigen
offen={frage?.art === "freigeben"}
titel={frage?.art === "freigeben" ? `${frage.name} freigeben?` : ""}
text={frage?.art === "freigeben"
? `Festgehalten wurde ${frage.name}, weil: ${frage.grund}. Nach dem Freigeben versucht der nächste Sonntags-Lauf das Update wieder.`
: ""}
knopf="Freigeben"
gesperrt={freigeben.isPending}
onNein={() => setFrage(null)}
onJa={() => {
if (frage?.art === "freigeben") freigeben.mutate(frage.id)
setFrage(null)
}}
/>
<Bestaetigen <Bestaetigen
offen={frage?.art === "zurueck"} offen={frage?.art === "zurueck"}
gefahr gefahr
titel="Sicherung zurückspielen?" titel="Sicherung zurückspielen?"
text={`Die Einstellungen von Hermes und llama-swap werden auf den Stand von ${frage?.art === "zurueck" ? frage.zeit : ""} gesetzt. Vorher wird der jetzige Stand gesichert. Die Dienste starten danach neu.`} text={`Die Einstellungen von Hermes und llama-swap werden auf den Stand von ${frage?.art === "zurueck" ? frage.zeit : ""} gesetzt. Vorher wird der jetzige Stand gesichert. Die Dienste starten danach neu.`}
knopf="Zurückspielen" knopf="Zurückspielen"
gesperrt={zurueck.isPending}
onNein={() => setFrage(null)} onNein={() => setFrage(null)}
onJa={() => { onJa={() => {
if (frage?.art === "zurueck") zurueck.mutate(frage.datei) if (frage?.art === "zurueck") zurueck.mutate(frage.datei)