Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter

This commit is contained in:
Hitonabi
2026-09-25 13:47:33 +02:00
24 changed files with 1835 additions and 60 deletions
@@ -0,0 +1,135 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { HomelabSnapshots, SnapshotGeraet, ZielAktion } from "@/lib/typen"
import { SnapshotListe } from "./SnapshotListe"
// Der Stand vom 25.09.2026 13:40: Gitea mit dem Rückweg des Updates von 11:26 und einem von Hand angelegten Snapshot,
// der PBS mit seiner Sicherung (kein Snapshot möglich), Arcane ohne Freigabe, NetBird mitten im Anlegen.
const JETZT = 1_790_334_000
const aktion = (pfad: string, label: string, frage: string): ZielAktion => ({ methode: "POST", pfad, label, frage })
const ZURUECK = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/zuruecksetzen", "Zurücksetzen",
"Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 (mc2-20260925-112639) zurücksetzen? Alles, was sich " +
"seitdem im Container geändert hat — Daten, Einstellungen, Updates —, ist danach verloren.")
const LOESCHEN = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/loeschen", "Snapshot löschen",
"Den Snapshot mc2-20260925-112639 von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen?")
const ANLEGEN = aktion("/api/homelab/snapshots/ct-104/anlegen", "Snapshot anlegen",
"Jetzt einen Snapshot von Gitea (Container 104) anlegen? Der Container läuft dabei weiter.")
const SICHERUNG_WEG = aktion("/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen",
"Sicherung löschen", "Die Sicherung vzdump-lxc-105-2026_09_25-11_29_57.tar.zst von Proxmox Backup Server … löschen?")
const basis = { vmid: 0, art: "container" as const, status: "running", erlaubt: true, anlegen: null, grund: null,
laeuft: null, zuletzt: null, snapshots: [], sicherungen: [] }
const gitea: SnapshotGeraet = {
...basis, id: "ct-104", vmid: 104, name: "Gitea", wo: "Container 104", anlegen: ANLEGEN,
zuletzt: { ok: true, ende: JETZT - 600, text: "Gitea: Snapshot mc2-20260925-112639 gelöscht." },
snapshots: [
{ name: "vor-dem-umbau", zeit: 1_789_000_000, beschreibung: null, herkunft: "proxmox", eigen: false,
loeschen: null, zuruecksetzen: null },
{ name: "mc2-20260925-112639", zeit: 1_790_328_400, beschreibung: "Vor einem Update durch den Homelab Orchestrator",
herkunft: "update", eigen: true, loeschen: LOESCHEN, zuruecksetzen: ZURUECK },
],
}
const pbs: SnapshotGeraet = {
...basis, id: "ct-105", vmid: 105, name: "Proxmox Backup Server", wo: "Container 105",
grund: "Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich.",
sicherungen: [{ datei: "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", speicher: "local", zeit: 1_790_328_597,
groesse: 360_932_725, loeschen: SICHERUNG_WEG }],
}
const arcane: SnapshotGeraet = {
...basis, id: "vm-106", vmid: 106, art: "vm", name: "Arcane (Docker)", wo: "VM 106", erlaubt: false,
grund: "Nicht freigegeben: Das Etikett „watcher“ fehlt.",
snapshots: [{ name: "mc2-20260920-100000", zeit: 1_789_891_200, beschreibung: null, herkunft: "update", eigen: true,
loeschen: null, zuruecksetzen: null }],
}
const netbird: SnapshotGeraet = {
...basis, id: "ct-102", vmid: 102, name: "NetBird", wo: "Container 102", laeuft: "Snapshot wird angelegt",
anlegen: aktion("/api/homelab/snapshots/ct-102/anlegen", "Snapshot anlegen", "…"),
snapshots: [{ ...gitea.snapshots[1], name: "mc2-20260925-112118",
loeschen: aktion("/x/loeschen", "Snapshot löschen", "…"), zuruecksetzen: aktion("/x/z", "Zurücksetzen", "…") }],
}
const adguard: SnapshotGeraet = { ...basis, id: "ct-100", vmid: 100, name: "AdGuard Home", wo: "Container 100",
anlegen: aktion("/api/homelab/snapshots/ct-100/anlegen", "Snapshot anlegen", "…") }
const daten: HomelabSnapshots = {
jetzt: JETZT, bericht: { empfangen: JETZT - 60, veraltet: false },
ausfuehrer: { verbunden: true, zuletzt: JETZT - 60, nur_lesen: false }, sperre: null, details: true,
geraete: [adguard, netbird, gitea, pbs, arcane],
summe: { snapshots: 4, eigene: 3, sicherungen: 1, sicherungen_groesse: 360_932_725 },
}
const bereich = (name: string) => screen.getByRole("region", { name })
const zeile = (name: string) => screen.getByRole("rowheader", { name }).closest("[role=row]") as HTMLElement
describe("SnapshotListe", () => {
it("zeigt je Gerät Name, Herkunft, Zeitpunkt, Alter und Beschreibung", () => {
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
const eigen = zeile("mc2-20260925-112639")
expect(within(eigen).getByText("vor Update")).toBeInTheDocument()
expect(within(eigen).getByText("HEUTE 11:26")).toBeInTheDocument()
expect(within(eigen).getByText("vor 2 h")).toBeInTheDocument()
expect(within(eigen).getByText("Vor einem Update durch den Homelab Orchestrator")).toBeInTheDocument()
// Von Hand angelegt: nur zur Ansicht, mit dem Grund statt Knöpfen.
const hand = zeile("vor-dem-umbau")
expect(within(hand).getByText("von Hand")).toBeInTheDocument()
expect(within(hand).getByText("10.09. 02:26")).toBeInTheDocument()
expect(within(hand).getByText("Nur in Proxmox")).toBeInTheDocument()
expect(within(hand).queryByRole("button")).not.toBeInTheDocument()
// Gerät ohne Snapshots
expect(within(bereich("AdGuard Home")).getByText("Keine Snapshots.")).toBeInTheDocument()
// Das Ergebnis der letzten Aktion steht beim Gerät, ohne seinen Namen noch einmal.
expect(within(bereich("Gitea")).getByText("Snapshot mc2-20260925-112639 gelöscht.")).toBeInTheDocument()
})
it("fragt vor dem Zurücksetzen mit dem Text des Homelab-Teils", () => {
const start = vi.fn()
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={start} />)
fireEvent.click(within(zeile("mc2-20260925-112639")).getByRole("button", { name: "Zurücksetzen" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText("Gitea: Zurücksetzen")).toBeInTheDocument()
expect(within(dialog).getByText(/ist danach verloren/)).toBeInTheDocument()
expect(start).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Zurücksetzen" }))
expect(start).toHaveBeenCalledWith(ZURUECK)
})
it("legt nach Rückfrage einen Snapshot an und löscht eine Sicherung", () => {
const start = vi.fn()
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={start} />)
fireEvent.click(within(bereich("Gitea")).getByRole("button", { name: "Snapshot anlegen" }))
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Snapshot anlegen" }))
expect(start).toHaveBeenLastCalledWith(ANLEGEN)
const sicherung = zeile("vzdump-lxc-105-2026_09_25-11_29_57.tar.zst")
expect(within(sicherung).getByText("Sicherung")).toBeInTheDocument()
expect(within(sicherung).getByText("361 MB auf „local“, vor einem Update angelegt")).toBeInTheDocument()
fireEvent.click(within(sicherung).getByRole("button", { name: "Löschen" }))
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Sicherung löschen" }))
expect(start).toHaveBeenLastCalledWith(SICHERUNG_WEG)
})
it("sagt, warum es keinen Knopf gibt", () => {
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
const p = bereich("Proxmox Backup Server")
expect(within(p).getByText(/Kein Snapshot möglich: Bind-Mount mp0/)).toBeInTheDocument()
expect(within(p).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument()
const a = bereich("Arcane (Docker)")
expect(within(a).getByText(/Nicht freigegeben: Das Etikett/)).toBeInTheDocument()
expect(within(a).getByText("Nicht freigegeben")).toBeInTheDocument() // eigener Snapshot, aber kein Knopf
expect(within(a).queryByRole("button")).not.toBeInTheDocument()
})
it("zeigt „Läuft: …“ statt des Knopfs und sperrt die übrigen Knöpfe des Geräts", () => {
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
const n = bereich("NetBird")
expect(within(n).getByRole("status")).toHaveTextContent("Läuft: Snapshot wird angelegt …")
expect(within(n).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument()
expect(within(n).getByRole("button", { name: "Löschen" })).toBeDisabled()
expect(within(bereich("Gitea")).getByRole("button", { name: "Löschen" })).toBeEnabled()
})
it("sperrt alles, solange es die Seite sagt", () => {
render(<SnapshotListe daten={daten} gesperrt onAktion={() => {}} />)
for (const knopf of screen.getAllByRole("button")) expect(knopf).toBeDisabled()
})
})
@@ -0,0 +1,209 @@
import { type ReactNode, useState } from "react"
import { CircleCheck, CircleX, Plus, Trash2, Undo2 } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots"
import type {
HomelabSicherungsArchiv, HomelabSnapshot, HomelabSnapshots, SnapshotGeraet, ZielAktion,
} from "@/lib/typen"
type Frage = { name: string; aktion: ZielAktion; gefahr: boolean } | null
// Eine Zeile je Snapshot bzw. Sicherung: Name, Herkunft, Zeitpunkt, Beschreibung, Knöpfe — in drei Anordnungen:
// Handy Name und Herkunft oben, darunter Zeitpunkt, Beschreibung und die Knöpfe nebeneinander
// ab md links Name, Zeitpunkt, Beschreibung untereinander, rechts die Knöpfe übereinander
// ab xl alles in einer Zeile; die Knöpfe haben eine feste Breite, damit die Spalten fluchten
// (Fünf Spalten schon ab md drückten den Namen bei 1024 px auf 77 px.)
const ZEILE =
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1.5 border-t border-linie py-3 " +
"[grid-template-areas:'name_herkunft'_'zeit_zeit'_'text_text'_'knoepfe_knoepfe'] " +
"md:grid-rows-[auto_auto_1fr] md:gap-x-4 md:[grid-template-areas:'name_herkunft'_'zeit_knoepfe'_'text_knoepfe'] " +
"xl:grid-cols-[minmax(10.5rem,1.1fr)_7.5rem_minmax(0,1fr)_minmax(0,1.4fr)_11rem] xl:grid-rows-none xl:items-center " +
"xl:[grid-template-areas:'name_herkunft_zeit_text_knoepfe']"
// Namen brechen an ihren Bindestrichen (mc2-20260925- | 113700), nicht mitten in der Zahl.
const NAME = "ziffern min-w-0 pt-1 text-sm break-words text-foreground [grid-area:name] xl:pt-0"
const HERKUNFT_ZELLE = "justify-self-end [grid-area:herkunft] xl:justify-self-start"
function Zeitpunkt({ zeit, jetzt }: { zeit: number | null; jetzt: number }) {
return (
<span role="cell" className="flex flex-wrap items-baseline gap-x-2 text-sm [grid-area:zeit]">
<span className="ziffern text-text-2">{zeitpunktText(zeit, jetzt)}</span>
{zeit !== null && <span className="text-text-3">{alterText(zeit, jetzt)}</span>}
</span>
)
}
function Beschreibung({ children }: { children: ReactNode }) {
return <span role="cell" className="min-w-0 text-sm break-words text-text-2 [grid-area:text]">{children}</span>
}
function Knoepfe({ children, ohne }: { children: ReactNode; ohne: string | null }) {
return (
<span role="cell"
className="flex flex-wrap gap-2 [grid-area:knoepfe] md:flex-col md:items-stretch md:justify-self-end xl:justify-self-stretch">
{ohne ? <span className="text-xs text-text-3">{ohne}</span> : children}
</span>
)
}
function SnapshotZeile({ g, s, jetzt, gesperrt, onFrage }: {
g: SnapshotGeraet
s: HomelabSnapshot
jetzt: number
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const { loeschen, zuruecksetzen } = s
const ohne = loeschen || zuruecksetzen ? null : s.eigen ? "Nicht freigegeben" : "Nur in Proxmox"
return (
<div role="row" className={ZEILE}>
<span role="rowheader" className={NAME}>{s.name}</span>
<span role="cell" className={HERKUNFT_ZELLE} title={HERKUNFT[s.herkunft].text}>
<Chip>{HERKUNFT[s.herkunft].chip}</Chip>
</span>
<Zeitpunkt zeit={s.zeit} jetzt={jetzt} />
<Beschreibung>{s.beschreibung ?? <span className="text-text-3">{HERKUNFT[s.herkunft].text}</span>}</Beschreibung>
<Knoepfe ohne={ohne}>
{zuruecksetzen && (
<Button variant="outline" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: zuruecksetzen, gefahr: true })}>
<Undo2 aria-hidden /> Zurücksetzen
</Button>
)}
{loeschen && (
<Button variant="outline" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: loeschen, gefahr: true })}>
<Trash2 aria-hidden /> Löschen
</Button>
)}
</Knoepfe>
</div>
)
}
function SicherungZeile({ g, e, jetzt, gesperrt, onFrage }: {
g: SnapshotGeraet
e: HomelabSicherungsArchiv
jetzt: number
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const groesse = groesseText(e.groesse)
const loeschen = e.loeschen
return (
<div role="row" className={ZEILE}>
<span role="rowheader" className={NAME}>{e.datei}</span>
<span role="cell" className={HERKUNFT_ZELLE}><Chip>Sicherung</Chip></span>
<Zeitpunkt zeit={e.zeit} jetzt={jetzt} />
<Beschreibung>
{groesse ? `${groesse} auf „${e.speicher}“` : `Auf „${e.speicher}“`}, vor einem Update angelegt
</Beschreibung>
<Knoepfe ohne={loeschen ? null : "Nicht freigegeben"}>
{loeschen && (
<Button variant="outline" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: loeschen, gefahr: true })}>
<Trash2 aria-hidden /> Löschen
</Button>
)}
</Knoepfe>
</div>
)
}
function Geraet({ g, jetzt, gesperrt, onFrage }: {
g: SnapshotGeraet
jetzt: number
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const leer = g.snapshots.length === 0 && g.sicherungen.length === 0
const anlegen = g.anlegen
return (
<section aria-label={g.name} className="flex min-w-0 flex-col gap-2 border-t border-linie-stark pt-4 first:border-t-0 first:pt-0">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="flex min-w-0 flex-col">
<h3 className="text-[15px] font-semibold break-words">{g.name}</h3>
<span className="text-sm text-text-3">{g.wo}</span>
</div>
{g.laeuft ? (
<p role="status" className="text-sm text-cyan">Läuft: {g.laeuft} …</p>
) : (
anlegen && (
<Button variant="outline" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: anlegen, gefahr: false })}>
<Plus aria-hidden /> Snapshot anlegen
</Button>
)
)}
</div>
{g.grund && <p className="text-sm text-text-3">{g.grund}</p>}
{g.zuletzt && (
<p className={cn("flex items-start gap-1.5 text-sm", g.zuletzt.ok ? "text-gruen-text" : "text-rot-text")}>
{g.zuletzt.ok
? <CircleCheck className="mt-0.5 size-4 shrink-0" aria-hidden />
: <CircleX className="mt-0.5 size-4 shrink-0" aria-hidden />}
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="break-words">{zuletztText(g.zuletzt.text, g.name)}</span>
<span className="ziffern text-xs text-text-3">{zeitpunktText(g.zuletzt.ende, jetzt)}</span>
</span>
</p>
)}
{leer ? (
<p className="text-sm text-text-3">Keine Snapshots.</p>
) : (
<div role="table" aria-label={`Snapshots von ${g.name}`} className="flex flex-col">
<div role="row" className="sr-only">
<span role="columnheader">Snapshot</span>
<span role="columnheader">Herkunft</span>
<span role="columnheader">Zeitpunkt</span>
<span role="columnheader">Beschreibung</span>
<span role="columnheader">Aktion</span>
</div>
{g.snapshots.map((s) => (
<SnapshotZeile key={s.name} g={g} s={s} jetzt={jetzt} gesperrt={gesperrt} onFrage={onFrage} />
))}
{g.sicherungen.map((e) => (
<SicherungZeile key={e.datei} g={g} e={e} jetzt={jetzt} gesperrt={gesperrt} onFrage={onFrage} />
))}
</div>
)}
</section>
)
}
/**
* Snapshots und Sicherungen je Gerät. Knöpfe gibt es nur, wo der Homelab-Teil eine Aktion mitschickt (Snapshots des
* Orchestrators, freigegebene Geräte); jede fragt vorher mit dem Text des Homelab-Teils. Läuft für ein Gerät etwas,
* steht „Läuft: …“ statt des Knopfs, und seine übrigen Knöpfe ruhen.
*/
export function SnapshotListe({ daten, gesperrt, onAktion }: {
daten: HomelabSnapshots
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
return (
<>
<div className="flex flex-col gap-5">
{daten.geraete.map((g) => (
<Geraet key={g.id} g={g} jetzt={daten.jetzt} gesperrt={gesperrt || !!g.laeuft} onFrage={setFrage} />
))}
</div>
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.name}: ${frage.aktion.label}` : ""}
text={frage?.aktion.frage ?? ""}
knopf={frage?.aktion.label ?? ""}
gefahr={frage?.gefahr}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage.aktion)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</>
)
}
@@ -47,6 +47,13 @@ describe("Speicher", () => {
expect(screen.getByText("fast voll")).toBeInTheDocument()
})
it("verweist beim Vorschlag zu den Snapshots auf deren Seite (seit 25.09.2026)", () => {
render(<Speicher daten={daten} snapshotsLink={<a href="/homelab/snapshots">Zu den Snapshots</a>} />)
const links = screen.getAllByRole("link", { name: "Zu den Snapshots" })
expect(links).toHaveLength(1)
expect(links[0].closest("li")).toHaveTextContent("Gitea: Snapshot mc2-20260924-192352")
})
it("nennt Störungen des NAS und fehlende Sicherungen", () => {
render(<Speicher daten={{
...daten,
+8 -3
View File
@@ -137,12 +137,14 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
* Speicher und Sicherungen des Homelabs: Speicherpool, NAS und die nächtlichen Sicherungen, darunter was im Pool
* Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %).
*/
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, snapshotsLink }: {
daten: HomelabSpeicher
/** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */
laeuft?: string | null
gesperrt?: boolean
onAktion?: (a: ZielAktion) => void
/** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */
snapshotsLink?: ReactNode
}) {
const [frage, setFrage] = useState<ZielAktion | null>(null)
const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok"
@@ -175,7 +177,8 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
</summary>
<ol className="flex flex-col">
{daten.empfehlungen.map((e) => (
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage} />
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage}
verweis={e.art === "snapshot" ? snapshotsLink : null} />
))}
</ol>
</details>
@@ -211,11 +214,12 @@ function MitBefehlen({ text }: { text: string }) {
)
}
function Vorschlag({ e, laeuft, gesperrt, onFrage }: {
function Vorschlag({ e, laeuft, gesperrt, onFrage, verweis }: {
e: SpeicherEmpfehlung
laeuft: string | null
gesperrt: boolean
onFrage: (a: ZielAktion) => void
verweis?: ReactNode
}) {
return (
<li className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1">
@@ -225,6 +229,7 @@ function Vorschlag({ e, laeuft, gesperrt, onFrage }: {
</div>
{e.ort && <span className="schild text-xs text-text-3">{ORT[e.ort] ?? e.ort}</span>}
<p className="text-sm break-words text-text-2"><MitBefehlen text={e.text} /></p>
{verweis}
{e.aktion && (laeuft ? (
<p role="status" className="text-sm text-cyan">Läuft: {laeuft}</p>
) : (