Files
mission-control-v2/frontend/src/views/Updates.tsx
T
HitonabiandClaude Opus 5.5 a091ccb3b8 layout: 320 px ohne Ueberstand, Schubladen in voller Breite, Doppelklick-Sperre greift sofort
Befunde aus der Sichtpruefung (Attrappe des Backends, 320/768/1024/1440 px):
- Zaehlwerk: Das "H" ragte bei 320 px 30 px ueber sein Feld. Ziffern skalieren jetzt
  mit der Feldbreite (Container-Einheiten), am PC bleibt es bei 38 x 56 px.
- Flugplan und Update-Verlauf: Der Text bekam schmal nur ~50 px und lief in den Zustand.
  Schmales Feld: Zeit und Zustand oben, Text darunter (nach Feldbreite, nicht Bildschirm).
- Update-Zeilen am Handy: Name und Version stiessen aneinander ("BetriebssystemUbuntu"),
  die Version steht jetzt darunter; scheitert die Pruefung, heisst sie "unbekannt" statt "-".
- Schubladen (Modell-Suche, Dienste, Einstellungen, Protokoll) waren am Handy nur 75 %
  und am PC hoechstens 384 px breit - die shadcn-Grundklassen schlugen die eigenen.
- Doppelklick: Die Sperre beim Installieren haengt jetzt an einem Ref und greift sofort
  (vorher starteten zwei Klicks im selben Takt zwei Downloads); die Ja-Knoepfe von
  "Alles aktualisieren" und "Auftrag abbrechen" ignorieren den zweiten Klick.
- Balken eines abgebrochenen Downloads grau statt cyan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:36:03 +02:00

468 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
import { Button } from "@/components/ui/button"
import {
type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails,
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
} from "@/lib/abfragen"
import { laeuftNoch } from "@/lib/anzeige"
import { detailsHabenNeues, pruefFehlerText, versionen } from "@/lib/wartung"
import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const BAUSTEINE = [
{ id: "os", name: "Betriebssystem" },
{ id: "engine", name: "Motor (llama.cpp)" },
{ id: "swap", name: "llama-swap" },
{ id: "hermes", name: "Hermes" },
] as const
/** 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
/** 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
}) {
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)
// Scheitert die Prüfung, kennt das Backend oft nicht einmal die laufende Version (Hermes: git fetch).
const laeuftText = v && v.laeuft !== "–" ? v.laeuft : fehler ? "unbekannt" : "–"
const laeuftZelle = laedt ? <Skelett /> : <>{laeuftText}</>
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 = (
<div 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}
</div>
)
}
// Am PC eine Tabellenzeile, am Handy ein Block: Name, darunter die Versionen, darunter was sich
// ändert, darunter die Knöpfe — ohne seitliches Scrollen und ohne dass Name und Version aneinanderstoßen.
return (
<div
role="row"
aria-busy={laedt || undefined}
className="grid grid-cols-1 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 -mt-1 text-sm text-text-2 md:hidden">
{laeuftZelle}
{!laedt && (neu || fehler) && <> {neu ? "→" : "·"} {neuZelle}</>}
</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>
<div role="cell" className="text-sm leading-relaxed text-text-2">
{fest && (
<span className="mb-1.5 block text-bernstein">
Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}.
</span>
)}
{was}
</div>
{(fest || hatUpdate) && (
<span role="cell" className="flex flex-wrap gap-2 md:flex-col md:items-end">
{fest && (
<Button variant="outline" size="sm" onClick={onFreigeben}>
Freigeben
</Button>
)}
{hatUpdate && (
<Button variant="outline" size="sm" onClick={onStart} disabled={gesperrt}>
Aktualisieren
</Button>
)}
</span>
)}
</div>
)
}
const LAUF_ART: Record<UpdateLauf["stufe"], ChipArt> = {
rot: "rot", gelb: "bernstein", gruen: "gruen", info: "cyan", grau: "grau",
}
const PUNKT: Record<UpdateLauf["stufe"], string> = {
rot: "bg-rot", gelb: "bg-bernstein", gruen: "bg-gruen", info: "bg-cyan", grau: "bg-text-3",
}
/** Ein Update-Lauf mit dem Ergebnis je Baustein — so, wie autoupdate.sh es gemeldet hat. */
function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
return (
<li className="flex flex-col gap-2 border-t border-linie py-3">
{/* Schmales Feld: 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 @sm: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 @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
{lauf.anlass}
</span>
<span className="col-start-2 row-start-1 justify-self-end @sm: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 @sm: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 break-words">
<span className="text-foreground">{z.baustein}</span> · {z.text}
</span>
</li>
))}
</ul>
)}
</li>
)
}
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()
const verlauf = useUpdateVerlauf()
const sicherungen = useSicherungen()
const updateStarten = useUpdateStarten()
const freigeben = useFreigeben()
const sichern = useJetztSichern()
const zurueck = useZurueckspielen()
const [frage, setFrage] = useState<Frage>(null)
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 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="@container 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 (
<>
<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>
</div>
<Bestaetigen
offen={frage?.art === "alle"}
titel="Alles jetzt aktualisieren?"
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={() => {
// Die Abfrage auf `frage` fängt den zweiten Klick eines Doppelklicks: Dann ist sie schon null.
if (frage?.art === "alle") 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)
setFrage(null)
}}
/>
</>
)
}