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
+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