homelab: eigener Reiter „Snapshots“ mit Verwaltung (anlegen, löschen, zurücksetzen)
Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten. Entscheide: - Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen. - Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer, SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran. - Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt eingespielt“. - Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen). - speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot. - Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf); die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers); Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand). Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren, Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2784c0ea32
commit
450c782ed3
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user