updates: Urteil und Paketliste sichtbar, ehrliche Lade- und Fehlerzustaende, Rueckfragen, Auftraege aller Gruppen
- Update-Details zeigen jetzt das Urteil des Hirns ("Nichts zu tun" / "Du musst
etwas tun" samt Text), die Zusammenfassung ist aufklappbar statt auf drei Zeilen
gekappt, das Betriebssystem zeigt seine Paketliste (und was Ubuntu zurueckhaelt).
- Scheitert eine Pruefung (z. B. git fetch bei Hermes), steht "Konnte nicht pruefen: ..."
statt "- -> neu"; waehrend des Ladens ein Platzhalter statt "..." und kein voreiliges "neu".
- Einzel-Update und Freigeben fragen vorher nach; der Ja-Knopf sperrt gegen Doppelklick.
- Neue Job-Karte (Fortschritt, Abbrechen mit Rueckfrage, letzte Meldung, Protokoll) fuer
alle laufenden Auftraege, egal welche Gruppe. Updates lassen sich bewusst nicht abbrechen.
- Job-Typen an jobengine.py angeglichen ("canceled", group, started_at/finished_at):
abgebrochene Auftraege erschienen bisher als "Laeuft".
- Fehler aus "Jetzt sichern" (Feld error) und "Es laeuft schon ein Update" (status busy)
werden gemeldet; ist ein Auftrag fertig, lesen Versionen, Modelle und Sicherungen neu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c8718fcde0
commit
5ddc952ecd
@@ -0,0 +1,42 @@
|
||||
import { useJobAbbrechen, useJobs } from "@/lib/abfragen"
|
||||
import { jobTitel, sichtbareJobs } from "@/lib/anzeige"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
/**
|
||||
* Laufende und zuletzt beendete Aufträge — egal, woher sie kommen (Update, Modell-Download, Suche
|
||||
* nach Neuem). Solange nichts läuft und nichts frisch beendet ist, zeigt sie gar nichts.
|
||||
*/
|
||||
export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
|
||||
const jobs = useJobs()
|
||||
const abbrechen = useJobAbbrechen()
|
||||
const liste = sichtbareJobs(jobs.data?.jobs ?? [])
|
||||
|
||||
let inhalt
|
||||
if (liste.length > 0) {
|
||||
inhalt = (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{liste.map((j) => (
|
||||
<JobKarte
|
||||
key={j.id}
|
||||
job={j}
|
||||
abbrechenGesperrt={abbrechen.isPending}
|
||||
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (jobs.isError && !jobs.data) {
|
||||
inhalt = <p role="alert" className="text-sm text-rot-text">Die Aufträge lassen sich gerade nicht lesen: {jobs.error.message}</p>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (alsPanel) return <Panel titel="Aufträge">{inhalt}</Panel>
|
||||
return (
|
||||
<section aria-label="Aufträge" className="flex flex-col gap-2.5">
|
||||
<h3 className="schild m-0 text-sm text-text-3">Aufträge</h3>
|
||||
{inhalt}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,13 +4,19 @@ import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
|
||||
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
|
||||
/**
|
||||
* Rückfrage vor Schritten, die die Box verändern (Updates, Freigeben, Zurückspielen, Neustarts,
|
||||
* Installieren, Abbrechen). `gesperrt` hält den Ja-Knopf fest, solange die Aktion schon unterwegs
|
||||
* ist — ein Doppelklick startet sie so nicht zweimal.
|
||||
*/
|
||||
export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen", gefahr, gesperrt, onJa, onNein, children }: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: string
|
||||
knopf: string
|
||||
neinKnopf?: string
|
||||
gefahr?: boolean
|
||||
gesperrt?: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
children?: ReactNode
|
||||
@@ -19,15 +25,22 @@ export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, c
|
||||
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
|
||||
<DialogContent className="border-linie bg-panel sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
|
||||
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle>
|
||||
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={onNein}>
|
||||
Abbrechen
|
||||
{neinKnopf}
|
||||
</Button>
|
||||
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
|
||||
<Button
|
||||
variant={gefahr ? "gefahr" : "default"}
|
||||
disabled={gesperrt}
|
||||
aria-busy={gesperrt || undefined}
|
||||
onClick={() => {
|
||||
if (!gesperrt) onJa()
|
||||
}}
|
||||
>
|
||||
{knopf}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { sichtbareJobs } from "@/lib/anzeige"
|
||||
import type { Job } from "@/lib/typen"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
|
||||
const JETZT = 1_790_000_000 // Unix-Sekunden, fester Bezugspunkt
|
||||
|
||||
function job(teil: Partial<Job>): Job {
|
||||
return {
|
||||
id: "j1", label: "Nach Updates suchen", state: "running", group: null,
|
||||
log: ["$ bash -c 'sudo apt-get update'"], returncode: null,
|
||||
started_at: JETZT - 120, finished_at: null, ...teil,
|
||||
}
|
||||
}
|
||||
|
||||
describe("JobKarte", () => {
|
||||
it("zeigt bei einem Download Fortschritt und fragt vor dem Abbrechen nach", () => {
|
||||
const onAbbrechen = vi.fn()
|
||||
render(
|
||||
<JobKarte
|
||||
job={job({
|
||||
label: "download unsloth/Qwen3.8-27B-GGUF", progress: 42,
|
||||
done_bytes: 12 * 1024 ** 3, total_bytes: 29 * 1024 ** 3, eta_s: 360,
|
||||
})}
|
||||
onAbbrechen={onAbbrechen}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Download unsloth/Qwen3.8-27B-GGUF")).toBeInTheDocument()
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42")
|
||||
expect(screen.getByText("42 % · 12,0 GB von 29,0 GB · noch etwa 6 min")).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abbrechen" }))
|
||||
expect(screen.getByRole("dialog", { name: "Auftrag abbrechen?" })).toBeInTheDocument()
|
||||
expect(onAbbrechen).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ja, abbrechen" }))
|
||||
expect(onAbbrechen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("nennt einen abgebrochenen Auftrag „Abgebrochen“ und nicht „Läuft“", () => {
|
||||
render(<JobKarte job={job({ state: "canceled", finished_at: JETZT })} onAbbrechen={() => {}} />)
|
||||
expect(screen.getByText("Abgebrochen")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Läuft")).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("bietet bei Updates kein Abbrechen an und zeigt bei Fehlern die letzte Meldung", () => {
|
||||
const { rerender } = render(
|
||||
<JobKarte job={job({ label: "Hermes-Agent-Update", group: "maintenance" })} onAbbrechen={() => {}} />,
|
||||
)
|
||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<JobKarte
|
||||
job={job({
|
||||
label: "Hermes-Agent-Update", group: "maintenance", state: "failed", finished_at: JETZT,
|
||||
log: ["$ bash -c …", "✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)", ""],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Fehler")).toBeInTheDocument()
|
||||
expect(screen.getByText(/Letzte Meldung: ✗ HERMES-UPDATE FEHLGESCHLAGEN/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("sichtbareJobs", () => {
|
||||
it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => {
|
||||
const liste = sichtbareJobs(
|
||||
[
|
||||
job({ id: "alt", state: "done", finished_at: JETZT - 2 * 86_400 }),
|
||||
job({ id: "f1", state: "done", finished_at: JETZT - 50 }),
|
||||
job({ id: "f2", state: "failed", finished_at: JETZT - 40 }),
|
||||
job({ id: "f3", state: "canceled", finished_at: JETZT - 30 }),
|
||||
job({ id: "f4", state: "done", finished_at: JETZT - 20 }),
|
||||
job({ id: "download", label: "download a/b", state: "running", group: null }),
|
||||
job({ id: "update", group: "maintenance", state: "queued", started_at: JETZT - 10 }),
|
||||
],
|
||||
JETZT,
|
||||
)
|
||||
expect(liste.map((j) => j.id)).toEqual(["download", "update", "f4", "f3", "f2"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
abbrechbar, fortschrittText, jobDownload, jobTitel, laeuftNoch, laufText, letzteMeldung,
|
||||
} from "@/lib/anzeige"
|
||||
import type { Job, JobZustand } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
import { Bestaetigen } from "./Bestaetigen"
|
||||
import { Chip, type ChipArt } from "./Chip"
|
||||
|
||||
const ZUSTAND: Record<JobZustand, { text: string; art: ChipArt }> = {
|
||||
queued: { text: "Wartet", art: "cyan" },
|
||||
running: { text: "Läuft", art: "cyan" },
|
||||
done: { text: "Fertig", art: "gruen" },
|
||||
failed: { text: "Fehler", art: "rot" },
|
||||
canceled: { text: "Abgebrochen", art: "grau" },
|
||||
}
|
||||
|
||||
const zeitVon = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
||||
|
||||
/**
|
||||
* Ein Hintergrund-Auftrag als Karte: Titel und Zustand, bei Downloads der Fortschritt, bei Fehlern
|
||||
* die letzte Meldung, auf Wunsch das Protokoll. Abbrechen nur, wo es wirklich stoppt (`abbrechbar`).
|
||||
*/
|
||||
export function JobKarte({ job, onAbbrechen, abbrechenGesperrt }: {
|
||||
job: Job
|
||||
onAbbrechen?: (job: Job) => void
|
||||
abbrechenGesperrt?: boolean
|
||||
}) {
|
||||
const [frage, setFrage] = useState(false)
|
||||
const [protokoll, setProtokoll] = useState(false)
|
||||
const zustand = ZUSTAND[job.state] ?? { text: job.state, art: "grau" as const }
|
||||
const titel = jobTitel(job)
|
||||
const fortschritt = fortschrittText(job)
|
||||
const meldung = letzteMeldung(job)
|
||||
const laeuft = laeuftNoch(job)
|
||||
const darfAbbrechen = !!onAbbrechen && abbrechbar(job)
|
||||
const protokollZeilen = (job.log ?? []).slice(-12)
|
||||
|
||||
return (
|
||||
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h4 className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</h4>
|
||||
<Chip art={zustand.art}>{zustand.text}</Chip>
|
||||
</div>
|
||||
|
||||
{job.progress != null && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`Fortschritt: ${titel}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(job.progress)}
|
||||
className="h-2 overflow-hidden rounded-full bg-linie"
|
||||
>
|
||||
<div
|
||||
className={job.state === "failed" ? "h-full bg-rot" : "h-full bg-cyan"}
|
||||
style={{ width: `${Math.max(2, Math.min(100, job.progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="ziffern m-0 text-sm text-text-2">
|
||||
{laeuft
|
||||
? fortschritt ?? laufText(job)
|
||||
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
|
||||
</p>
|
||||
|
||||
{/* Bei Fehlern steht der Grund meist in der letzten Zeile; ohne Fortschrittsbalken zeigt sie, woran der Auftrag gerade ist. */}
|
||||
{meldung && (job.state === "failed" || (laeuft && job.progress == null)) && (
|
||||
<p className={job.state === "failed" ? "m-0 text-sm break-words text-rot-text" : "ziffern m-0 line-clamp-2 text-xs break-all text-text-3"}>
|
||||
{job.state === "failed" ? `Letzte Meldung: ${meldung}` : meldung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(darfAbbrechen || protokollZeilen.length > 1) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{darfAbbrechen && (
|
||||
<Button variant="outline" size="sm" disabled={abbrechenGesperrt} onClick={() => setFrage(true)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
{protokollZeilen.length > 1 && (
|
||||
<Button variant="ghost" size="sm" aria-expanded={protokoll} onClick={() => setProtokoll(!protokoll)}>
|
||||
{protokoll ? "Protokoll zu" : "Protokoll"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{protokoll && (
|
||||
<pre className="ziffern m-0 max-h-56 overflow-auto rounded-md bg-[#0b0d0f] p-2 text-xs whitespace-pre-wrap break-all text-text-2">
|
||||
{protokollZeilen.join("\n")}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{darfAbbrechen && (
|
||||
<Bestaetigen
|
||||
offen={frage}
|
||||
gefahr
|
||||
titel="Auftrag abbrechen?"
|
||||
text={jobDownload(job)
|
||||
? `Der ${titel} stoppt. Du kannst ihn später über „Modelle selbst suchen“ neu starten.`
|
||||
: `„${titel}“ wird abgebrochen.`}
|
||||
knopf="Ja, abbrechen"
|
||||
neinKnopf="Weiterlaufen lassen"
|
||||
onNein={() => setFrage(false)}
|
||||
onJa={() => {
|
||||
setFrage(false)
|
||||
onAbbrechen?.(job)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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<Radar>("/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<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
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()
|
||||
return useMutation({
|
||||
@@ -116,7 +123,7 @@ function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
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<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> = {
|
||||
@@ -144,10 +155,34 @@ const UPDATE_PFADE: Record<string, string> = {
|
||||
|
||||
export const useUpdateStarten = () =>
|
||||
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"],
|
||||
})
|
||||
|
||||
/** 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 = () =>
|
||||
useQuery({
|
||||
queryKey: ["aufraeumen"],
|
||||
@@ -168,8 +203,10 @@ export const useFreigeben = () =>
|
||||
)
|
||||
|
||||
export const useJetztSichern = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
|
||||
erfolg: () => "Sicherung erstellt.",
|
||||
useAktion(() => post<AktionsErgebnis & { size_mb?: number }>("/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"],
|
||||
})
|
||||
|
||||
|
||||
+106
-6
@@ -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<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 ------------------------------------------------------------------------
|
||||
|
||||
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
|
||||
|
||||
@@ -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<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 {
|
||||
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 {
|
||||
|
||||
+304
-115
@@ -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<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
|
||||
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 ? <span className="text-cyan">{neu}</span> : <span className="text-text-3">–</span>
|
||||
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 ? <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
|
||||
// ändert, darunter die Knöpfe — ohne seitliches Scrollen.
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<span role="rowheader" className="text-[15px] font-semibold">{name}</span>
|
||||
<span role="cell" className="ziffern text-right text-sm text-text-2 md:hidden">
|
||||
{v.laeuft}
|
||||
{neu && <> → {neuText}</>}
|
||||
{laeuftZelle}
|
||||
{!laedt && (neu || fehler) && <> {neu ? "→" : "·"} {neuZelle}</>}
|
||||
</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 md:block">{neuText}</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">{neuZelle}</span>
|
||||
<span role="cell" className="col-span-2 text-sm leading-relaxed text-text-2 md:col-span-1">
|
||||
{fest && (
|
||||
<span className="mb-1.5 block text-bernstein">
|
||||
Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}.
|
||||
</span>
|
||||
)}
|
||||
<span className="line-clamp-3 whitespace-pre-line">{was}</span>
|
||||
{was}
|
||||
</span>
|
||||
{(fest || hatUpdate) && (
|
||||
<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>
|
||||
)}
|
||||
{hatUpdate && (
|
||||
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
|
||||
<Button variant="outline" size="sm" onClick={onStart} disabled={gesperrt}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
)}
|
||||
@@ -89,17 +216,22 @@ const PUNKT: Record<UpdateLauf["stufe"], string> = {
|
||||
function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
|
||||
return (
|
||||
<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]">
|
||||
<span className="ziffern text-sm text-text-3">{flugplanZeit(lauf.start)}</span>
|
||||
<span className="min-w-0 text-[15px]">{lauf.anlass}</span>
|
||||
<Chip art={LAUF_ART[lauf.stufe]}>{lauf.titel}</Chip>
|
||||
{/* Handy: Zeit und Ergebnis oben, der Anlass darunter über die volle Breite. */}
|
||||
<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="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>
|
||||
</span>
|
||||
</div>
|
||||
{lauf.stufe !== "grau" && lauf.zeilen.length > 0 && (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0 md:pl-[124px]">
|
||||
{lauf.zeilen.map((z) => (
|
||||
<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 className="min-w-0">
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="text-foreground">{z.baustein}</span> · {z.text}
|
||||
</span>
|
||||
</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) {
|
||||
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 | { 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 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 = <Zustandsfeld text="Updates werden gelesen …" />
|
||||
} else if (start.isError && !start.data) {
|
||||
tabelle = <Zustandsfeld fehler text={`Die Box antwortet nicht: ${start.error.message}`} />
|
||||
} else {
|
||||
tabelle = (
|
||||
<div role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col">
|
||||
<div
|
||||
role="row"
|
||||
className="schild hidden grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
|
||||
>
|
||||
<span role="columnheader">Baustein</span>
|
||||
<span role="columnheader">Läuft</span>
|
||||
<span role="columnheader">Neu</span>
|
||||
<span role="columnheader">Was sich ändert</span>
|
||||
<span role="columnheader" className="sr-only">Aktion</span>
|
||||
</div>
|
||||
{BAUSTEINE.map((b) => (
|
||||
<BausteinZeile
|
||||
key={b.id}
|
||||
id={b.id}
|
||||
name={b.name}
|
||||
neuLautStart={neuLautStart.get(b.id)}
|
||||
fest={festgehalten.get(b.id)}
|
||||
gesperrt={gesperrt}
|
||||
onStart={() => 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 })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let verlaufInhalt
|
||||
if (verlauf.isPending) {
|
||||
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">
|
||||
{verlauf.data.laeufe.map((l) => <LaufEintrag key={l.start} lauf={l} />)}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{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">
|
||||
<span className="ziffern text-sm">{sicherungsZeit(s)}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="ziffern text-xs text-text-3">{s.size_mb.toLocaleString("de-DE")} MB</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!zurueckMoeglich || zurueck.isPending}
|
||||
onClick={() => setFrage({ art: "zurueck", datei: s.file, zeit: sicherungsZeit(s) })}
|
||||
>
|
||||
Zurückspielen
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,10 +363,14 @@ export function UpdatesSeite() {
|
||||
titel="Updates"
|
||||
rechts={
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button variant="outline" onClick={() => updateStarten.mutate("pruefen")} disabled={updateStarten.isPending}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updateStarten.mutate("pruefen")}
|
||||
disabled={updateStarten.isPending || suchtGerade}
|
||||
>
|
||||
Nach Neuem suchen
|
||||
</Button>
|
||||
<Button onClick={() => setFrage({ art: "alle" })} disabled={laeuft || mitUpdate.size === 0}>
|
||||
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || neuLautStart.size === 0}>
|
||||
Alles jetzt aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
@@ -166,103 +379,79 @@ export function UpdatesSeite() {
|
||||
<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="row"
|
||||
className="schild hidden grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
|
||||
>
|
||||
<span role="columnheader">Baustein</span>
|
||||
<span role="columnheader">Läuft</span>
|
||||
<span role="columnheader">Neu</span>
|
||||
<span role="columnheader">Was sich ändert</span>
|
||||
<span role="columnheader" className="sr-only">Aktion</span>
|
||||
</div>
|
||||
{BAUSTEINE.map((b) => (
|
||||
<BausteinZeile
|
||||
key={b.id}
|
||||
id={b.id}
|
||||
name={b.name}
|
||||
gelesen={start.data?.updates.gelesen ?? false}
|
||||
hatUpdate={mitUpdate.has(b.id)}
|
||||
fest={festgehalten.get(b.id)}
|
||||
laeuftGerade={laeuft}
|
||||
onStart={() => updateStarten.mutate(b.id)}
|
||||
onFreigeben={() => freigeben.mutate(b.id)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
{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">
|
||||
{verlauf.data?.laeufe.length ? (
|
||||
<ol className="m-0 flex list-none flex-col p-0">
|
||||
{verlauf.data.laeufe.map((l) => <LaufEintrag key={l.start} lauf={l} />)}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-[15px] text-text-2">
|
||||
{verlauf.isPending ? "Wird gelesen …" : "Noch keine Update-Läufe aufgezeichnet."}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel titel="Verlauf">{verlaufInhalt}</Panel>
|
||||
|
||||
<Panel
|
||||
titel="Sicherungen"
|
||||
rechts={
|
||||
<Button variant="outline" size="sm" onClick={() => sichern.mutate(undefined)} disabled={sichern.isPending}>
|
||||
Jetzt sichern
|
||||
{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>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{(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">
|
||||
<span className="ziffern text-sm">{sicherungsZeit(s)}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="ziffern text-xs text-text-3">{s.size_mb.toLocaleString("de-DE")} MB</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!sicherungen.data?.available}
|
||||
onClick={() => setFrage({ art: "zurueck", datei: s.file, zeit: sicherungsZeit(s) })}
|
||||
>
|
||||
Zurückspielen
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{sicherungen.data && !zurueckMoeglich && (
|
||||
<p className="text-sm text-text-3">Zurückspielen geht nur auf der Box selbst.</p>
|
||||
)}
|
||||
{sicherungsInhalt}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Bestaetigen
|
||||
offen={frage?.art === "alle"}
|
||||
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"
|
||||
gesperrt={gesperrt}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
updateStarten.mutate("alle")
|
||||
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
|
||||
offen={frage?.art === "zurueck"}
|
||||
gefahr
|
||||
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.`}
|
||||
knopf="Zurückspielen"
|
||||
gesperrt={zurueck.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage?.art === "zurueck") zurueck.mutate(frage.datei)
|
||||
|
||||
Reference in New Issue
Block a user