homelab: Speicherpool, NAS und Sicherungen überwachen – mit Empfehlungen, was Platz bringt

User-Wunsch 25.09.: Speicherpool im Wächter (gelb ab 80 %, rot bei 10 % frei)
samt Empfehlungen, dazu das NAS (Backup-Ziel, per NFS auf dem Proxmox-Host).
- Ausführer: Thin-Pools mit echter Belegung je Volume (lvs), ungenutzte und
  verwaiste Platten, VM-Belegung über den Gast-Agenten, Discard je VM,
  Netzlaufwerke (df mit Zeitlimit, hängender harter NFS-Mount hält nichts auf),
  Sicherungsaufträge und jüngste Sicherung je Gast.
- Homelab-Teil: services/homelab/speicher.py (Auswertung, Empfehlungen,
  Wächter pruefe_speicher, NAS-Erreichbarkeit per TCP), GET /api/homelab/speicher;
  Gast-Plattenwächter nennt jetzt auch VMs richtig.
- Oberfläche: Karte „Speicher und Sicherungen“ im Homelab-Cockpit.
- deploy.sh und ausfuehrer-einrichten.sh warten, solange im Homelab ein Update
  oder „Alle aktualisieren“ läuft (der Neustart bräche den Lauf ab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 11:15:06 +02:00
co-authored by Claude Opus 5.5
parent 9867affc5a
commit 903fbbfbb9
43 changed files with 1249 additions and 40 deletions
@@ -0,0 +1,70 @@
import { render, screen, within } from "@testing-library/react"
import { auftraegeText, groesse, zeitplanText } from "@/lib/speicher"
import type { HomelabSpeicher } from "@/lib/typen"
import { Speicher } from "./Speicher"
// Die Zahlen vom 25.09.2026: local-lvm zu 49 %, das NAS (QNAP per NFS) zu 39 %, alle 8 Geräte heute Nacht gesichert.
const daten: HomelabSpeicher = {
bericht: true,
pools: [{ speicher: "local-lvm", belegt: 184_123_256_523, gesamt: 374_538_764_288, frei: 190_415_507_765,
anteil: 0.4916, meta_anteil: 0.0209, aktiv: true, stufe: "ok" }],
nas: [{ server: "192.168.178.62", ziel: "/mnt/qnap-backup", quelle: "192.168.178.62:/Backup", art: "nfs",
eingebunden: true, zustand: "ok", erreichbar: true, belegt: 1_517_685_571_584, gesamt: 3_847_503_609_856,
anteil: 0.3945, stufe: "ok", problem: null }],
sicherungen: { jobs: [{ zeitplan: "02:30", speicher: "pbs-qnap", alle: true }], ziele: [], erfasst: 8, fehlend: [],
zuletzt: 1_790_296_485, probleme: [], stufe: "ok" },
empfehlungen: [
{ art: "trim-vm", titel: "Arcane (Docker): 125 GB an den Pool zurückgeben", platz: 125_493_947_924,
kurz: "125 GB bei Arcane (Docker) per TRIM (Discard einschalten)",
text: "Im Pool belegt die VM 160,9 GB, genutzt sind darin 35,5 GB. Discard ist aus …" },
{ art: "snapshot", titel: "Gitea: Snapshot mc2-20260924-192352", platz: null, kurz: "Snapshot bei Gitea",
text: "Rückweg des Updates vom 24.09. 19:23. …" },
],
}
describe("Speicher", () => {
it("zeigt Pool, NAS und Sicherungen mit Füllstand", () => {
render(<Speicher daten={daten} />)
expect(screen.getByRole("meter", { name: "Speicherpool local-lvm: 49 % belegt" })).toBeInTheDocument()
expect(screen.getByText(/184 GB von 375 GB/)).toBeInTheDocument()
expect(screen.getByText("190 GB frei")).toBeInTheDocument()
expect(screen.getByText("NAS 192.168.178.62")).toBeInTheDocument()
expect(screen.getByText(/1,5 TB von 3,8 TB · 2,3 TB frei/)).toBeInTheDocument()
expect(screen.getByText("täglich 02:30 auf pbs-qnap")).toBeInTheDocument()
expect(screen.getByText(/Alle 8 Geräte gesichert/)).toBeInTheDocument()
})
it("klappt die Vorschläge erst auf, wenn der Pool knapp wird", () => {
const { container, rerender } = render(<Speicher daten={daten} />)
const klappe = container.querySelector("details")!
expect(klappe.open).toBe(false)
expect(within(klappe).getByText("2 Vorschläge · bis zu 125 GB")).toBeInTheDocument()
expect(within(klappe).getByText("≈ 125 GB")).toBeInTheDocument()
expect(within(klappe).getByText("Größe unbekannt")).toBeInTheDocument()
rerender(<Speicher daten={{ ...daten, pools: [{ ...daten.pools[0], anteil: 0.91, stufe: "rot" }] }} />)
expect(container.querySelector("details")!.open).toBe(true)
expect(screen.getByText("fast voll")).toBeInTheDocument()
})
it("nennt Störungen des NAS und fehlende Sicherungen", () => {
render(<Speicher daten={{
...daten,
nas: [{ ...daten.nas[0], erreichbar: false, stufe: "rot", problem: "Das NAS (192.168.178.62) antwortet nicht." }],
sicherungen: { ...daten.sicherungen, stufe: "gelb", fehlend: [{ ziel: "ct-104", name: "Gitea", zuletzt: null }] },
}} />)
expect(screen.getByText("Das NAS (192.168.178.62) antwortet nicht.")).toBeInTheDocument()
expect(screen.getByText("gestört")).toBeInTheDocument()
expect(screen.getByText("Gitea: noch nie gesichert")).toBeInTheDocument()
})
})
describe("Speicher-Texte", () => {
it("rundet Größen und liest den Zeitplan", () => {
expect(groesse(5_200_000_000)).toBe("5,2 GB")
expect(groesse(184_123_256_523)).toBe("184 GB")
expect(groesse(3_847_503_609_856)).toBe("3,8 TB")
expect(zeitplanText("02:30")).toBe("täglich 02:30")
expect(zeitplanText("sat 03:00")).toBe("Zeitplan „sat 03:00“")
expect(auftraegeText({ ...daten.sicherungen, jobs: [] })).toBe("Kein Sicherungsauftrag in Proxmox")
})
})
@@ -0,0 +1,168 @@
import type { ReactNode } from "react"
import { ChevronRight } from "lucide-react"
import { cn } from "cn"
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher"
import type { HomelabSpeicher, NasStand, SicherungsLage, SpeicherPool, SpeicherStufe } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const CHIP: Record<SpeicherStufe | "keine", ChipArt> = { ok: "gruen", gelb: "bernstein", rot: "rot", keine: "grau" }
const FARBE: Record<SpeicherStufe, string> = { ok: "var(--reihe-1)", gelb: "var(--bernstein)", rot: "var(--rot)" }
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
/** Füllstand mit Marken bei 80 % (gelb) und 90 % (rot, 10 % frei). */
function Fuellstand({ anteil, stufe, name }: { anteil: number; stufe: SpeicherStufe; name: string }) {
const prozent = Math.round(anteil * 100)
return (
<div
role="meter"
aria-label={`${name}: ${prozent} % belegt`}
aria-valuenow={prozent}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-2 w-full overflow-hidden rounded-full bg-erhaben"
>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, prozent))}%`, background: FARBE[stufe] }} />
<span aria-hidden className="absolute inset-y-0 left-[80%] w-px bg-bernstein/70" />
<span aria-hidden className="absolute inset-y-0 left-[90%] w-px bg-rot/80" />
</div>
)
}
function Kachel({ titel, unter, chip, chipText, children }: {
titel: string
unter?: string
chip: ChipArt
chipText: string
children: ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie bg-erhaben/40 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{titel}</span>
{unter && <span className="text-sm break-words text-text-3">{unter}</span>}
</div>
<Chip art={chip}>{chipText}</Chip>
</div>
{children}
</div>
)
}
function PoolKachel({ p }: { p: SpeicherPool }) {
const prozent = Math.round(p.anteil * 100)
return (
<Kachel titel={`Speicherpool ${p.speicher}`} unter="Platten der Container und VMs" chip={CHIP[p.stufe]} chipText={STUFE_TEXT[p.stufe]}>
<Fuellstand anteil={Math.max(p.anteil, p.meta_anteil ?? 0)} stufe={p.stufe} name={`Speicherpool ${p.speicher}`} />
<p className="ziffern text-sm text-text-2">
{prozent} % · {groesse(p.belegt)} von {groesse(p.gesamt)} · <span className={cn(p.stufe !== "ok" && "text-foreground")}>{groesse(p.frei)} frei</span>
</p>
{p.meta_anteil !== null && (
<p className="text-xs text-text-3">Metadaten {Math.round(p.meta_anteil * 100)} % belegt</p>
)}
</Kachel>
)
}
function NasKachel({ n }: { n: NasStand }) {
const chipText = n.problem ? (n.stufe === "rot" ? "gestört" : "unklar") : STUFE_TEXT[n.stufe]
return (
<Kachel
titel={`NAS ${n.server ?? ""}`.trim()}
unter={`${n.art.toUpperCase()} · ${n.quelle ?? "?"} → ${n.ziel}`}
chip={CHIP[n.stufe]}
chipText={chipText}
>
{n.problem ? (
<p className={cn("text-sm", n.stufe === "rot" ? "text-rot-text" : "text-bernstein")}>{n.problem}</p>
) : (
n.anteil !== null && (
<>
<Fuellstand anteil={n.anteil} stufe={n.stufe} name={`NAS ${n.server ?? ""}`} />
<p className="ziffern text-sm text-text-2">
{Math.round(n.anteil * 100)} % · {groesse(n.belegt)} von {groesse(n.gesamt)} · {groesse((n.gesamt ?? 0) - (n.belegt ?? 0))} frei
</p>
</>
)
)}
<p className="text-xs text-text-3">
{n.erreichbar === false ? "antwortet nicht" : "antwortet"} · {n.eingebunden ? "auf dem Proxmox-Host eingebunden" : "nicht eingebunden"}
</p>
</Kachel>
)
}
function SicherungsKachel({ s }: { s: SicherungsLage }) {
const chipText = { ok: "aktuell", gelb: "Lücke", rot: "gestört", keine: "kein Auftrag" }[s.stufe]
return (
<Kachel titel="Sicherungen" unter={auftraegeText(s)} chip={CHIP[s.stufe]} chipText={chipText}>
{s.probleme.map((p) => (
<p key={p} className="text-sm text-rot-text">{p}</p>
))}
{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.fehlend.length > 0 && (
<ul className="flex flex-col gap-1 text-sm">
{s.fehlend.map((f) => (
<li key={f.ziel} className="text-bernstein">
{f.name}: {f.zuletzt ? <>zuletzt <span className="ziffern">{zeitAus(f.zuletzt)}</span></> : "noch nie gesichert"}
</li>
))}
</ul>
)}
</Kachel>
)
}
/**
* 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 }: { daten: HomelabSpeicher }) {
const knapp = daten.pools.some((p) => p.stufe !== "ok")
const summe = daten.empfehlungen.reduce((n, e) => n + (e.platz ?? 0), 0)
return (
<div className="flex flex-col gap-4">
<div className="grid gap-3 md:grid-cols-3">
{daten.pools.map((p) => <PoolKachel key={p.speicher} p={p} />)}
{daten.nas.length > 0 ? (
daten.nas.map((n) => <NasKachel key={n.ziel} n={n} />)
) : (
<Kachel titel="NAS" chip="grau" chipText="keins">
<p className="text-sm text-text-3">Auf dem Proxmox-Host ist kein Netzlaufwerk eingebunden.</p>
</Kachel>
)}
<SicherungsKachel s={daten.sicherungen} />
</div>
{daten.empfehlungen.length > 0 && (
<details open={knapp} className="group rounded-xl border border-linie bg-erhaben/40 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 />
Was im Speicherpool Platz bringt
</span>
<span className="ziffern text-sm text-text-2">
{daten.empfehlungen.length} {daten.empfehlungen.length === 1 ? "Vorschlag" : "Vorschläge"}
{summe > 0 && ` · bis zu ${groesse(summe)}`}
</span>
</summary>
<ol className="flex flex-col">
{daten.empfehlungen.map((e) => (
<li key={e.titel} className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<span className="text-[15px] font-medium break-words">{e.titel}</span>
<span className="ziffern text-sm text-text-2">{e.platz ? `≈ ${groesse(e.platz)}` : "Größe unbekannt"}</span>
</div>
<p className="text-sm break-words text-text-2">{e.text}</p>
</li>
))}
</ol>
</details>
)}
</div>
)
}
+11 -1
View File
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -423,6 +423,16 @@ export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) =>
retry: false,
})
/** Speicherpool, NAS und Sicherungen samt Empfehlungen — der Bericht dahinter kommt alle 10 Minuten. */
export const useHomelabSpeicher = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-speicher"],
queryFn: () => api<HomelabSpeicher>("/api/homelab/speicher"),
enabled: aktiv,
refetchInterval: takt(60_000),
retry: false,
})
export const useHomelabEinstellungen = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-einstellungen"],
+26
View File
@@ -0,0 +1,26 @@
import type { SpeicherStufe, SicherungsLage } from "@/lib/typen"
// Speicher und Sicherungen des Homelabs (seit 25.09.2026): die kleinen Texte der Karte im Homelab-Cockpit.
const komma = (n: number, stellen = 1) => n.toFixed(stellen).replace(".", ",")
/** „184 GB“, „5,2 GB“, „2,3 TB“ — ab 10 GB ohne Nachkommastelle. */
export function groesse(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return "–"
if (bytes >= 1e12) return `${komma(bytes / 1e12)} TB`
return bytes >= 10e9 ? `${Math.round(bytes / 1e9)} GB` : `${komma(bytes / 1e9)} GB`
}
/** Der Zeitplan eines Sicherungsauftrags in Worten: „02:30“ heißt bei Proxmox täglich um 02:30. */
export function zeitplanText(zeitplan: string | null): string {
if (!zeitplan) return "ohne Zeitplan"
return /^\s*\d{1,2}:\d{2}\s*$/.test(zeitplan) ? `täglich ${zeitplan.trim()}` : `Zeitplan „${zeitplan}“`
}
/** „täglich 02:30 auf pbs-qnap“ — alle Aufträge in einer Zeile. */
export function auftraegeText(s: SicherungsLage): string {
if (s.jobs.length === 0) return "Kein Sicherungsauftrag in Proxmox"
return s.jobs.map((j) => `${zeitplanText(j.zeitplan)} auf ${j.speicher ?? "?"}`).join(", ")
}
export const STUFE_TEXT: Record<SpeicherStufe, string> = { ok: "in Ordnung", gelb: "wird knapp", rot: "fast voll" }
+56
View File
@@ -461,3 +461,59 @@ export interface HomelabEinstellungen {
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
}
// --- Speicher und Sicherungen des Homelabs (seit 25.09.2026) -------------------------------------------------
export type SpeicherStufe = "ok" | "gelb" | "rot"
export interface SpeicherPool {
speicher: string
belegt: number
gesamt: number
frei: number
anteil: number
meta_anteil: number | null
aktiv: boolean
stufe: SpeicherStufe
}
export interface NasStand {
server: string | null
ziel: string
quelle: string | null
art: string
eingebunden: boolean
zustand: "ok" | "haengt" | "fehler" | null
erreichbar: boolean | null
belegt: number | null
gesamt: number | null
anteil: number | null
stufe: SpeicherStufe
problem: string | null
}
export interface SicherungsLage {
jobs: { zeitplan: string | null; speicher: string | null; alle: boolean }[]
ziele: { speicher: string; art: string | null; aktiv: boolean | null; belegt: number | null; gesamt: number | null }[]
erfasst: number
fehlend: { ziel: string; name: string; zuletzt: number | null }[]
zuletzt: number | null
probleme: string[]
stufe: SpeicherStufe | "keine"
}
export interface SpeicherEmpfehlung {
art: "trim" | "trim-vm" | "ungenutzt" | "verwaist" | "snapshot"
titel: string
text: string
kurz: string
platz: number | null
}
export interface HomelabSpeicher {
bericht: boolean
pools: SpeicherPool[]
nas: NasStand[]
sicherungen: SicherungsLage
empfehlungen: SpeicherEmpfehlung[]
}
+9 -1
View File
@@ -4,10 +4,12 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Gesundheit } from "@/components/homelab/Gesundheit"
import { Lagebild } from "@/components/homelab/Lagebild"
import { Speicher } from "@/components/homelab/Speicher"
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
import {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useInstanz, usePartner, useSammellauf,
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
useSammellauf,
useZielAktion,
} from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
@@ -187,6 +189,7 @@ export function HomelabSeite() {
const navigate = useNavigate()
const { ersatz, stand, laufend, hinweise } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand)
if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
@@ -220,6 +223,11 @@ export function HomelabSeite() {
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
)}
</Panel>
{speicher.data?.bericht && (
<Panel titel="Speicher und Sicherungen" id="speicher">
<Speicher daten={speicher.data} />
</Panel>
)}
</>
)
}