boxwart: Update-Verlauf zeigt das echte Ergebnis je Baustein, festgehaltene Bausteine sichtbar
Ampel / ampel (push) Failing after 22s

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>
This commit is contained in:
Hitonabi
2026-09-23 21:56:19 +02:00
co-authored by Claude Opus 5.5
parent f3af2adec3
commit 790de19f1a
21 changed files with 544 additions and 80 deletions
+14 -5
View File
@@ -46,6 +46,8 @@ export const useSicherungen = () =>
export interface UpdateDetails {
kind: string
installed_version?: string | null
behind?: number
installed_build?: number | null
latest_build?: number | null
latest_tag?: string | null
@@ -66,11 +68,12 @@ export const useUpdateDetails = (art: string | null) =>
})
export interface UpdateLauf {
start: string | null
ende: string | null
status: string
fehler: string | null
job: string
start: string
ende: string
anlass: string
stufe: "rot" | "gelb" | "gruen" | "info" | "grau"
titel: string
zeilen: { baustein: string; ergebnis: string; stufe: UpdateLauf["stufe"]; text: string }[]
}
export const useUpdateVerlauf = () =>
@@ -138,6 +141,12 @@ export const useUpdateStarten = () =>
neuLaden: ["jobs", "start"],
})
export const useFreigeben = () =>
useAktion(
(baustein: string) => post<AktionsErgebnis>(`/api/updates/festgehalten/${encodeURIComponent(baustein)}/freigeben`),
{ erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Freigegeben.", neuLaden: ["start"] },
)
export const useJetztSichern = () =>
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
erfolg: () => "Sicherung erstellt.",
+7
View File
@@ -61,6 +61,13 @@ export function hauptleuchte(z: Start["zustand"]) {
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 }
// Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand.
if (id === "hermes") {
return {
laeuft: d.installed_version ? `v${d.installed_version}` : "–",
neu: d.behind ? `+${d.behind.toLocaleString("de-DE")} Änderungen` : null,
}
}
const pre = id === "engine" ? "b" : "v"
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
+10
View File
@@ -66,10 +66,20 @@ export interface Start {
updates: {
bausteine: { id: string; name: string; neu: string }[]
modelle: { role: string; title: string; repo: string }[]
festgehalten: Festgehalten[]
}
flugplan: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }
}
/** Ein Baustein, den der Sonntags-Lauf nach einem gescheiterten Update überspringt. */
export interface Festgehalten {
baustein: string
name: string
version: string
seit: string
grund: string
}
export interface AktionsErgebnis {
ok: boolean
text?: string
+6
View File
@@ -50,6 +50,12 @@ describe("versionen", () => {
it("zählt beim Betriebssystem die Pakete", () => {
expect(versionen("os", { kind: "os", count: 24 })).toEqual({ laeuft: "Ubuntu", neu: "24 Pakete" })
})
it("zeigt bei Hermes die Version und die Zahl der Änderungen", () => {
expect(versionen("hermes", { kind: "hermes", installed_version: "0.21.3", behind: 2523 }))
.toEqual({ laeuft: "v0.21.3", neu: "+2.523 Änderungen" })
expect(versionen("hermes", { kind: "hermes", behind: 0 })).toEqual({ laeuft: "–", neu: null })
})
})
describe("rolleVon", () => {
+62 -24
View File
@@ -1,14 +1,15 @@
import { useState } from "react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import {
useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails, useUpdateStarten, useUpdateVerlauf,
useZurueckspielen,
type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails,
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
} from "@/lib/abfragen"
import { versionen } from "@/lib/anzeige"
import type { Job, Sicherung } from "@/lib/typen"
import type { Festgehalten, Job, Sicherung } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const BAUSTEINE = [
@@ -18,11 +19,13 @@ const BAUSTEINE = [
{ id: "hermes", name: "Hermes" },
] as const
function BausteinZeile({ id, name, hatUpdate, onStart, laeuftGerade }: {
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)
@@ -37,19 +40,63 @@ function BausteinZeile({ id, name, hatUpdate, onStart, laeuftGerade }: {
<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">
{hatUpdate && (
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
Aktualisieren
</Button>
)}
<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"
@@ -78,11 +125,13 @@ export function UpdatesSeite() {
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")
@@ -125,8 +174,10 @@ export function UpdatesSeite() {
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>
@@ -145,20 +196,7 @@ export function UpdatesSeite() {
<Panel titel="Verlauf">
{verlauf.data?.laeufe.length ? (
<ol className="m-0 flex list-none flex-col p-0">
{verlauf.data.laeufe.map((l) => {
const ok = l.status === "completed"
const art = ok ? "gruen" : l.status === "failed" ? "rot" : "cyan"
return (
<li key={`${l.start}-${l.job}`} className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-start gap-3 border-t border-linie py-3">
<span className="ziffern text-sm text-text-3">{l.start ? flugplanZeit(l.start) : "–"}</span>
<span className="min-w-0 text-[15px]">
{l.job}
{l.fehler && <span className="block text-sm text-rot-text">{l.fehler}</span>}
</span>
<Chip art={art}>{ok ? "Durchgelaufen" : l.status === "failed" ? "Fehlgeschlagen" : "Läuft"}</Chip>
</li>
)
})}
{verlauf.data.laeufe.map((l) => <LaufEintrag key={l.start} lauf={l} />)}
</ol>
) : (
<p className="text-[15px] text-text-2">