Welle 1 · Sicherungs-Abdeckung: Gäste ohne Auftrag, Prüfung durch den PBS, Probe-Wiederherstellung je Gerät

Der Ausführer meldet jetzt, welche Gäste kein Sicherungsauftrag erfasst (wie Proxmox unter Rechenzentrum → Backup),
je Gast die jüngste Prüfung durch den PBS samt beschädigter Sicherungen, die Schätzung des PBS, wann sein Datastore
voll ist, und öffnet einmal die Woche die jüngste Sicherung jedes Gasts probeweise (nur lesend: Konfiguration, bei
Containern der Dateibaum). Der Wächter meldet: ohne Auftrag gelb (sofort), beschädigt rot, seit 15 Tagen nicht
geprüft gelb, Probe gescheitert gelb, Sicherungsziel laut PBS bald voll. Die Speicherkarte zeigt „Sicherungen je
Gerät“ mit Knopf „Jetzt prüfen“. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:37:21 +02:00
co-authored by Claude Opus 5.5
parent f7a9b8ad50
commit afa3a476b5
13 changed files with 765 additions and 17 deletions
@@ -0,0 +1,55 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { SicherungGeraet, SicherungsLage } from "@/lib/typen"
import { SicherungJeGeraet } from "./SicherungJeGeraet"
const jetzt = Math.floor(Date.now() / 1000)
const gut: SicherungGeraet = {
ziel: "ct-102", name: "NetBird", vmid: 102, auftrag: true, zuletzt: jetzt - 13 * 3600, frisch: true,
geprueft: { zustand: "ok", zeit: jetzt - 5 * 86400, sicherung: jetzt - 5 * 86400 - 4 * 3600 }, defekt: [],
probe: { speicher: "pbs-qnap", sicherung: jetzt - 13 * 3600, ok: true, fehler: null, zeit: jetzt - 2 * 86400 },
}
const lage = (geraete: SicherungGeraet[]): SicherungsLage => ({
jobs: [{ zeitplan: "02:30", speicher: "pbs-qnap", alle: false }], ziele: [], erfasst: 2, fehlend: [], zuletzt: null,
probleme: [], stufe: "ok", geraete, probe: { zeit: jetzt - 2 * 86400 },
})
describe("SicherungJeGeraet", () => {
it("bleibt zu, solange alles abgedeckt ist, und sagt es in einer Zeile", () => {
const { container } = render(<SicherungJeGeraet s={lage([gut])} />)
expect(container.querySelector("details")!.open).toBe(false)
expect(screen.getByText("alle 1 in einem Auftrag · Probe vor 2 Tagen")).toBeInTheDocument()
const zeile = screen.getByRole("row", { name: /NetBird/ })
expect(within(zeile).getByText(/in Ordnung/)).toBeInTheDocument()
expect(within(zeile).getByText(/gelungen/)).toBeInTheDocument()
})
it("klappt auf und nennt, was fehlt: ohne Auftrag, beschädigt, Probe gescheitert", () => {
const onProbe = vi.fn()
const { container } = render(
<SicherungJeGeraet
s={lage([
gut,
{ ...gut, ziel: "ct-104", name: "Gitea", vmid: 104, auftrag: false, zuletzt: null, frisch: false, geprueft: null, probe: null },
{ ...gut, ziel: "vm-106", name: "Arcane (Docker)", vmid: 106, geprueft: { ...gut.geprueft!, zustand: "failed" },
defekt: [jetzt - 5 * 86400], probe: { ...gut.probe!, ok: false, fehler: "Die Konfiguration ließ sich nicht aus der Sicherung lesen" } },
])}
onProbe={onProbe}
/>,
)
expect(container.querySelector("details")!.open).toBe(true)
expect(screen.getByText("1 ohne Auftrag · Probe bei 1 gescheitert")).toBeInTheDocument()
const gitea = screen.getByRole("row", { name: /Gitea/ })
expect(within(gitea).getByText("in keinem Sicherungsauftrag")).toBeInTheDocument()
expect(within(gitea).getByText("noch nie")).toBeInTheDocument()
const arcane = screen.getByRole("row", { name: /Arcane/ })
expect(within(arcane).getByText(/beschädigt/)).toBeInTheDocument()
expect(within(arcane).getByText("Die Konfiguration ließ sich nicht aus der Sicherung lesen")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Jetzt prüfen" }))
expect(onProbe).toHaveBeenCalledOnce()
})
it("zeigt nichts, solange der Ausführer keine Abdeckung liefert", () => {
const { container } = render(<SicherungJeGeraet s={{ ...lage([]), geraete: undefined }} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,112 @@
import type { ReactNode } from "react"
import { ChevronRight } from "lucide-react"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import type { SicherungGeraet, SicherungsLage } from "@/lib/typen"
import { flugplanZeit, vor } from "@/lib/zeit"
// Sicherungen je Gerät (Ausbauplan Welle 1, seit 25.09.2026): ob ein Sicherungsauftrag das Gerät erfasst, wann es
// zuletzt gesichert wurde, wann der PBS die Sicherung zuletzt geprüft hat und ob sich die jüngste probeweise öffnen
// ließ (Probe-Wiederherstellung des Ausführers, jede Woche). So bleibt kein neues Gerät unbemerkt ohne Sicherung.
const SPALTEN = "md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1.1fr)_minmax(0,1.1fr)]"
const zeitAus = (s: number) => flugplanZeit(new Date(s * 1000).toISOString()).toLowerCase()
function Zelle({ titel, children, className }: { titel: string; children: ReactNode; className?: string }) {
return (
<span role="cell" className={cn("text-sm text-text-2", className)}>
<span className="text-xs text-text-3 md:hidden">{titel} </span>
{children}
</span>
)
}
function Zeile({ g }: { g: SicherungGeraet }) {
const beschaedigt = g.geprueft?.zustand === "failed"
return (
<div role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-1.5 border-t border-white/[0.06] py-3 md:items-start", SPALTEN)}>
<span role="rowheader" className="col-span-2 flex min-w-0 flex-col md:col-span-1">
<span className="text-[15px] font-semibold break-words">{g.name}</span>
{!g.auftrag && <span className="text-[13px] text-bernstein">in keinem Sicherungsauftrag</span>}
</span>
<Zelle titel="Gesichert" className={cn(g.auftrag && !g.frisch && "text-bernstein")}>
{g.zuletzt ? <span className="ziffern">{vor(g.zuletzt)}</span> : "noch nie"}
</Zelle>
<Zelle titel="Vom PBS geprüft" className={cn(beschaedigt && "text-rot-text")}>
{g.geprueft ? (
<>
<span className="ziffern">{zeitAus(g.geprueft.zeit)}</span>
{beschaedigt ? " · beschädigt" : " · in Ordnung"}
</>
) : (
<span className="text-text-3">noch nicht</span>
)}
{g.defekt.length > 0 && !beschaedigt && (
<span className="block text-xs text-bernstein">
{g.defekt.length === 1 ? "eine ältere Sicherung beschädigt" : `${g.defekt.length} ältere Sicherungen beschädigt`}
</span>
)}
</Zelle>
<Zelle titel="Probe" className={cn(g.probe && !g.probe.ok && "text-rot-text")}>
{g.probe ? (
<>
{g.probe.ok ? "gelungen" : "gescheitert"} <span className="ziffern">{vor(g.probe.zeit)}</span>
{g.probe.fehler && <span className="block text-xs break-words">{g.probe.fehler}</span>}
</>
) : (
<span className="text-text-3">noch keine</span>
)}
</Zelle>
</div>
)
}
/** Die Zeile über der Tabelle: was fehlt, oder dass alles abgedeckt ist. */
function zusammenfassung(s: SicherungsLage, geraete: SicherungGeraet[]): string {
const ohne = geraete.filter((g) => !g.auftrag).length
const teile = [ohne ? `${ohne} ohne Auftrag` : `alle ${geraete.length} in einem Auftrag`]
const gescheitert = geraete.filter((g) => g.probe && !g.probe.ok).length
if (gescheitert) teile.push(`Probe bei ${gescheitert} gescheitert`)
else if (s.probe?.zeit) teile.push(`Probe ${vor(s.probe.zeit)}`)
return teile.join(" · ")
}
export function SicherungJeGeraet({ s, onProbe, probeGesperrt = false }: {
s: SicherungsLage
/** „Jetzt prüfen“: die Probe-Wiederherstellung sofort. Ohne: kein Knopf. */
onProbe?: () => void
probeGesperrt?: boolean
}) {
const geraete = s.geraete ?? []
if (geraete.length === 0) return null
const problem = geraete.some((g) => !g.auftrag || g.defekt.length > 0 || (g.probe && !g.probe.ok))
return (
<details open={problem} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
Sicherungen je Gerät
</span>
<span className={cn("text-right text-sm", problem ? "text-bernstein" : "text-text-2")}>{zusammenfassung(s, geraete)}</span>
</summary>
<p className="m-0 pb-1 text-sm text-text-2">
Der PBS prüft die Sicherungen selbst (Prüfauftrag im PBS). Die Probe öffnet einmal die Woche die jüngste Sicherung
jedes Geräts, nur lesend: Die Konfiguration muss sich daraus lesen lassen, bei Containern auch der Dateibaum.
</p>
{onProbe && (
<Button variant="outline" size="sm" className="my-2" disabled={probeGesperrt} onClick={onProbe}>
Jetzt prüfen
</Button>
)}
<div role="table" aria-label="Sicherungen je Gerät" className="flex flex-col">
<div role="row" className={cn("etikett hidden gap-x-4 pt-1 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">Gesichert</span>
<span role="columnheader">Vom PBS geprüft</span>
<span role="columnheader">Probe</span>
</div>
{geraete.map((g) => <Zeile key={g.ziel} g={g} />)}
</div>
</details>
)
}
+13 -2
View File
@@ -4,6 +4,7 @@ import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import { SicherungJeGeraet } from "@/components/homelab/SicherungJeGeraet"
import { STUFE_FARBE, tempoText, vollText, vorhersageStufe } from "@/lib/prognose"
import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher"
import type {
@@ -131,11 +132,16 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
{s.probleme.map((p) => (
<p key={p} className="text-sm text-rot-text">{p}</p>
))}
{s.stufe === "ok" && (
{(s.stufe_frische ?? s.stufe) === "ok" && (
<p className="text-sm text-text-2">
Alle {s.erfasst} Geräte gesichert{s.zuletzt ? <>, zuletzt <span className="ziffern">{zeitAus(s.zuletzt)}</span></> : null}.
</p>
)}
{(s.ohne_auftrag?.length ?? 0) > 0 && (
<p className="text-sm text-bernstein">
In keinem Sicherungsauftrag: {s.ohne_auftrag!.map((g) => g.name).join(", ")}
</p>
)}
{s.fehlend.length > 0 && (
<ul className="flex flex-col gap-1 text-sm">
{s.fehlend.map((f) => (
@@ -151,6 +157,7 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
<p key={z.speicher} className={cn("ziffern text-xs", voll ? STUFE_FARBE[vorhersageStufe(z.vorhersage)] : "text-text-3")}>
Ziel {z.speicher}: {Math.round(((z.belegt ?? 0) / (z.gesamt ?? 1)) * 100)} % belegt
{voll ? ` · ${voll}` : z.vorhersage?.grund === "gleich" ? " · gleichbleibend" : ""}
{z.vorhersage?.quelle === "pbs" && (voll || z.vorhersage.grund === "gleich") ? " (Schätzung des PBS)" : ""}
</p>
)
})}
@@ -162,7 +169,7 @@ 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, snapshotsLink }: {
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, snapshotsLink, onProbe, probeGesperrt }: {
daten: HomelabSpeicher
/** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */
laeuft?: string | null
@@ -170,6 +177,9 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, sna
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
/** „Jetzt prüfen“ bei den Sicherungen je Gerät (Probe-Wiederherstellung, seit 25.09.2026). */
onProbe?: () => void
probeGesperrt?: boolean
}) {
const [frage, setFrage] = useState<ZielAktion | null>(null)
const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok"
@@ -188,6 +198,7 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, sna
)}
<SicherungsKachel s={daten.sicherungen} />
</div>
<SicherungJeGeraet s={daten.sicherungen} onProbe={onProbe} probeGesperrt={probeGesperrt} />
{daten.empfehlungen.length > 0 && (
<details open={knapp} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
+8
View File
@@ -506,6 +506,14 @@ export const useHomelabSnapshots = (aktiv: boolean) =>
retry: false,
})
/** „Jetzt prüfen“ bei den Sicherungen je Gerät: die Probe-Wiederherstellung sofort (seit 25.09.2026), nur lesend. Das
* Ergebnis bringt der Bericht, den der Ausführer gleich danach schickt. */
export const useSicherungsProbe = () =>
useAktion(() => post<AktionsErgebnis>("/api/homelab/sicherungen/probe"), {
erfolg: () => "Probe gestartet. Das Ergebnis steht in etwa einer Minute bei den Geräten.",
neuLaden: ["homelab-speicher"],
})
/** 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 }), {
+25
View File
@@ -437,6 +437,8 @@ export interface Vorhersage {
anteil: number | null
/** verlauf = weniger als zwei Tage Verlauf, gleich = wächst nicht. */
grund: "verlauf" | "gleich" | null
/** „pbs“: die eigene Schätzung des PBS (beim Sicherungsziel), sonst aus unserem Verlauf. */
quelle?: "pbs"
}
/** KI-Box: cpu, ram, gpu, platte in %, temp_cpu/temp_gpu in °C, netz_rx/netz_tx in Bytes/s, tokens je Minute. */
@@ -566,6 +568,29 @@ export interface SicherungsLage {
zuletzt: number | null
probleme: string[]
stufe: SpeicherStufe | "keine"
/** Nur die Frische der Sicherungen (stufe schließt seit 25.09.2026 die Abdeckung mit ein). */
stufe_frische?: SpeicherStufe | "keine"
/** Seit 25.09.2026: die Abdeckung je Gerät (Sicherungs-Abdeckung, Ausbauplan Welle 1). */
ohne_auftrag?: { ziel: string; name: string }[]
geraete?: SicherungGeraet[]
pruefung?: { zuletzt: number | null; noetig: boolean }
probe?: { zeit: number } | null
}
export interface SicherungGeraet {
ziel: string
name: string
vmid: number
/** Erfasst ein Sicherungsauftrag das Gerät? */
auftrag: boolean
zuletzt: number | null
frisch: boolean
/** Die jüngste Prüfung durch den PBS: Beginn, Ergebnis, Zeit der geprüften Sicherung. */
geprueft: { zustand: "ok" | "failed" | string; zeit: number; sicherung: number } | null
/** Zeiten der Sicherungen, deren Prüfung Beschädigtes fand. */
defekt: number[]
/** Probe-Wiederherstellung: die jüngste Sicherung probeweise geöffnet (zeit = wann). */
probe: { speicher: string; sicherung: number; ok: boolean; fehler: string | null; zeit: number } | null
}
export interface SpeicherEmpfehlung {
+4
View File
@@ -10,6 +10,7 @@ import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
import {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
useSammellauf, useSammellaufQuittieren,
useSicherungsProbe,
useZielAktion,
} from "@/lib/abfragen"
import { lage, offeneUpdates } from "@/lib/homelab"
@@ -197,6 +198,7 @@ export function HomelabSeite() {
const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand)
const probe = useSicherungsProbe()
if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
@@ -244,6 +246,8 @@ export function HomelabSeite() {
laeuft={laufend.get("pve") ?? null}
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)}
onProbe={() => probe.mutate(undefined)}
probeGesperrt={probe.isPending || !stand.ausfuehrer.verbunden}
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 />