Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter
This commit is contained in:
@@ -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