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
+5
View File
@@ -45,6 +45,10 @@ const homelabUpdates = createRoute({
getParentRoute: () => wurzel, path: "/homelab/updates",
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"),
})
const homelabSnapshots = createRoute({
getParentRoute: () => wurzel, path: "/homelab/snapshots",
component: lazyRouteComponent(() => import("@/views/HomelabSnapshots"), "HomelabSnapshotsSeite"),
})
const homelabMonitoring = createRoute({
getParentRoute: () => wurzel, path: "/homelab/monitoring",
component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"),
@@ -79,6 +83,7 @@ export const router = createRouter({
routeTree: wurzel.addChildren([
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
homelabProtokoll, einstellungen, altUpdates, altModelle,
homelabSnapshots,
]),
defaultPreload: "intent",
defaultErrorComponent: Absturz,
@@ -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>
) : (
+22
View File
@@ -14,6 +14,7 @@ import type {
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
import type { HomelabSnapshots } from "./typen"
import { gbText } from "./zeit"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
@@ -483,6 +484,27 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
retry: false,
})
/**
* Snapshots und Sicherungen je Gerät (Seite Homelab → Snapshots, seit 25.09.2026). Der Schlüssel beginnt mit
* „homelab“: Wer „homelab“ neu lädt (Live-Strom nach einem neuen Bericht, die Update-Knöpfe), lädt diese Liste mit.
* Läuft für ein Gerät etwas, alle 3 s nachsehen.
*/
export const useHomelabSnapshots = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab", "snapshots"],
queryFn: () => api<HomelabSnapshots>("/api/homelab/snapshots"),
enabled: aktiv,
refetchInterval: (q) => (q.state.data?.geraete.some((g) => g.laeuft) ? 3000 : 30_000),
retry: false,
})
/** Knöpfe der Seite Snapshots: Pfad und Rückfrage liefert der Homelab-Teil mit der Liste. */
export const useSnapshotAktion = () =>
useAktion((a: ZielAktion) => api<AktionsErgebnis>(a.pfad, { method: a.methode }), {
erfolg: (a) => `${a.label}: gestartet. Das Ergebnis steht gleich beim Gerät.`,
neuLaden: ["homelab", "homelab-laeufe"],
})
export const useHomelabEinstellungen = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-einstellungen"],
+4
View File
@@ -2,6 +2,7 @@ import { useRouterState } from "@tanstack/react-router"
import {
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
} from "lucide-react"
import { History } from "lucide-react"
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
import { offeneUpdates } from "@/lib/homelab"
import { useStromLage } from "@/lib/strom"
@@ -20,6 +21,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
export type SeitenPfad =
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
| "/homelab/snapshots"
export interface Seite {
to: SeitenPfad
@@ -40,6 +42,8 @@ export const SEITEN: Seite[] = [
{ to: "/box/dienste", label: "Dienste und Protokolle", Icon: ScrollText, ort: "box" },
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true },
// Nach „Updates“: Snapshots sind der Rückweg der Updates (seit 25.09.2026).
{ to: "/homelab/snapshots", label: "Snapshots", Icon: History, ort: "homelab" },
{ to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" },
{ to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" },
{ to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" },
+39
View File
@@ -0,0 +1,39 @@
import { alterText, groesseText, istSnapshotLauf, summeText, zeitpunktText, zuletztText } from "@/lib/snapshots"
// 25.09.2026 13:40 in Berlin — der Snapshot von Gitea ist von 11:26 desselben Tages.
const JETZT = 1_790_334_000
describe("Snapshot-Texte", () => {
it("nennt Zeitpunkt und Alter nach der Uhr des Homelab-Teils", () => {
expect(zeitpunktText(1_790_328_400, JETZT)).toBe("HEUTE 11:26")
expect(alterText(1_790_328_400, JETZT)).toBe("vor 2 h")
expect(zeitpunktText(1_789_000_000, JETZT)).toBe("10.09. 02:26")
expect(alterText(1_789_000_000, JETZT)).toBe("vor 15 Tagen")
expect(zeitpunktText(null, JETZT)).toBe("Zeitpunkt unbekannt")
expect(alterText(null, JETZT)).toBe("")
})
it("rechnet Sicherungen unter einem GB in MB", () => {
expect(groesseText(360_932_725)).toBe("361 MB")
expect(groesseText(1_234_000_000)).toBe("1,2 GB")
expect(groesseText(null)).toBeNull()
})
it("zählt oben rechts und lässt fehlende Sicherungen weg", () => {
expect(summeText({ snapshots: 3, eigene: 1, sicherungen: 1, sicherungen_groesse: 1 })).toBe("3 Snapshots · 1 Sicherung")
expect(summeText({ snapshots: 1, eigene: 1, sicherungen: 0, sicherungen_groesse: null })).toBe("1 Snapshot")
})
it("trennt Snapshot-Aktionen von Updates", () => {
expect(istSnapshotLauf({ baustein: "snapshot-zurueck" })).toBe(true)
expect(istSnapshotLauf({ baustein: "sicherung-loeschen" })).toBe(true)
expect(istSnapshotLauf({ baustein: "app" })).toBe(false)
expect(istSnapshotLauf({ baustein: "kernel" })).toBe(false)
})
it("nimmt den Gerätenamen aus dem Ergebnis, das ja beim Gerät steht", () => {
expect(zuletztText("Gitea: auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.", "Gitea"))
.toBe("Auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.")
expect(zuletztText("Snapshot anlegen unterbrochen: …", "Gitea")).toBe("Snapshot anlegen unterbrochen: …")
})
})
+48
View File
@@ -0,0 +1,48 @@
import type { HomelabLauf, HomelabSnapshots, SnapshotHerkunft } from "@/lib/typen"
import { flugplanZeit, vor } from "@/lib/zeit"
// Die Seite Homelab → Snapshots (seit 25.09.2026): kleine Texte, damit die Ansicht nur noch zeichnet. Was erlaubt ist
// und die Rückfragen kommen vom Homelab-Teil (backend/services/homelab/snapshots.py).
/** Die Läufe der Seite Snapshots — keine Updates: Sie gehören weder in den Update-Verlauf noch in „Zuletzt eingespielt“. */
export const SNAPSHOT_LAEUFE = new Set(["snapshot-anlegen", "snapshot-loeschen", "snapshot-zurueck", "sicherung-loeschen"])
export const istSnapshotLauf = (l: Pick<HomelabLauf, "baustein">) => SNAPSHOT_LAEUFE.has(l.baustein)
export const HERKUNFT: Record<SnapshotHerkunft, { chip: string; text: string }> = {
update: { chip: "vor Update", text: "Rückweg vor einem Update" },
knopf: { chip: "per Knopf", text: "Hier per Knopf angelegt" },
proxmox: { chip: "von Hand", text: "Von Hand in Proxmox angelegt" },
}
/** „HEUTE 11:26“, „SA 08:00“, „20.09. 11:26“ — nach der Uhr des Homelab-Teils (jetzt in Unix-Sekunden). */
export function zeitpunktText(zeit: number | null, jetzt: number): string {
if (zeit === null) return "Zeitpunkt unbekannt"
return flugplanZeit(new Date(zeit * 1000).toISOString(), new Date(jetzt * 1000))
}
/** „vor 2 h“, „vor 5 Tagen“ — leer, wo der Zeitpunkt fehlt. */
export function alterText(zeit: number | null, jetzt: number): string {
return zeit === null ? "" : vor(zeit, jetzt * 1000)
}
/** „361 MB“, „1,2 GB“ — wie die Rückfrage des Homelab-Teils; null, wo die Größe fehlt. Sicherungen sind oft kleiner
* als ein GB (die Übersicht importiert diese Datei, darum ohne lib/speicher). */
export function groesseText(bytes: number | null): string | null {
if (bytes === null || bytes <= 0) return null
return bytes < 1e9 ? `${Math.round(bytes / 1e6)} MB` : `${(bytes / 1e9).toFixed(1).replace(".", ",")} GB`
}
/** Das Ergebnis der letzten Aktion beim Gerät: ohne den Gerätenamen vorn, den die Meldung für Telegram braucht
* („Gitea: auf den Snapshot … zurückgesetzt“ → „Auf den Snapshot … zurückgesetzt“). */
export function zuletztText(text: string, name: string): string {
const rest = text.startsWith(`${name}: `) ? text.slice(name.length + 2) : text
return rest.charAt(0).toUpperCase() + rest.slice(1)
}
/** Oben rechts: „3 Snapshots · 1 Sicherung“. */
export function summeText(s: HomelabSnapshots["summe"]): string {
const teile = [`${s.snapshots} ${s.snapshots === 1 ? "Snapshot" : "Snapshots"}`]
if (s.sicherungen > 0) teile.push(`${s.sicherungen} ${s.sicherungen === 1 ? "Sicherung" : "Sicherungen"}`)
return teile.join(" · ")
}
+66 -3
View File
@@ -331,7 +331,8 @@ export interface HomelabStand {
ausfuehrer: { verbunden: boolean; zuletzt: number | null }
}
/** Ein „Jetzt updaten“-Lauf im Homelab (GET /api/homelab/laeufe). */
/** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite
* Snapshots (Bausteine snapshot-anlegen, snapshot-loeschen, snapshot-zurueck, sicherung-loeschen). */
export interface HomelabLauf {
id: string
ziel: string
@@ -340,7 +341,9 @@ export interface HomelabLauf {
status: "laeuft" | "fertig" | "unterbrochen"
start: number
ende: number | null
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | null
ergebnis:
| "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen"
| "angelegt" | "geloescht" | "zurueckgesetzt" | null
text: string | null
schritte: string[]
}
@@ -480,7 +483,7 @@ export interface Sammellauf {
export interface ProtokollEintrag {
zeit: number
art: "auftrag" | "lauf" | "sammellauf" | "hinweis" | "meldung" | "pflege"
art: "auftrag" | "lauf" | "snapshot" | "sammellauf" | "hinweis" | "meldung" | "pflege"
titel: string
text: string | null
stufe: "ok" | "info" | "warn" | "fehler"
@@ -568,3 +571,63 @@ export interface HomelabSpeicher {
sicherungen: SicherungsLage
empfehlungen: SpeicherEmpfehlung[]
}
// --- Snapshots des Homelabs (GET /api/homelab/snapshots, seit 25.09.2026) ---------------------------------------
/** Woher ein Snapshot stammt: Rückweg vor einem Update, per Knopf auf der Seite Snapshots, von Hand in Proxmox. */
export type SnapshotHerkunft = "update" | "knopf" | "proxmox"
export interface HomelabSnapshot {
name: string
/** Unix-Sekunden; null, wo Proxmox keinen Zeitpunkt nennt. */
zeit: number | null
beschreibung: string | null
herkunft: SnapshotHerkunft
/** Vom Orchestrator (mc2-…): Nur solche lassen sich hier löschen oder zurücksetzen. */
eigen: boolean
loeschen: ZielAktion | null
zuruecksetzen: ZielAktion | null
}
/** Eine Sicherung des Orchestrators (vzdump-Archiv), wo vor Updates kein Snapshot geht (PBS). */
export interface HomelabSicherungsArchiv {
datei: string
speicher: string
zeit: number | null
/** Bytes; null, solange der Ausführer sie nicht nennt. */
groesse: number | null
loeschen: ZielAktion | null
}
export interface SnapshotGeraet {
id: string
vmid: number
art: "container" | "vm"
name: string
/** „Container 104“ */
wo: string
status: string | null
erlaubt: boolean
/** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */
anlegen: ZielAktion | null
grund: string | null
/** Was für das Gerät gerade läuft (auch ein Update), sonst null. */
laeuft: string | null
/** Ergebnis der letzten Aktion dieser Seite, solange es frisch ist. */
zuletzt: { ok: boolean; ende: number; text: string } | null
snapshots: HomelabSnapshot[]
sicherungen: HomelabSicherungsArchiv[]
}
export interface HomelabSnapshots {
/** Uhr des Homelab-Teils (Unix-Sekunden) — danach rechnet die Seite das Alter. */
jetzt: number
bericht: { empfangen: number; veraltet: boolean } | null
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean }
/** Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update irgendwo, der Ausführer) — sonst null. */
sperre: string | null
/** false: Der Ausführer ist älter als der 25.09.2026 und nennt nur Namen (Zeitpunkt aus dem Namen). */
details: boolean
geraete: SnapshotGeraet[]
summe: { snapshots: number; eigene: number; sicherungen: number; sicherungen_groesse: number | null }
}
+21 -8
View File
@@ -13,12 +13,13 @@ import {
useZielAktion,
} from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
import { istSnapshotLauf } from "@/lib/snapshots"
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher,
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Monitoring und
// Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Snapshots, Monitoring
// und Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
@@ -29,6 +30,7 @@ const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
fehler: { art: "rot", text: "Fehler" },
neustart: { art: "cyan", text: "Neustart" },
aufgeraeumt: { art: "gruen", text: "aufgeräumt" },
offen: { art: "cyan", text: "Probelauf" }, // Docker über Arcane, solange es nur probt
}
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
@@ -97,14 +99,18 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
)
}
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. */
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. Die Aktionen der
* Seite Snapshots sind keine Updates und stehen dort. */
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 12)
const fertig = laeufe.filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 12)
if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
return (
<ol className="@container m-0 flex list-none flex-col p-0">
{fertig.map((l) => {
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
// Ein Ergebnis ohne eigenen Eintrag riss die Seite vorher um (ERGEBNIS[…] war undefined).
const e = l.ergebnis
? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: l.ergebnis })
: { art: "grau" as ChipArt, text: "unterbrochen" }
return (
<li key={l.id} className="flex flex-col gap-1.5 border-t border-linie py-3 first:border-t-0 first:pt-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
@@ -188,18 +194,20 @@ function useHomelabBereich() {
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */
export function HomelabSeite() {
const navigate = useNavigate()
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand)
if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
const l = lage(stand, hinweise, laufend.size, berichtZeit)
// „Update läuft“ nur für Updates — ein Snapshot anlegen oder löschen ist keins.
const imUpdate = new Set(laeufe.filter((x) => x.status === "laeuft" && !istSnapshotLauf(x)).map((x) => x.ziel))
const l = lage(stand, hinweise, imUpdate.size, berichtZeit)
return (
<>
<Lagebild
lage={l}
lampen={stand.ziele.map((z) => lampeFuer(z, laufend.has(z.id)))}
lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))}
onAnsehen={() => {
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })
@@ -231,6 +239,11 @@ export function HomelabSeite() {
laeuft={laufend.get("pve") ?? null}
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)}
snapshotsLink={
<Link to="/homelab/snapshots" className="inline-flex min-h-10 items-center gap-1 self-start text-sm text-lab-text hover:underline">
Zu den Snapshots <ArrowRight className="size-3.5" aria-hidden />
</Link>
}
/>
</Panel>
)}
+9 -5
View File
@@ -1,5 +1,5 @@
import { useState } from "react"
import { Bell, ChevronRight, Cog, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react"
import { Bell, ChevronRight, Cog, History, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react"
import { cn } from "cn"
import { Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
@@ -14,6 +14,7 @@ type Art = ProtokollEintrag["art"]
const ARTEN: { id: Art | "alle"; name: string }[] = [
{ id: "alle", name: "Alles" },
{ id: "lauf", name: "Updates" },
{ id: "snapshot", name: "Snapshots" },
{ id: "auftrag", name: "Ausführer" },
{ id: "hinweis", name: "Hinweise" },
{ id: "meldung", name: "Meldungen" },
@@ -22,6 +23,7 @@ const ARTEN: { id: Art | "alle"; name: string }[] = [
const SYMBOL: Record<Art, LucideIcon> = {
auftrag: Cog, lauf: RefreshCw, sammellauf: RefreshCw, hinweis: TriangleAlert, meldung: Bell, pflege: Search,
snapshot: History,
}
const STUFE: Record<ProtokollEintrag["stufe"], { punkt: string; text: string; wort: string }> = {
@@ -47,7 +49,9 @@ function tagUeberschrift(zeit: number): string {
function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null }) {
const [offen, setOffen] = useState(false)
const s = STUFE[e.stufe] ?? STUFE.info
const Symbol = SYMBOL[e.art] ?? Cog
// Nicht „Symbol“ nennen: Im Entwicklungsmodus setzt der React Compiler seinen Zwischenspeicher oben in der Funktion
// mit Symbol.for(…) zurück — eine gleichnamige Konstante darunter ließ die Seite dort abstürzen.
const Zeichen = SYMBOL[e.art] ?? Cog
return (
<li className="grid grid-cols-[64px_18px_minmax(0,1fr)] gap-x-3 border-t border-linie py-2.5 first:border-t-0">
<span className="ziffern pt-0.5 text-xs text-text-3">{uhr.format(new Date(e.zeit * 1000))}</span>
@@ -56,7 +60,7 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null })
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex flex-wrap items-center gap-x-2 text-[15px]">
<Symbol className="size-3.5 shrink-0 text-text-3" aria-hidden />
<Zeichen className="size-3.5 shrink-0 text-text-3" aria-hidden />
<span className={cn(e.stufe === "fehler" || e.stufe === "warn" ? s.text : "text-foreground")}>{e.titel}</span>
{geraet && <span className="rounded-md bg-lab-grund px-1.5 text-xs text-lab-text">{geraet}</span>}
<span className="sr-only">({s.wort})</span>
@@ -106,8 +110,8 @@ export function HomelabProtokollSeite() {
return (
<Panel titel="Protokoll">
<p className="-mt-2 text-[15px] text-text-2">
Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Hinweise,
gesendete Meldungen und die nächtliche Paketsuche.
Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots,
Hinweise, gesendete Meldungen und die nächtliche Paketsuche.
</p>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Filter">
{ARTEN.map((a) => (
+66
View File
@@ -0,0 +1,66 @@
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { SnapshotListe } from "@/components/homelab/SnapshotListe"
import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen"
import { summeText, zeitpunktText } from "@/lib/snapshots"
// Homelab · Snapshots (seit 25.09.2026, User-Wunsch): je Gerät seine Snapshots und die Sicherungen des Orchestrators,
// mit Knöpfen zum Anlegen, Löschen und Zurücksetzen. Was erlaubt ist und die Rückfragen kommen vom Homelab-Teil
// (backend/services/homelab/snapshots.py); die Aktionen laufen dort als Läufe wie die Updates.
export function HomelabSnapshotsSeite() {
const instanz = useInstanz()
const partner = usePartner()
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const abfrage = useHomelabSnapshots(aktiv)
const aktion = useSnapshotAktion()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
if (abfrage.isPending) return <Zustandsfeld text="Die Snapshots werden gelesen …" />
if (!abfrage.data) {
return <Zustandsfeld fehler text={`Die Snapshots lassen sich nicht lesen: ${abfrage.error?.message ?? "keine Daten"}`} />
}
const d = abfrage.data
// Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — derselbe Satz wie beim Knopf.
const grund = d.sperre
return (
<Panel titel="Snapshots" rechts={d.bericht ? <Chip>{summeText(d.summe)}</Chip> : undefined}>
<p className="-mt-2 text-[15px] text-text-2">
Ein Snapshot hält den Stand eines Geräts fest. Vor jedem Update legt der Orchestrator einen an (beim Proxmox
Backup Server eine Sicherung) und räumt nach einem grünen Update die älteren weg. Hier legst du selbst welche
an — die bleiben, bis du sie löschst —, setzt ein Gerät zurück oder löschst, was weg kann. Von Hand in Proxmox
angelegte stehen nur zur Ansicht da.
</p>
{grund && (
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein">
{grund}
</p>
)}
{d.bericht?.veraltet && (
<p className="text-[15px] text-bernstein">
Der letzte Bericht ist von {zeitpunktText(d.bericht.empfangen, d.jetzt)}; die Liste kann veraltet sein.
</p>
)}
{!d.bericht ? (
<p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>
) : (
<>
{!d.details && (
<p className="text-sm text-text-3">
Beschreibungen und die Größe der Sicherungen erscheinen, sobald der neue Ausführer auf dem Proxmox-Host
läuft; bis dahin kommt der Zeitpunkt aus dem Namen des Snapshots.
</p>
)}
<SnapshotListe daten={d} gesperrt={aktion.isPending || !!grund} onAktion={(a) => aktion.mutate(a)} />
<p className="text-sm text-text-3">
Wie viel ein Snapshot im Speicherpool belegt, sagt Proxmox nicht: Er teilt sich die Daten mit dem Gerät und
wächst mit jeder Änderung seit seinem Zeitpunkt. Wie voll der Pool ist, steht im Cockpit unter „Speicher und
Sicherungen“.
</p>
</>
)}
</Panel>
)
}
+3 -1
View File
@@ -11,6 +11,7 @@ import {
useUpdateVerlauf,
} from "@/lib/abfragen"
import { useBereichStand, type Verbindung } from "@/lib/navigation"
import { istSnapshotLauf } from "@/lib/snapshots"
import { useLetzteMetrik } from "@/lib/strom"
import type { Hinweis, Messpunkt } from "@/lib/typen"
import { gesamtlage, type KurzZeile, kurzformen, type Lagestufe } from "@/lib/uebersicht"
@@ -236,7 +237,8 @@ function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) {
...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({
zeit: new Date(l.start).getTime(), bereich: "KI-Box", titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau",
})),
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft").slice(0, 6).map((l) => ({
// Die Aktionen der Seite Snapshots sind keine Updates.
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({
zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen",
art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau",
})),