Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten. Entscheide: - Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen. - Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer, SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran. - Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt eingespielt“. - Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen). - speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot. - Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf); die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers); Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand). Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren, Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
import { type ReactNode, useState } from "react"
|
|
import { ChevronRight } from "lucide-react"
|
|
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 { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher"
|
|
import type {
|
|
HomelabSpeicher, NasStand, SicherungsLage, SpeicherEmpfehlung, SpeicherPool, SpeicherStufe, Systemplatte, ZielAktion,
|
|
} 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 SystemplatteKachel({ s }: { s: Systemplatte }) {
|
|
return (
|
|
<Kachel titel="Systemplatte" unter="Proxmox-Host: Kernel, Sicherungen vor Updates" chip={CHIP[s.stufe]} chipText={STUFE_TEXT[s.stufe]}>
|
|
<Fuellstand anteil={s.anteil} stufe={s.stufe} name="Systemplatte des Proxmox-Hosts" />
|
|
<p className="ziffern text-sm text-text-2">
|
|
{Math.round(s.anteil * 100)} % · {groesse(s.belegt)} von {groesse(s.gesamt)} · {groesse(s.frei)} frei
|
|
</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, 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"
|
|
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-2 2xl:grid-cols-4">
|
|
{daten.pools.map((p) => <PoolKachel key={p.speicher} p={p} />)}
|
|
{daten.systemplatte && <SystemplatteKachel s={daten.systemplatte} />}
|
|
{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 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) => (
|
|
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage}
|
|
verweis={e.art === "snapshot" ? snapshotsLink : null} />
|
|
))}
|
|
</ol>
|
|
</details>
|
|
)}
|
|
<Bestaetigen
|
|
offen={!!frage}
|
|
titel={frage ? `Proxmox-Host: ${frage.label}` : ""}
|
|
text={frage?.frage ?? ""}
|
|
knopf={frage?.label ?? ""}
|
|
gesperrt={gesperrt}
|
|
onJa={() => {
|
|
if (frage && onAktion) onAktion(frage)
|
|
setFrage(null)
|
|
}}
|
|
onNein={() => setFrage(null)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ORT: Record<string, string> = { pool: "Speicherpool", systemplatte: "Systemplatte des Hosts" }
|
|
|
|
/** Befehle stehen im Text zwischen Backticks: als Code, ohne Ligaturen — sonst zog die Schrift „--“ zu „—“ zusammen. */
|
|
function MitBefehlen({ text }: { text: string }) {
|
|
return text.split(/`([^`]+)`/).map((teil, i) =>
|
|
i % 2 === 1 ? (
|
|
<code key={i} className="rounded bg-erhaben px-1 font-mono text-[13px] text-foreground [font-variant-ligatures:none]">
|
|
{teil}
|
|
</code>
|
|
) : (
|
|
teil
|
|
),
|
|
)
|
|
}
|
|
|
|
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">
|
|
<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>
|
|
{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>
|
|
) : (
|
|
<Button variant="outline" size="sm" className="mt-1 self-start" disabled={gesperrt} onClick={() => onFrage(e.aktion!)}>
|
|
{e.aktion.label}
|
|
</Button>
|
|
))}
|
|
</li>
|
|
)
|
|
}
|