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:
co-authored by
Claude Opus 5.5
parent
f7a9b8ad50
commit
afa3a476b5
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }), {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user