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
+6
View File
@@ -70,6 +70,12 @@ def host_neustart() -> dict:
return _antwort(updates.starten("pve", "neustart")) return _antwort(updates.starten("pve", "neustart"))
@router.post("/ziele/pve/kernel-aufraeumen")
def host_kernel_aufraeumen() -> dict:
"""Alte Kernel vom Proxmox-Host entfernen (seit 25.09.2026, nur per Knopf). Welche, entscheidet der Ausführer."""
return _antwort(updates.starten("pve", "kernel"))
@router.post("/ziele/{ziel_id}/suchen") @router.post("/ziele/{ziel_id}/suchen")
def suchen(ziel_id: str) -> dict: def suchen(ziel_id: str) -> dict:
gast = inventar.gast(ziel_id) gast = inventar.gast(ziel_id)
+2 -1
View File
@@ -33,7 +33,8 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
fcntl = None fcntl = None
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen", AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen"} "host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
"kernel_aufraeumen"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet) VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60) BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe … VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
+6 -2
View File
@@ -62,10 +62,12 @@ _AKTION = {
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt", "host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
"Host-Pakete"), "Host-Pakete"),
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"), "host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
"kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt",
"Kernel aufräumen"),
} }
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart"} _HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen"}
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update", _LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"neustart": "Neustart"} "neustart": "Neustart", "kernel": "Kernel-Aufräumen"}
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt", _SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"} "zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) " _MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
@@ -313,6 +315,8 @@ def _lauf(x: dict, schw: Callable) -> dict | None:
titel, stufe = f"{was} gescheitert · {name}", "fehler" titel, stufe = f"{was} gescheitert · {name}", "fehler"
elif ergebnis == "neustart": elif ergebnis == "neustart":
titel, stufe = f"Neustart ausgelöst · {name}", "info" titel, stufe = f"Neustart ausgelöst · {name}", "info"
elif ergebnis == "aufgeraeumt":
titel, stufe = f"Alte Kernel entfernt · {name}", "ok"
elif ergebnis == "offen": elif ergebnis == "offen":
titel, stufe = f"Docker-Probelauf · {name}", "info" titel, stufe = f"Docker-Probelauf · {name}", "info"
else: else:
+52 -6
View File
@@ -9,6 +9,8 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu
ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben). ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung • Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
(täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar. (täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar.
• Systemplatte des Proxmox-Hosts (Stufen wie oben): Dort liegen die Kernel, die Sicherungen vor Updates (local) und
alles vom Host selbst. Alte Kernel lassen sich per Knopf entfernen (Ausführer: kernel_aufraeumen).
Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage). Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage).
""" """
@@ -111,7 +113,7 @@ def empfehlungen(bericht: dict) -> list[dict]:
for u in g.get("ungenutzt") or []: for u in g.get("ungenutzt") or []:
liste.append({ liste.append({
"art": "ungenutzt", "platz": u.get("belegt"), "titel": f"{_name(g)}: ungenutzte Platte {u['volume']}", "art": "ungenutzt", "platz": u.get("belegt"), "titel": f"{_name(g)}: ungenutzte Platte {u['volume']}",
"kurz": f"ungenutzte Platte bei {_name(g)}", "kurz": f"ungenutzte Platte bei {_name(g)}", "ort": "pool",
"text": (f"Hängt an keinem Laufwerk mehr. Wird sie nicht mehr gebraucht: in Proxmox {_wo(g)} → Hardware " "text": (f"Hängt an keinem Laufwerk mehr. Wird sie nicht mehr gebraucht: in Proxmox {_wo(g)} → Hardware "
"→ „Unbenutzte Festplatte“ → Entfernen (löscht sie endgültig).")}) "→ „Unbenutzte Festplatte“ → Entfernen (löscht sie endgültig).")})
if (summe := sum(x for _, x in container)) >= EMPFEHLUNG_AB: if (summe := sum(x for _, x in container)) >= EMPFEHLUNG_AB:
@@ -120,16 +122,18 @@ def empfehlungen(bericht: dict) -> list[dict]:
befehle = "; ".join(f"pct fstrim {g['vmid']} --ignore-mountpoints" for g, _ in container) befehle = "; ".join(f"pct fstrim {g['vmid']} --ignore-mountpoints" for g, _ in container)
liste.append({ liste.append({
"art": "trim", "platz": summe, "titel": "Container: freigegebenen Platz an den Pool zurückgeben", "art": "trim", "platz": summe, "titel": "Container: freigegebenen Platz an den Pool zurückgeben",
"kurz": f"{_groesse(summe)} per TRIM in den Containern", "kurz": f"{_groesse(summe)} per TRIM in den Containern", "ort": "pool",
"text": (f"Die Container belegen im Pool {_gb(summe)} GB mehr, als sie nutzen ({einzeln}). In Proxmox: pve → " "text": (f"Die Container belegen im Pool {_gb(summe)} GB mehr, als sie nutzen ({einzeln}). In Proxmox: pve → "
f"Shell → {befehle} — im laufenden Betrieb und ohne Verlust.")}) f"Shell → {befehle} — im laufenden Betrieb und ohne Verlust.")})
for p in (bericht.get("host") or {}).get("pools") or []: for p in (bericht.get("host") or {}).get("pools") or []:
for v in p.get("verwaist") or []: for v in p.get("verwaist") or []:
liste.append({ liste.append({
"art": "verwaist", "platz": v.get("belegt"), "titel": f"Verwaiste Platte {v['volume']}", "art": "verwaist", "platz": v.get("belegt"), "titel": f"Verwaiste Platte {v['volume']}",
"kurz": f"verwaiste Platte {v['volume']}", "kurz": f"verwaiste Platte {v['volume']}", "ort": "pool",
"text": (f"Gehört zu keinem Gast mehr. Wird sie nicht mehr gebraucht: in Proxmox pve → {p.get('speicher')} " "text": (f"Gehört zu keinem Gast mehr. Wird sie nicht mehr gebraucht: in Proxmox pve → {p.get('speicher')} "
"→ Datenträger → Entfernen (löscht sie endgültig).")}) "→ Datenträger → Entfernen (löscht sie endgültig).")})
if kernel := _kernel_vorschlag(bericht.get("host") or {}):
liste.append(kernel)
liste.sort(key=lambda e: -(e["platz"] or 0)) liste.sort(key=lambda e: -(e["platz"] or 0))
for g in gaeste: for g in gaeste:
for s in g.get("snapshots") or []: for s in g.get("snapshots") or []:
@@ -137,6 +141,7 @@ def empfehlungen(bericht: dict) -> list[dict]:
wann = f"vom {m.group(3)}.{m.group(2)}. {m.group(4)}:{m.group(5)}" if m else "von Hand angelegt" wann = f"vom {m.group(3)}.{m.group(2)}. {m.group(4)}:{m.group(5)}" if m else "von Hand angelegt"
liste.append({ liste.append({
"art": "snapshot", "platz": None, "titel": f"{_name(g)}: Snapshot {s}", "kurz": f"Snapshot bei {_name(g)}", "art": "snapshot", "platz": None, "titel": f"{_name(g)}: Snapshot {s}", "kurz": f"Snapshot bei {_name(g)}",
"ort": "pool",
"text": ((f"Rückweg des Updates {wann}. Läuft {_name(g)} seitdem gut, kann er weg" if m else "text": ((f"Rückweg des Updates {wann}. Läuft {_name(g)} seitdem gut, kann er weg" if m else
f"Snapshot {wann}. Wird er nicht mehr gebraucht, kann er weg") f"Snapshot {wann}. Wird er nicht mehr gebraucht, kann er weg")
+ f": in Proxmox {_wo(g)} → Snapshots → {s} → Entfernen. Wie viel er belegt, sagt Proxmox " + f": in Proxmox {_wo(g)} → Snapshots → {s} → Entfernen. Wie viel er belegt, sagt Proxmox "
@@ -144,6 +149,36 @@ def empfehlungen(bericht: dict) -> list[dict]:
return liste return liste
def _kernel_vorschlag(host: dict) -> dict | None:
"""Alte Kernel auf der Systemplatte des Hosts — mit Knopf. Die Liste stammt vom Ausführer (was apt für entbehrlich
hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst."""
alt = host.get("kernel_alt") or []
if not alt:
return None
platz = sum(int(k.get("groesse") or 0) for k in alt)
bleiben = ", ".join(str(v).removesuffix("-pve") for v in host.get("kernel_bleiben") or []) or "der laufende"
return {
"art": "kernel", "ort": "systemplatte", "platz": platz,
"titel": f"Proxmox-Host: {len(alt)} alte Kernel entfernen",
"kurz": f"{_groesse(platz)} alte Kernel auf dem Proxmox-Host",
"text": (f"Liegen auf der Systemplatte des Hosts, nicht im Speicherpool. Entfernt wird nur, was Proxmox selbst für "
f"entbehrlich hält; es bleiben {bleiben} — darunter der laufende und der für den nächsten Start. Die "
"Gäste laufen weiter, ein Neustart ist dafür nicht nötig."),
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/pve/kernel-aufraeumen", "label": "Alte Kernel entfernen",
"frage": (f"{len(alt)} alte Kernel vom Proxmox-Host entfernen (etwa {_groesse(platz)})? Es bleiben "
f"{bleiben}. Die Gäste laufen weiter, ein Neustart ist nicht nötig.")},
}
def systemplatte(bericht: dict) -> dict | None:
p = (bericht.get("host") or {}).get("systemplatte") or {}
gesamt, belegt = p.get("gesamt"), p.get("belegt")
if not gesamt or belegt is None:
return None
anteil = belegt / gesamt
return {"belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt, "anteil": anteil, "stufe": _stufe(anteil)}
def _trim_vm(g: dict, im_pool: int, genutzt: int, gewinn: int) -> dict: def _trim_vm(g: dict, im_pool: int, genutzt: int, gewinn: int) -> dict:
kopf = f"Im Pool belegt die VM {_gb(im_pool)} GB, genutzt sind darin {_gb(genutzt)} GB." kopf = f"Im Pool belegt die VM {_gb(im_pool)} GB, genutzt sind darin {_gb(genutzt)} GB."
if g.get("discard"): if g.get("discard"):
@@ -156,6 +191,7 @@ def _trim_vm(g: dict, im_pool: int, genutzt: int, gewinn: int) -> dict:
"sofort mit „sudo fstrim -av“ in der VM.") "sofort mit „sudo fstrim -av“ in der VM.")
return {"art": "trim-vm", "platz": gewinn, "titel": f"{_name(g)}: {_groesse(gewinn)} an den Pool zurückgeben", return {"art": "trim-vm", "platz": gewinn, "titel": f"{_name(g)}: {_groesse(gewinn)} an den Pool zurückgeben",
"kurz": f"{_groesse(gewinn)} bei {_name(g)} per TRIM" + ("" if g.get("discard") else " (Discard einschalten)"), "kurz": f"{_groesse(gewinn)} bei {_name(g)} per TRIM" + ("" if g.get("discard") else " (Discard einschalten)"),
"ort": "pool",
"text": text} "text": text}
@@ -258,8 +294,8 @@ def sicherungen(bericht: dict, jetzt: float) -> dict:
def lage(jetzt: float | None = None) -> dict: def lage(jetzt: float | None = None) -> dict:
b = _bericht() b = _bericht()
jetzt = time.time() if jetzt is None else jetzt jetzt = time.time() if jetzt is None else jetzt
return {"bericht": bool(b), "pools": pools(b), "nas": nas(b), "sicherungen": sicherungen(b, jetzt), return {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b),
"empfehlungen": empfehlungen(b)} "sicherungen": sicherungen(b, jetzt), "empfehlungen": empfehlungen(b)}
def pruefe_speicher(jetzt: float | None = None) -> list: def pruefe_speicher(jetzt: float | None = None) -> list:
@@ -271,7 +307,7 @@ def pruefe_speicher(jetzt: float | None = None) -> list:
jetzt = time.time() if jetzt is None else jetzt jetzt = time.time() if jetzt is None else jetzt
befunde = [] befunde = []
tipps = empfehlungen(b) tipps = empfehlungen(b)
platz_tipp = "; ".join(e["kurz"] for e in tipps[:3]) platz_tipp = "; ".join([e["kurz"] for e in tipps if e.get("ort") == "pool"][:3])
for p in pools(b): for p in pools(b):
if p["stufe"] == "ok": if p["stufe"] == "ok":
continue continue
@@ -284,6 +320,16 @@ def pruefe_speicher(jetzt: float | None = None) -> list:
+ (f"Platz bringt: {platz_tipp}. " if platz_tipp else "") + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“."), + "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“."),
quelle="pve")) quelle="pve"))
sp = systemplatte(b)
if sp and sp["stufe"] != "ok":
kernel = next((e for e in tipps if e["art"] == "kernel"), None)
befunde.append(Befund(
id="host-platte", stufe=sp["stufe"],
titel=f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll",
text=(f"Noch {_groesse(sp['frei'])} frei. Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. "
+ (f"Platz bringt: {kernel['kurz']} (Knopf im Homelab-Cockpit unter „Speicher und Sicherungen“)."
if kernel else "Alte Sicherungen unter /var/lib/vz/dump und ISO-Abbilder prüfen.")),
quelle="pve"))
for n in nas(b): for n in nas(b):
if n["stufe"] == "ok": if n["stufe"] == "ok":
continue continue
+15 -3
View File
@@ -316,6 +316,16 @@ def _docker_lauf(lauf: dict, basis: str, gast: dict) -> None:
def _host_lauf(lauf: dict) -> None: def _host_lauf(lauf: dict) -> None:
try: try:
if lauf["baustein"] == "kernel":
# Welche Kernel gehen, bestimmt der Ausführer selbst (nie der laufende oder der nächste).
a = _auftrag(lauf, "kernel_aufraeumen", {}, ZEITLIMIT_SICHERUNG_S)
werte = dict(z.split("=", 1) for z in str(a.get("text") or "").splitlines()[:2] if "=" in z)
anzahl, frei = int(werte.get("entfernt") or 0), int(werte.get("frei") or 0)
_auftrag(lauf, "bericht", {}, 5 * 60)
_ende(lauf, "aufgeraeumt", f"Proxmox-Host: {anzahl} alte Kernel entfernt, "
f"{frei / 1e9:.1f} GB frei geworden.".replace(".", ",", 1)
if anzahl else "Proxmox-Host: kein alter Kernel zu entfernen.")
return
if lauf["baustein"] == "neustart": if lauf["baustein"] == "neustart":
_auftrag(lauf, "host_neustart", {}, 60) _auftrag(lauf, "host_neustart", {}, 60)
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.") _ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
@@ -324,7 +334,8 @@ def _host_lauf(lauf: dict) -> None:
_auftrag(lauf, "bericht", {}, 5 * 60) _auftrag(lauf, "bericht", {}, 5 * 60)
_ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.") _ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.")
except RuntimeError as exc: except RuntimeError as exc:
_ende(lauf, "fehler", f"Proxmox-Host: Update gescheitert — {exc}", dringend=True) was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update"
_ende(lauf, "fehler", f"Proxmox-Host: {was} gescheitert — {exc}", dringend=True)
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None: def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
@@ -358,7 +369,7 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict
sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt
der Knopf ab, solange ein Sammellauf läuft.""" der Knopf ab, solange ein Sammellauf läuft."""
if baustein not in ("app", "os", "pakete", "neustart", "docker"): if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel"):
return {"ok": False, "detail": "Unbekannter Baustein."} return {"ok": False, "detail": "Unbekannter Baustein."}
with START_SPERRE: with START_SPERRE:
if sammellauf is None and _sammellauf_laeuft(): if sammellauf is None and _sammellauf_laeuft():
@@ -368,7 +379,8 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict
if ziel_id == "pve": if ziel_id == "pve":
name, gast = "Proxmox-Host", None name, gast = "Proxmox-Host", None
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
mehr = {"pakete": len(host.get("updates") or [])} if baustein == "pakete" else {} mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete"
else {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" else {})
else: else:
gast = inventar.gast(ziel_id) gast = inventar.gast(ziel_id)
if not gast: if not gast:
+32
View File
@@ -148,6 +148,8 @@ def _ausfuehrer_spielen(stopp: threading.Event, neue_version: str, protokoll: li
text = "" text = ""
if auftrag["aktion"] == "snapshot": if auftrag["aktion"] == "snapshot":
text = "snapshot=mc2-20260924-190000" text = "snapshot=mc2-20260924-190000"
elif auftrag["aktion"] == "kernel_aufraeumen":
text = "entfernt=20\nfrei=18300000000\nRemoving proxmox-kernel-6.17.2-1-pve-signed …"
elif auftrag["aktion"] == "bericht": elif auftrag["aktion"] == "bericht":
neu = json.loads(json.dumps(BERICHT)) neu = json.loads(json.dumps(BERICHT))
next(g for g in neu["gaeste"] if g["vmid"] == 104)["app"]["version"] = neue_version next(g for g in neu["gaeste"] if g["vmid"] == 104)["app"]["version"] = neue_version
@@ -451,3 +453,33 @@ def test_neuer_kernel_heisst_neustart(daten):
host = {b["id"]: b for b in next(z for z in inventar.ziele()["ziele"] if z["id"] == "pve")["bausteine"]} host = {b["id"]: b for b in next(z for z in inventar.ziele()["ziele"] if z["id"] == "pve")["bausteine"]}
assert host["neustart"]["grund"] == "Neuer Kernel 7.0.14-19-pve ist installiert, es läuft noch 7.0.14-12-pve." assert host["neustart"]["grund"] == "Neuer Kernel 7.0.14-19-pve ist installiert, es läuft noch 7.0.14-12-pve."
assert host["neustart"]["aktion"]["label"] == "Jetzt neu starten" assert host["neustart"]["aktion"]["label"] == "Jetzt neu starten"
def test_alte_kernel_nur_auf_knopfdruck(ausfuehrer):
"""25.09.2026: 25 Kernel auf dem Host, 20 davon hielt apt für entbehrlich."""
starten, protokoll, meldungen, verlauf = ausfuehrer
starten("1.27.3")
lauf = _auf_ende(updates.starten("pve", "kernel")["lauf"])
assert (lauf["ergebnis"], protokoll) == ("aufgeraeumt", ["kernel_aufraeumen", "bericht"])
assert meldungen[-1] == ("Proxmox-Host: 20 alte Kernel entfernt, 18,3 GB frei geworden.", False)
assert verlauf[-1][2:4] == ("pve", "aufgeraeumt")
from services.homelab import protokoll as prot
assert prot._lauf(lauf, lambda *a: None) is None or True # Titel-Zweig existiert (kein KeyError)
def test_ausfuehrer_waehlt_alte_kernel_selbst():
a = _ausfuehrer_modul()
simulation = """NOTE: This is only a simulation!
Reading package lists...
The following packages will be REMOVED:
proxmox-kernel-6.14.11-2-pve-signed proxmox-kernel-7.0.14-12-pve-signed libfoo1
Remv proxmox-kernel-6.14.11-2-pve-signed [6.14.11-2]
Remv proxmox-kernel-7.0.14-12-pve-signed [7.0.14-12]
Remv proxmox-kernel-7.0.14-5-pve-signed [7.0.14-5]
Remv proxmox-kernel-6.17 [6.17.13-21]
Remv libfoo1 [1.2-3]
Remv pve-kernel-6.8.12-4-pve [6.8.12-4]
"""
behalten = {"7.0.14-12-pve", "7.0.14-19-pve"}
assert a.alte_kernel(simulation, behalten) == [
"proxmox-kernel-6.14.11-2-pve-signed", "proxmox-kernel-7.0.14-5-pve-signed", "pve-kernel-6.8.12-4-pve"]
+24
View File
@@ -239,3 +239,27 @@ def test_schnittstelle(monkeypatch):
assert daten["bericht"] is True and daten["pools"][0]["stufe"] == "ok" assert daten["bericht"] is True and daten["pools"][0]["stufe"] == "ok"
assert daten["nas"][0]["erreichbar"] is True and daten["sicherungen"]["stufe"] == "ok" assert daten["nas"][0]["erreichbar"] is True and daten["sicherungen"]["stufe"] == "ok"
assert daten["empfehlungen"][0]["art"] == "trim-vm" assert daten["empfehlungen"][0]["art"] == "trim-vm"
def test_alte_kernel_mit_knopf_und_systemplatte(monkeypatch):
b = _bericht()
b["host"].update(
kernel_alt=[{"paket": f"proxmox-kernel-6.17.{i}-1-pve-signed", "version": f"6.17.{i}-1-pve", "groesse": 915_000_000}
for i in range(20)],
kernel_bleiben=["6.14.8-2-pve", "6.17.13-21-pve", "7.0.14-12-pve", "7.0.14-19-pve"],
systemplatte={"belegt": 47_000_000_000, "gesamt": 94_000_000_000})
tipps = speicher.empfehlungen(b)
assert [t["art"] for t in tipps] == ["trim-vm", "kernel", "trim", "snapshot"]
kernel = tipps[1]
assert kernel["titel"] == "Proxmox-Host: 20 alte Kernel entfernen" and kernel["platz"] == 18_300_000_000
assert kernel["ort"] == "systemplatte" and kernel["aktion"]["pfad"] == "/api/homelab/ziele/pve/kernel-aufraeumen"
assert "es bleiben 6.14.8-2, 6.17.13-21, 7.0.14-12, 7.0.14-19" in kernel["text"]
assert speicher.systemplatte(b)["stufe"] == "ok"
# Die Systemplatte läuft voll: eigener Hinweis mit dem Kernel-Vorschlag; der Pool-Hinweis nennt ihn nicht.
b["host"]["systemplatte"]["belegt"] = 80_000_000_000
b["host"]["pools"][0]["belegt"] = int(b["host"]["pools"][0]["gesamt"] * 0.95)
monkeypatch.setattr(speicher, "antwortet", lambda s, p: True)
monkeypatch.setattr(speicher, "_bericht", lambda: b)
befunde = {x.id: x for x in speicher.pruefe_speicher(JETZT)}
assert befunde["host-platte"].stufe == "gelb" and "18 GB alte Kernel auf dem Proxmox-Host" in befunde["host-platte"].text
assert "Kernel" not in befunde["pool:local-lvm"].text
+83 -3
View File
@@ -637,12 +637,70 @@ def _host() -> dict:
host["fehler"] = str(exc)[:200] host["fehler"] = str(exc)[:200]
code, kernel = _laufen(["uname", "-r"], 5) code, kernel = _laufen(["uname", "-r"], 5)
host["kernel"] = kernel if code == 0 else None host["kernel"] = kernel if code == 0 else None
vorhanden = [os.path.basename(p)[len("vmlinuz-"):] for p in glob.glob("/boot/vmlinuz-*")] pin = _lesen("/etc/kernel/proxmox-boot-pin").strip() or None
host["kernel_neu"] = neuer_kernel(host["kernel"], vorhanden, _lesen("/etc/kernel/proxmox-boot-pin").strip() or None) host["kernel_neu"] = neuer_kernel(host["kernel"], _kernel_vorhanden(), pin)
host["neustart_noetig"] = host["neustart_noetig"] or bool(host["kernel_neu"]) host["neustart_noetig"] = host["neustart_noetig"] or bool(host["kernel_neu"])
host["systemplatte"] = _platte("/")
try:
host["kernel_alt"], host["kernel_bleiben"] = _kernel_lage(host["kernel"], host["kernel_neu"] or host["kernel"], pin)
except Exception:
host["kernel_alt"], host["kernel_bleiben"] = [], []
return host return host
def _platte(pfad: str) -> dict | None:
"""Belegung wie df sie zeigt: belegt + was Nutzer noch schreiben dürfen (ohne die Reserve für root)."""
try:
s = os.statvfs(pfad)
except (OSError, AttributeError): # AttributeError: Windows kennt statvfs nicht (Tests am PC)
return None
belegt = (s.f_blocks - s.f_bfree) * s.f_frsize
return {"belegt": belegt, "gesamt": belegt + s.f_bavail * s.f_frsize}
def _kernel_vorhanden() -> list[str]:
return [os.path.basename(p)[len("vmlinuz-"):] for p in glob.glob("/boot/vmlinuz-*")]
KERNEL_PAKET = re.compile(r"^(?:proxmox|pve)-kernel-(\d+\.\d+\.\d+-\d+-pve)(?:-signed)?$")
def alte_kernel(simulation: str, behalten: set[str]) -> list[str]:
"""Kernel-Pakete, die `apt-get -s autoremove` entfernen würde — nur Kernel und nie einer aus `behalten` (der
laufende, der beim nächsten Start kommt, ein festgepinnter). Was Proxmox selbst hält (je Serie den jüngsten, den
bei der Installation), steht gar nicht erst in der Simulation."""
pakete = []
for zeile in simulation.splitlines():
m = re.match(r"^Remv (\S+)", zeile)
k = KERNEL_PAKET.match(m.group(1)) if m else None
if k and k.group(1) not in behalten:
pakete.append(m.group(1))
return pakete
def _kernel_lage(laufend: str | None, naechster: str | None, pin: str | None) -> tuple[list[dict], list[str]]:
"""(entbehrliche Kernel mit Paket, Version und belegtem Platz samt initrd; die bleibenden Versionen)."""
code, simulation = _laufen(["apt-get", "-s", "autoremove"], 120)
if code != 0 or not laufend:
return [], []
pakete = alte_kernel(simulation, {k for k in (laufend, naechster, pin) if k})
groessen: dict[str, int] = {}
if pakete:
code, aus = _laufen(["dpkg-query", "-W", "-f=${Package}\t${Installed-Size}\n", *pakete], 30)
for zeile in aus.splitlines() if code == 0 else []:
name, _, kib = zeile.partition("\t")
if kib.strip().isdigit():
groessen[name] = int(kib) * 1024
weg = []
for paket in pakete:
version = KERNEL_PAKET.match(paket).group(1)
initrd = f"/boot/initrd.img-{version}"
weg.append({"paket": paket, "version": version,
"groesse": groessen.get(paket, 0) + (os.path.getsize(initrd) if os.path.exists(initrd) else 0)})
entbehrlich = {w["version"] for w in weg}
return weg, sorted((v for v in _kernel_vorhanden() if v not in entbehrlich), key=_versionsschluessel)
def _versionsschluessel(version: str) -> tuple[int, ...]: def _versionsschluessel(version: str) -> tuple[int, ...]:
return tuple(int(x) for x in re.findall(r"\d+", version)) return tuple(int(x) for x in re.findall(r"\d+", version))
@@ -843,11 +901,33 @@ def a_bericht(p: dict) -> tuple[int, str]:
return 0, json.dumps(bericht(), ensure_ascii=False) return 0, json.dumps(bericht(), ensure_ascii=False)
def a_kernel_aufraeumen(p: dict) -> tuple[int, str]:
"""Alte Kernel entfernen (seit 25.09.2026, nur auf Knopfdruck). Welche, bestimmt dieser Host selbst, jetzt und wie
im Bericht — der Server kann nur den Anstoß geben. Am Ende: entfernt=<Anzahl>, frei=<Bytes>."""
code, laufend = _laufen(["uname", "-r"], 5)
if code != 0 or not laufend.strip():
raise ValueError("der laufende Kernel ließ sich nicht lesen")
laufend = laufend.strip()
pin = _lesen("/etc/kernel/proxmox-boot-pin").strip() or None
naechster = neuer_kernel(laufend, _kernel_vorhanden(), pin) or laufend
weg, _ = _kernel_lage(laufend, naechster, pin)
pakete = [w["paket"] for w in weg if w["version"] not in {laufend, naechster, pin}]
if not pakete:
return 0, "entfernt=0\nfrei=0\nKein alter Kernel zu entfernen."
vorher = _platte("/") or {}
code, text = _laufen(["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "-y", "purge", *pakete], 30 * 60,
sanft=True)
nachher = _platte("/") or {}
frei = max(0, int(vorher.get("belegt") or 0) - int(nachher.get("belegt") or 0))
return code, f"entfernt={len(pakete) if code == 0 else 0}\nfrei={frei}\n{text[-1500:]}"
AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update, "suchen": a_suchen, AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update, "suchen": a_suchen,
"zurueck": a_zurueck, "zurueck": a_zurueck,
"snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update, "snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
"host_neustart": a_host_neustart, "bericht": a_bericht, "host_neustart": a_host_neustart, "bericht": a_bericht,
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen} "sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen,
"kernel_aufraeumen": a_kernel_aufraeumen}
def ausfuehren(auftrag: dict) -> dict: def ausfuehren(auftrag: dict) -> dict:
+7 -1
View File
@@ -349,7 +349,13 @@ flowchart LR
nicht eingebunden oder hängt = rot; eine Sicherung älter als erlaubt (Zeitplan nur mit Uhrzeit = täglich: 26 h, nicht eingebunden oder hängt = rot; eine Sicherung älter als erlaubt (Zeitplan nur mit Uhrzeit = täglich: 26 h,
sonst 8 Tage) = gelb, bei allen Geräten oder unerreichbarem Ziel rot. Empfehlungen, größter Gewinn zuerst: Platz, sonst 8 Tage) = gelb, bei allen Geräten oder unerreichbarem Ziel rot. Empfehlungen, größter Gewinn zuerst: Platz,
den ein Gast freigegeben hat, der im Pool aber belegt bleibt (VM ohne Discard; Container per `pct fstrim`, den ein Gast freigegeben hat, der im Pool aber belegt bleibt (VM ohne Discard; Container per `pct fstrim`,
zusammengefasst ab 1 GB), ungenutzte und verwaiste Platten, Snapshots (Größe unbekannt, zuletzt). zusammengefasst ab 1 GB), ungenutzte und verwaiste Platten, Snapshots (Größe unbekannt, zuletzt). Seit 25.09. auch
die Systemplatte des Hosts (`host.systemplatte`, `statvfs("/")` wie df) und alte Kernel: `host.kernel_alt` = was
`apt-get -s autoremove` entfernen würde, nur Kernel-Pakete, nie laufender, nächster (`kernel_neu`) oder
festgepinnter Kernel, mit Größe samt initrd; `host.kernel_bleiben`. Der Vorschlag hat einen Knopf
(`POST /api/homelab/ziele/pve/kernel-aufraeumen` → Lauf `kernel` → Ausführer-Aktion `kernel_aufraeumen`, die die
Liste selbst neu bestimmt und `apt-get -y purge` ausführt; Ergebnis `aufgeraeumt`). `neustart_noetig` kommt
außerdem, wenn ein neuerer Kernel installiert ist als der laufende (Proxmox legt dafür kein reboot-required an).
- **Oberfläche** (Bereich „Homelab“, nur Daten aus `/api/homelab/…`): - **Oberfläche** (Bereich „Homelab“, nur Daten aus `/api/homelab/…`):
- **Cockpit** (`/homelab`): oben die Lage wie das Warnpanel der KI-Box — die große Leuchte (Störung vor Hinweis vor - **Cockpit** (`/homelab`): oben die Lage wie das Warnpanel der KI-Box — die große Leuchte (Störung vor Hinweis vor
laufendem Update vor offenen Updates) und eine Lampe je Gerät —, darunter die Hinweise des Homelab-Wächters und laufendem Update vor offenen Updates) und eine Lampe je Gerät —, darunter die Hinweise des Homelab-Wächters und
+5 -2
View File
@@ -112,8 +112,11 @@ Werte zu jedem Zeitpunkt; „Werte als Tabelle“ klappt die letzten Punkte als
- **Speicher und Sicherungen** (seit 25.09.): der Speicherpool des Proxmox-Hosts (auf ihm liegen alle Container, die - **Speicher und Sicherungen** (seit 25.09.): der Speicherpool des Proxmox-Hosts (auf ihm liegen alle Container, die
VM und die Snapshots), das NAS (antwortet es, ist es eingebunden, wie voll) und die nächtlichen Sicherungen (hat VM und die Snapshots), das NAS (antwortet es, ist es eingebunden, wie voll) und die nächtlichen Sicherungen (hat
jedes Gerät eine frische). Füllstände ab 80 % bernstein, ab 90 % — also bei 10 % frei — rot und als dringende jedes Gerät eine frische). Füllstände ab 80 % bernstein, ab 90 % — also bei 10 % frei — rot und als dringende
Meldung. Darunter „Was im Speicherpool Platz bringt“: Vorschläge mit ungefährem Gewinn und dem Klickweg in Meldung. Dazu die Systemplatte des Hosts (Kernel, Sicherungen vor Updates), mit denselben Stufen. Darunter „Was
Proxmox, größter zuerst; aufgeklappt, sobald der Pool knapp wird. Gelöscht wird nichts von selbst. Platz bringt“: Vorschläge mit ungefährem Gewinn, dem Ort und dem Klickweg in Proxmox, größter zuerst; aufgeklappt,
sobald Pool oder Systemplatte knapp werden. Gelöscht wird nichts von selbst — nur alte Kernel haben einen Knopf
(„Alte Kernel entfernen“, mit Rückfrage): Entfernt wird, was Proxmox selbst für entbehrlich hält, nie der laufende
oder der Kernel für den nächsten Start; die Gäste laufen weiter.
## Homelab · Updates ## Homelab · Updates
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{Bt as a,Dt as o,I as s,Zt as c,jt as l,nt as u}from"./index-COh3_SSc.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-06g2D7J7.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:t}){let n=c(),r=s(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await a(`/api/maintenance/restart`,{service:e.unit});t.ok?o(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):o(`fehler`,`${e.name} ließ sich nicht starten: ${l(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:i(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(d,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&t(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste}; import{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{Bt as a,Dt as o,I as s,Zt as c,jt as l,nt as u}from"./index-FvHAYExN.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-CB1NvtKC.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:t}){let n=c(),r=s(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await a(`/api/maintenance/restart`,{service:e.unit});t.ok?o(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):o(`fehler`,`${e.name} ließ sich nicht starten: ${l(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:i(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(d,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&t(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-COh3_SSc.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-06g2D7J7.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}export{d as MenueSchublade}; import{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-FvHAYExN.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-CB1NvtKC.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}export{d as MenueSchublade};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-06g2D7J7.js";var s=e(),c=t();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster}; import{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-CB1NvtKC.js";var s=e(),c=t();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Kt as e}from"./index-COh3_SSc.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t}; import{Kt as e}from"./index-FvHAYExN.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Kt as e}from"./index-COh3_SSc.js";var t={name:`search`,size:24,node:[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]};t.node;var n=e(t);export{n as t}; import{Kt as e}from"./index-FvHAYExN.js";var t={name:`search`,size:24,node:[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]};t.node;var n=e(t);export{n as t};
@@ -1 +1 @@
import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Vt as i}from"./index-COh3_SSc.js";import{t as a}from"./button-D8rvET0Z.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-D9JSQbGO.js";var p=t();e();var m=n();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t}; import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Vt as i}from"./index-FvHAYExN.js";import{t as a}from"./button-D8rvET0Z.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-LMCPoT8k.js";var p=t();e();var m=n();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
+2 -2
View File
@@ -9,11 +9,11 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title> <title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-COh3_SSc.js"></script> <script type="module" crossorigin src="/assets/index-FvHAYExN.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js"> <link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js"> <link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js"> <link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js">
<link rel="stylesheet" crossorigin href="/assets/index-CXja6cgU.css"> <link rel="stylesheet" crossorigin href="/assets/index-tY0RgKNN.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -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 { auftraegeText, groesse, zeitplanText } from "@/lib/speicher"
import type { HomelabSpeicher } from "@/lib/typen" import type { HomelabSpeicher } from "@/lib/typen"
import { Speicher } from "./Speicher" import { Speicher } from "./Speicher"
@@ -8,6 +8,7 @@ const daten: HomelabSpeicher = {
bericht: true, bericht: true,
pools: [{ speicher: "local-lvm", belegt: 184_123_256_523, gesamt: 374_538_764_288, frei: 190_415_507_765, 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" }], 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", 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, 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 }], 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", () => { describe("Speicher-Texte", () => {
it("rundet Größen und liest den Zeitplan", () => { it("rundet Größen und liest den Zeitplan", () => {
expect(groesse(5_200_000_000)).toBe("5,2 GB") 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 { ChevronRight } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt } from "@/components/cockpit/Chip" import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher" 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" import { flugplanZeit } from "@/lib/zeit"
const CHIP: Record<SpeicherStufe | "keine", ChipArt> = { ok: "gruen", gelb: "bernstein", rot: "rot", keine: "grau" } 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 }) { function NasKachel({ n }: { n: NasStand }) {
const chipText = n.problem ? (n.stufe === "rot" ? "gestört" : "unklar") : STUFE_TEXT[n.stufe] const chipText = n.problem ? (n.stufe === "rot" ? "gestört" : "unklar") : STUFE_TEXT[n.stufe]
return ( 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 * 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 %). * Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %).
*/ */
export function Speicher({ daten }: { daten: HomelabSpeicher }) { export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
const knapp = daten.pools.some((p) => p.stufe !== "ok") 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) const summe = daten.empfehlungen.reduce((n, e) => n + (e.platz ?? 0), 0)
return ( return (
<div className="flex flex-col gap-4"> <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.pools.map((p) => <PoolKachel key={p.speicher} p={p} />)}
{daten.systemplatte && <SystemplatteKachel s={daten.systemplatte} />}
{daten.nas.length > 0 ? ( {daten.nas.length > 0 ? (
daten.nas.map((n) => <NasKachel key={n.ziel} n={n} />) 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"> <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"> <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 /> <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>
<span className="ziffern text-sm text-text-2"> <span className="ziffern text-sm text-text-2">
{daten.empfehlungen.length} {daten.empfehlungen.length === 1 ? "Vorschlag" : "Vorschläge"} {daten.empfehlungen.length} {daten.empfehlungen.length === 1 ? "Vorschlag" : "Vorschläge"}
@@ -152,17 +175,50 @@ export function Speicher({ daten }: { daten: HomelabSpeicher }) {
</summary> </summary>
<ol className="flex flex-col"> <ol className="flex flex-col">
{daten.empfehlungen.map((e) => ( {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"> <Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage} />
<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> </ol>
</details> </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> </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" status: "laeuft" | "fertig" | "unterbrochen"
start: number start: number
ende: number | null ende: number | null
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | null ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | null
text: string | null text: string | null
schritte: string[] schritte: string[]
} }
@@ -503,16 +503,29 @@ export interface SicherungsLage {
} }
export interface SpeicherEmpfehlung { 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 titel: string
text: string text: string
kurz: string kurz: string
platz: number | null 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 { export interface HomelabSpeicher {
bericht: boolean bericht: boolean
pools: SpeicherPool[] pools: SpeicherPool[]
systemplatte: Systemplatte | null
nas: NasStand[] nas: NasStand[]
sicherungen: SicherungsLage sicherungen: SicherungsLage
empfehlungen: SpeicherEmpfehlung[] empfehlungen: SpeicherEmpfehlung[]
+8 -2
View File
@@ -28,6 +28,7 @@ const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
zurueckgerollt: { art: "bernstein", text: "zurückgerollt" }, zurueckgerollt: { art: "bernstein", text: "zurückgerollt" },
fehler: { art: "rot", text: "Fehler" }, fehler: { art: "rot", text: "Fehler" },
neustart: { art: "cyan", text: "Neustart" }, neustart: { art: "cyan", text: "Neustart" },
aufgeraeumt: { art: "gruen", text: "aufgeräumt" },
} }
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString()) 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. */ /** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */
export function HomelabSeite() { export function HomelabSeite() {
const navigate = useNavigate() const navigate = useNavigate()
const { ersatz, stand, laufend, hinweise } = useHomelabBereich() const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand) const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand) const speicher = useHomelabSpeicher(!!stand)
if (ersatz || !stand) return ersatz if (ersatz || !stand) return ersatz
@@ -225,7 +226,12 @@ export function HomelabSeite() {
</Panel> </Panel>
{speicher.data?.bericht && ( {speicher.data?.bericht && (
<Panel titel="Speicher und Sicherungen" id="speicher"> <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> </Panel>
)} )}
</> </>
+1 -1
View File
@@ -225,7 +225,7 @@ function HomelabKarte({ verbindung }: { verbindung: Verbindung }) {
const ERGEBNIS: Record<string, ChipArt> = { const ERGEBNIS: Record<string, ChipArt> = {
eingespielt: "gruen", gruen: "gruen", zurueckgerollt: "bernstein", gelb: "bernstein", fehler: "rot", rot: "rot", 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. */ /** Was zuletzt eingespielt wurde, aus beiden Bereichen, neueste zuerst. */