Files
mission-control-v2/frontend/src/views/Updates.tsx
T
HitonabiandClaude Opus 5.5 790de19f1a
Ampel / ampel (push) Failing after 22s
boxwart: Update-Verlauf zeigt das echte Ergebnis je Baustein, festgehaltene Bausteine sichtbar
Der Verlauf las bisher nur den Hermes-Cron-Status ("Skript lief") und zeigte die
Laeufe vom 06. und 13.09. als "durchgelaufen", obwohl dort zurueckgerollt und
festgehalten wurde; ein abgebrochener Lauf stand als "laeuft" da. Jetzt kommt der
Verlauf aus dem Meldeprotokoll (~/mc2-notify.log), also aus dem, was autoupdate.sh
selbst je Baustein meldet.

Festgehaltene Bausteine (Pin-Register) werden zum Waechter-Hinweis mit Knopf
"Freigeben", auf der Updates-Seite markiert und in der Updates-Lampe gezaehlt.
Hermes zeigt seine laufende Version und die Zahl der neuen Aenderungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:56:19 +02:00

264 lines
11 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 { cn } from "cn"
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 { versionen } from "@/lib/anzeige"
import type { Festgehalten, Job, Sicherung } 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
function BausteinZeile({ id, name, hatUpdate, fest, onStart, onFreigeben, laeuftGerade }: {
id: string
name: string
hatUpdate: boolean
fest: Festgehalten | undefined
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 = !hatUpdate
? "Aktuell."
: data?.summary?.trim() || (id === "os" && data?.count ? "Paketliste unten in den Details." : isPending ? "Wird gelesen …" : "")
return (
<tr className="border-t border-linie align-top">
<th scope="row" className="py-3.5 pr-4 text-left font-sans text-[15px] font-semibold">{name}</th>
<td className="ziffern py-3.5 pr-4 text-sm text-text-2">{v.laeuft}</td>
<td className="ziffern py-3.5 pr-4 text-sm">{neu ? <span className="text-cyan">{neu}</span> : <span className="text-text-3"></span>}</td>
<td className="py-3.5 pr-4 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>
)}
<span className="line-clamp-3 whitespace-pre-line">{was}</span>
</td>
<td className="py-2.5 text-right">
<span className="inline-flex flex-col items-end gap-2">
{fest && (
<Button variant="outline" size="sm" onClick={onFreigeben}>
Freigeben
</Button>
)}
{hatUpdate && (
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
Aktualisieren
</Button>
)}
</span>
</td>
</tr>
)
}
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">
<div className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3">
<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>
</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">
<span className="text-foreground">{z.baustein}</span> · {z.text}
</span>
</li>
))}
</ul>
)}
</li>
)
}
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
}
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<null | { art: "alle" } | { art: "zurueck"; datei: string; zeit: string }>(null)
const mitUpdate = new Set(start.data?.updates.bausteine.map((b) => b.id) ?? [])
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")
return (
<>
<Panel
titel="Updates"
rechts={
<div className="flex flex-wrap gap-2.5">
<Button variant="outline" onClick={() => updateStarten.mutate("pruefen")} disabled={updateStarten.isPending}>
Nach Neuem suchen
</Button>
<Button onClick={() => setFrage({ art: "alle" })} disabled={laeuft || mitUpdate.size === 0}>
Alles jetzt aktualisieren
</Button>
</div>
}
>
<p className="-mt-2 text-[15px] text-text-2">
Jeden Sonntag um 04:30 automatisch. Vorher wird gesichert, danach geprüft.
</p>
{start.isPending ? (
<Zustandsfeld text="Updates werden gelesen …" />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] border-collapse">
<thead>
<tr className="schild text-left text-xs text-text-3">
<th scope="col" className="pb-2 font-semibold">Baustein</th>
<th scope="col" className="pb-2 font-semibold">Läuft</th>
<th scope="col" className="pb-2 font-semibold">Neu</th>
<th scope="col" className="pb-2 font-semibold">Was sich ändert</th>
<th scope="col" className="pb-2 text-right font-semibold"><span className="sr-only">Aktion</span></th>
</tr>
</thead>
<tbody>
{BAUSTEINE.map((b) => (
<BausteinZeile
key={b.id}
id={b.id}
name={b.name}
hatUpdate={mitUpdate.has(b.id)}
fest={festgehalten.get(b.id)}
laeuftGerade={laeuft}
onStart={() => updateStarten.mutate(b.id)}
onFreigeben={() => freigeben.mutate(b.id)}
/>
))}
</tbody>
</table>
</div>
)}
{wartungsJobs.length > 0 && (
<div className="flex flex-col gap-2.5">
<h3 className="schild text-sm text-text-3">Laufende und letzte Aufträge</h3>
{wartungsJobs.slice(-3).reverse().map((j) => <JobKasten key={j.id} job={j} />)}
</div>
)}
</Panel>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<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="Sicherungen"
rechts={
<Button variant="outline" size="sm" onClick={() => sichern.mutate(undefined)} disabled={sichern.isPending}>
Jetzt sichern
</Button>
}
>
<p className="-mt-2 text-[15px] text-text-2">Täglich um 03:35, mit Kopie auf einem zweiten Gerät.</p>
<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>
</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."
knopf="Aktualisieren"
onNein={() => setFrage(null)}
onJa={() => {
updateStarten.mutate("alle")
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"
onNein={() => setFrage(null)}
onJa={() => {
if (frage?.art === "zurueck") zurueck.mutate(frage.datei)
setFrage(null)
}}
/>
</>
)
}