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:
co-authored by
Claude Opus 5.5
parent
28888890ec
commit
7a456c8efa
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user