homelab: alte Kernel per Knopf entfernen; Systemplatte des Proxmox-Hosts im Blick

User-Wunsch 25.09.: Auf dem Host lagen 25 Kernel (~23 GB), 20 davon hält apt für
entbehrlich (19,7 GB).
- Ausführer: host.kernel_alt/kernel_bleiben aus apt-get -s autoremove (nur
  Kernel, nie laufender, nächster oder festgepinnter), host.systemplatte;
  neue Aktion kernel_aufraeumen bestimmt die Liste selbst und purgt.
- Homelab-Teil: Lauf „kernel“ (Ergebnis aufgeraeumt), POST
  /api/homelab/ziele/pve/kernel-aufraeumen, Vorschlag mit Knopf, Wächter für die
  Systemplatte (80/90 %).
- Oberfläche: Kachel Systemplatte, Knopf „Alte Kernel entfernen“ mit Rückfrage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 12:16:14 +02:00
co-authored by Claude Opus 5.5
parent 28888890ec
commit 7a456c8efa
36 changed files with 377 additions and 60 deletions
@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react"
import { fireEvent, render, screen, within } from "@testing-library/react"
import { auftraegeText, groesse, zeitplanText } from "@/lib/speicher"
import type { HomelabSpeicher } from "@/lib/typen"
import { Speicher } from "./Speicher"
@@ -8,6 +8,7 @@ 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" }],
systemplatte: { belegt: 47_000_000_000, gesamt: 94_000_000_000, frei: 47_000_000_000, anteil: 0.5, 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 }],
@@ -58,6 +59,33 @@ describe("Speicher", () => {
})
})
describe("Alte Kernel", () => {
const kernel = {
art: "kernel" as const, ort: "systemplatte" as const, titel: "Proxmox-Host: 20 alte Kernel entfernen",
platz: 18_300_000_000, kurz: "18 GB alte Kernel auf dem Proxmox-Host", text: "Liegen auf der Systemplatte …",
aktion: { methode: "POST", pfad: "/api/homelab/ziele/pve/kernel-aufraeumen", label: "Alte Kernel entfernen",
frage: "20 alte Kernel vom Proxmox-Host entfernen (etwa 18 GB)? Es bleiben 7.0.14-12, 7.0.14-19." },
}
it("fragt vor dem Entfernen und zeigt die Systemplatte", () => {
const start = vi.fn()
render(<Speicher daten={{ ...daten, empfehlungen: [kernel] }} onAktion={start} />)
expect(screen.getByRole("meter", { name: "Systemplatte des Proxmox-Hosts: 50 % belegt" })).toBeInTheDocument()
expect(screen.getByText("Systemplatte des Hosts")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Alte Kernel entfernen" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Es bleiben 7.0.14-12, 7.0.14-19/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "Alte Kernel entfernen" }))
expect(start).toHaveBeenCalledWith(kernel.aktion)
})
it("zeigt den laufenden Schritt statt des Knopfs", () => {
render(<Speicher daten={{ ...daten, empfehlungen: [kernel] }} laeuft="kernel_aufraeumen …" onAktion={() => {}} />)
expect(screen.getByRole("status")).toHaveTextContent("Läuft: kernel_aufraeumen …")
expect(screen.queryByRole("button", { name: "Alte Kernel entfernen" })).not.toBeInTheDocument()
})
})
describe("Speicher-Texte", () => {
it("rundet Größen und liest den Zeitplan", () => {
expect(groesse(5_200_000_000)).toBe("5,2 GB")
+69 -13
View File
@@ -1,9 +1,13 @@
import type { ReactNode } from "react"
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, SpeicherPool, SpeicherStufe } from "@/lib/typen"
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" }
@@ -65,6 +69,17 @@ function PoolKachel({ p }: { p: SpeicherPool }) {
)
}
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 (
@@ -122,13 +137,21 @@ 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 }: { daten: HomelabSpeicher }) {
const knapp = daten.pools.some((p) => p.stufe !== "ok")
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
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
}) {
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-3">
<div className="grid gap-3 md:grid-cols-2 xl: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} />)
) : (
@@ -143,7 +166,7 @@ export function Speicher({ daten }: { daten: HomelabSpeicher }) {
<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
Was Platz bringt
</span>
<span className="ziffern text-sm text-text-2">
{daten.empfehlungen.length} {daten.empfehlungen.length === 1 ? "Vorschlag" : "Vorschläge"}
@@ -152,17 +175,50 @@ export function Speicher({ daten }: { daten: HomelabSpeicher }) {
</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>
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage} />
))}
</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" }
function Vorschlag({ e, laeuft, gesperrt, onFrage }: {
e: SpeicherEmpfehlung
laeuft: string | null
gesperrt: boolean
onFrage: (a: ZielAktion) => void
}) {
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">{e.text}</p>
{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>
)
}
+15 -2
View File
@@ -302,7 +302,7 @@ export interface HomelabLauf {
status: "laeuft" | "fertig" | "unterbrochen"
start: number
ende: number | null
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | null
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | null
text: string | null
schritte: string[]
}
@@ -503,16 +503,29 @@ export interface SicherungsLage {
}
export interface SpeicherEmpfehlung {
art: "trim" | "trim-vm" | "ungenutzt" | "verwaist" | "snapshot"
art: "trim" | "trim-vm" | "ungenutzt" | "verwaist" | "snapshot" | "kernel"
/** Wo der Platz frei wird: im Speicherpool oder auf der Systemplatte des Proxmox-Hosts. */
ort?: "pool" | "systemplatte"
titel: string
text: string
kurz: string
platz: number | null
/** Nur wo es einen Knopf gibt (alte Kernel). */
aktion?: ZielAktion | null
}
export interface Systemplatte {
belegt: number
gesamt: number
frei: number
anteil: number
stufe: SpeicherStufe
}
export interface HomelabSpeicher {
bericht: boolean
pools: SpeicherPool[]
systemplatte: Systemplatte | null
nas: NasStand[]
sicherungen: SicherungsLage
empfehlungen: SpeicherEmpfehlung[]
+8 -2
View File
@@ -28,6 +28,7 @@ const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
zurueckgerollt: { art: "bernstein", text: "zurückgerollt" },
fehler: { art: "rot", text: "Fehler" },
neustart: { art: "cyan", text: "Neustart" },
aufgeraeumt: { art: "gruen", text: "aufgeräumt" },
}
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
@@ -187,7 +188,7 @@ 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 } = useHomelabBereich()
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand)
if (ersatz || !stand) return ersatz
@@ -225,7 +226,12 @@ export function HomelabSeite() {
</Panel>
{speicher.data?.bericht && (
<Panel titel="Speicher und Sicherungen" id="speicher">
<Speicher daten={speicher.data} />
<Speicher
daten={speicher.data}
laeuft={laufend.get("pve") ?? null}
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)}
/>
</Panel>
)}
</>
+1 -1
View File
@@ -225,7 +225,7 @@ function HomelabKarte({ verbindung }: { verbindung: Verbindung }) {
const ERGEBNIS: Record<string, ChipArt> = {
eingespielt: "gruen", gruen: "gruen", zurueckgerollt: "bernstein", gelb: "bernstein", fehler: "rot", rot: "rot",
neustart: "cyan", info: "cyan",
neustart: "cyan", info: "cyan", aufgeraeumt: "gruen",
}
/** Was zuletzt eingespielt wurde, aus beiden Bereichen, neueste zuerst. */