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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 11:15:06 +02:00
co-authored by Claude Opus 5.5
parent 9867affc5a
commit 903fbbfbb9
43 changed files with 1249 additions and 40 deletions
+7
View File
@@ -126,6 +126,13 @@ def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
return {"eintraege": protokoll.eintraege(grenze, berichte)}
@router.get("/speicher")
def speicher_lage() -> dict:
"""Speicherpool, NAS und Sicherungen samt Empfehlungen, was Platz bringt (Karte im Homelab-Cockpit)."""
from services.homelab import speicher
return speicher.lage()
@router.get("/einstellungen")
def einstellungen_stand() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
+312
View File
@@ -0,0 +1,312 @@
"""Speicher und Sicherungen des Homelabs (seit 25.09.2026, User-Wunsch).
Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfung, ob das NAS antwortet:
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt,
der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten,
Snapshots.
• NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet,
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
(täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar.
Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage).
"""
import re
import socket
import threading
import time
from datetime import datetime
from kern.zeit import LOCAL_TZ
from services.homelab import apps, kanal
GELB, ROT = 0.80, 0.90
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert
TRIM_AB = 250_000_000 # ein Container zählt beim gemeinsamen TRIM erst ab hier mit
TAEGLICH_S, SONST_S = 26 * 3600, 8 * 86400
PORTS = {"nfs": 2049, "nfs4": 2049, "cifs": 445, "smb3": 445}
ANTWORT_CACHE_S = 30
RANG = {"ok": 0, "gelb": 1, "rot": 2}
_antwort_cache: dict[tuple[str, int], tuple[float, bool]] = {}
_antwort_lock = threading.Lock()
def _stufe(anteil: float | None) -> str:
if anteil is None:
return "ok"
return "rot" if anteil >= ROT else "gelb" if anteil >= GELB else "ok"
def _schlimmer(*stufen: str) -> str:
return max(stufen, key=lambda s: RANG.get(s, 0))
def _gb(n: float | None) -> str:
return "–" if n is None else f"{n / 1e9:.1f}".replace(".", ",")
def _groesse(n: float | None) -> str:
"""„125 GB“, „2,3 TB“ — für Texte."""
if n is None:
return "–"
if n >= 1e12:
return f"{n / 1e12:.1f}".replace(".", ",") + " TB"
return f"{n / 1e9:.0f} GB" if n >= 10e9 else f"{_gb(n)} GB"
def _name(g: dict) -> str:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
return app.name if app else str(g.get("name") or g["vmid"])
def _wo(g: dict) -> str:
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
def _zid(g: dict) -> str:
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
def _datum(ts: float) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M")
def _bericht() -> dict:
return (kanal.bericht() or {}).get("bericht") or {}
# --- Speicherpool ----------------------------------------------------------------------------------------------
def pools(bericht: dict) -> list[dict]:
ergebnis = []
for p in (bericht.get("host") or {}).get("pools") or []:
gesamt, belegt = int(p.get("gesamt") or 0), int(p.get("belegt") or 0)
if not gesamt:
continue
anteil, meta = belegt / gesamt, p.get("meta_anteil")
ergebnis.append({"speicher": p.get("speicher"), "belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt,
"anteil": anteil, "meta_anteil": meta, "aktiv": p.get("aktiv", True),
"stufe": _schlimmer(_stufe(anteil), _stufe(meta))})
return ergebnis
def empfehlungen(bericht: dict) -> list[dict]:
"""Was im Speicherpool Platz bringt, der größte Gewinn zuerst; Snapshots (Größe unbekannt) danach.
Je Eintrag: art, titel, text (was zu tun ist, mit Klickweg), kurz (für Meldungen), platz (Bytes oder None)."""
gaeste = bericht.get("gaeste") or []
liste: list[dict] = []
container: list[tuple[dict, int]] = []
for g in gaeste:
platte, im_pool = g.get("platte") or {}, g.get("pool_belegt")
if im_pool and platte.get("belegt") is not None:
gewinn = int(im_pool) - int(platte["belegt"])
if g.get("art") == "lxc":
if gewinn >= TRIM_AB:
container.append((g, gewinn))
elif gewinn >= EMPFEHLUNG_AB:
liste.append(_trim_vm(g, int(im_pool), int(platte["belegt"]), gewinn))
for u in g.get("ungenutzt") or []:
liste.append({
"art": "ungenutzt", "platz": u.get("belegt"), "titel": f"{_name(g)}: ungenutzte Platte {u['volume']}",
"kurz": f"ungenutzte Platte bei {_name(g)}",
"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).")})
if (summe := sum(x for _, x in container)) >= EMPFEHLUNG_AB:
container.sort(key=lambda t: -t[1])
einzeln = ", ".join(f"{_name(g)} {_gb(x)} GB" for g, x in container)
befehle = "; ".join(f"pct fstrim {g['vmid']} --ignore-mountpoints" for g, _ in container)
liste.append({
"art": "trim", "platz": summe, "titel": "Container: freigegebenen Platz an den Pool zurückgeben",
"kurz": f"{_groesse(summe)} per TRIM in den Containern",
"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.")})
for p in (bericht.get("host") or {}).get("pools") or []:
for v in p.get("verwaist") or []:
liste.append({
"art": "verwaist", "platz": v.get("belegt"), "titel": f"Verwaiste Platte {v['volume']}",
"kurz": f"verwaiste Platte {v['volume']}",
"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).")})
liste.sort(key=lambda e: -(e["platz"] or 0))
for g in gaeste:
for s in g.get("snapshots") or []:
m = re.fullmatch(r"mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})\d{2}", s)
wann = f"vom {m.group(3)}.{m.group(2)}. {m.group(4)}:{m.group(5)}" if m else "von Hand angelegt"
liste.append({
"art": "snapshot", "platz": None, "titel": f"{_name(g)}: Snapshot {s}", "kurz": f"Snapshot bei {_name(g)}",
"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": in Proxmox {_wo(g)} → Snapshots → {s} → Entfernen. Wie viel er belegt, sagt Proxmox "
"nicht — bei Containern meist wenig, er wächst mit jeder Änderung seitdem.")})
return liste
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."
if g.get("discard"):
text = (f"{kopf} In der VM „sudo fstrim -av“ ausführen; Linux-Gäste tun das meist ohnehin jede Woche "
"(fstrim.timer).")
else:
text = (f"{kopf} Discard ist aus, deshalb kommt Platz, den die VM freigibt, nie im Pool an. In Proxmox: "
f"VM {g['vmid']} → Hardware → Festplatte → Bearbeiten → „Discard“ anhaken, dann die VM einmal neu "
"starten (die Dienste darin sind kurz weg). Danach gibt sie freien Platz jede Woche von selbst zurück; "
"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",
"kurz": f"{_groesse(gewinn)} bei {_name(g)} per TRIM" + ("" if g.get("discard") else " (Discard einschalten)"),
"text": text}
# --- NAS --------------------------------------------------------------------------------------------------------
def antwortet(server: str, port: int) -> bool:
"""Nimmt das NAS auf dem Port seines Dienstes Verbindungen an? (30 s gemerkt: Wächter und Oberfläche fragen oft.)"""
jetzt = time.monotonic()
with _antwort_lock:
if (e := _antwort_cache.get((server, port))) and jetzt - e[0] < ANTWORT_CACHE_S:
return e[1]
try:
with socket.create_connection((server, port), timeout=3):
ok = True
except OSError:
ok = False
with _antwort_lock:
_antwort_cache[(server, port)] = (jetzt, ok)
return ok
def nas(bericht: dict, pruefen=None) -> list[dict]:
pruefen = pruefen or antwortet
ergebnis = []
for m in (bericht.get("host") or {}).get("netzlaufwerke") or []:
server, port = m.get("server"), PORTS.get(str(m.get("art")))
erreichbar = pruefen(server, port) if server and port else None
platz = m.get("platz") or {}
gesamt, belegt = platz.get("gesamt"), platz.get("belegt")
anteil = belegt / gesamt if gesamt and belegt is not None else None
eintrag = {"server": server, "ziel": m.get("ziel"), "quelle": m.get("quelle"), "art": m.get("art"),
"eingebunden": bool(m.get("eingebunden")), "zustand": m.get("zustand"), "erreichbar": erreichbar,
"belegt": belegt, "gesamt": gesamt, "anteil": anteil, "stufe": "ok", "problem": None}
if erreichbar is False:
eintrag.update(stufe="rot", problem=f"Das NAS ({server}) antwortet nicht.")
elif not m.get("eingebunden"):
eintrag.update(stufe="rot", problem=f"{m.get('ziel')} ist auf dem Proxmox-Host nicht eingebunden.")
elif m.get("zustand") == "haengt":
eintrag.update(stufe="rot", problem=f"Die Einbindung {m.get('ziel')} hängt: Der Proxmox-Host bekommt keine "
"Antwort vom NAS.")
elif m.get("zustand") == "fehler":
eintrag.update(stufe="gelb", problem=f"Die Belegung von {m.get('ziel')} ließ sich nicht lesen.")
else:
eintrag["stufe"] = _stufe(anteil)
ergebnis.append(eintrag)
return ergebnis
# --- Sicherungen ------------------------------------------------------------------------------------------------
def max_alter_s(zeitplan: object) -> int:
"""Wie alt die jüngste Sicherung höchstens sein darf: täglich (nur Uhrzeit, etwa „02:30“) 26 Stunden, bei
Wochentagen oder allem Ausgefalleneren 8 Tage."""
return TAEGLICH_S if re.fullmatch(r"\s*(\d{1,2}|\*)(:\d{2})?\s*", str(zeitplan or "")) else SONST_S
def _erfasst(job: dict, vmid: int) -> bool:
"""Sichert dieser Auftrag den Gast? (alle außer den ausgenommenen, oder die aufgezählten)"""
if vmid in (job.get("ausgenommen") or []):
return False
return bool(job.get("alle")) or vmid in (job.get("vmids") or [])
def sicherungen(bericht: dict, jetzt: float) -> dict:
host = bericht.get("host") or {}
jobs = [j for j in host.get("sicherung_jobs") or [] if j.get("an")]
ziele = host.get("sicherungsziele") or {}
probleme: list[str] = []
for name in sorted({j.get("speicher") for j in jobs if j.get("speicher")}):
z = ziele.get(name) or {}
if not z.get("aktiv"):
probleme.append(f"Das Sicherungsziel {name} ist nicht erreichbar.")
elif z.get("fehler"):
probleme.append(f"Die Sicherungen auf {name} ließen sich nicht lesen.")
erfasst, fehlend, zuletzt = 0, [], None
for g in bericht.get("gaeste") or []:
eigene = [j for j in jobs if _erfasst(j, g["vmid"])]
if not eigene:
continue
erfasst += 1
stand = [((ziele.get(j.get("speicher")) or {}).get("neueste") or {}).get(str(g["vmid"])) for j in eigene]
juengste = max((t for t in stand if t), default=None)
if juengste:
zuletzt = max(zuletzt or 0, juengste)
if not any(t and jetzt - t <= max_alter_s(j.get("zeitplan")) for t, j in zip(stand, eigene, strict=True)):
fehlend.append({"ziel": _zid(g), "name": _name(g), "zuletzt": juengste})
stufe = "keine" if not jobs else "ok"
if fehlend:
stufe = "rot" if len(fehlend) == erfasst else "gelb"
if probleme:
stufe = "rot"
return {"jobs": [{"zeitplan": j.get("zeitplan"), "speicher": j.get("speicher"), "alle": j.get("alle")} for j in jobs],
"ziele": [{"speicher": n, "art": z.get("art"), "aktiv": z.get("aktiv"), "belegt": z.get("belegt"),
"gesamt": z.get("gesamt")} for n, z in sorted(ziele.items())],
"erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe}
# --- Für Oberfläche und Wächter ----------------------------------------------------------------------------------
def lage(jetzt: float | None = None) -> dict:
b = _bericht()
jetzt = time.time() if jetzt is None else jetzt
return {"bericht": bool(b), "pools": pools(b), "nas": nas(b), "sicherungen": sicherungen(b, jetzt),
"empfehlungen": empfehlungen(b)}
def pruefe_speicher(jetzt: float | None = None) -> list:
"""Befunde für den Wächter. Ohne Bericht keine — das Schweigen des Ausführers meldet pruefe_ausfuehrer()."""
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
b = _bericht()
if not b:
return []
jetzt = time.time() if jetzt is None else jetzt
befunde = []
tipps = empfehlungen(b)
platz_tipp = "; ".join(e["kurz"] for e in tipps[:3])
for p in pools(b):
if p["stufe"] == "ok":
continue
anteil = max(p["anteil"], p["meta_anteil"] or 0)
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool"
befunde.append(Befund(
id=f"pool:{p['speicher']}", stufe=p["stufe"],
titel=f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll",
text=(f"Noch {_groesse(p['frei'])} frei. Läuft er voll, stehen alle Gäste darauf still. "
+ (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“."),
quelle="pve"))
for n in nas(b):
if n["stufe"] == "ok":
continue
if n["problem"]:
titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. "
"Strom und Netz des NAS prüfen, dann in dessen Oberfläche nachsehen.")
else:
titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll"
text = (f"Noch {_groesse((n['gesamt'] or 0) - (n['belegt'] or 0))} frei. Die Sicherungen brauchen jede "
"Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder alte Dateien auf dem NAS "
"aufräumen.")
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=n["stufe"], titel=titel, text=text, quelle="nas"))
s = sicherungen(b, jetzt)
if s["stufe"] in ("gelb", "rot"):
teile = list(s["probleme"])
if s["fehlend"]:
namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})"
for f in s["fehlend"][:6])
teile.append(f"Keine frische Sicherung: {namen}.")
befunde.append(Befund(
id="sicherungen", stufe=s["stufe"],
titel=("Sicherungen scheitern" if s["stufe"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät"
+ ("en" if len(s["fehlend"]) != 1 else "")),
text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.",
quelle="pve"))
return befunde
+9 -3
View File
@@ -580,12 +580,16 @@ def pruefe_gast_platten() -> list[Befund]:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
name = app.name if app else str(g.get("name") or g["vmid"])
anteil = belegt / gesamt
# Seit 25.09.2026 kennt der Bericht auch die Platte der VMs (über den Gast-Agenten).
vm = g.get("art") != "lxc"
vergroessern = ("VM → Hardware → Festplatte → Aktion → Größe ändern, danach in der VM das Dateisystem "
"mitwachsen lassen" if vm else "Container → Ressourcen → Laufwerk → Größe ändern")
befunde.append(Befund(
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb",
titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll",
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt (Container {g['vmid']}). Platz schaffen "
"oder in Proxmox die Platte vergrößern: Container → Ressourcen → Laufwerk → Größe ändern."),
quelle=f"ct-{g['vmid']}"))
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']}). "
f"Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."),
quelle=f"{'vm' if vm else 'ct'}-{g['vmid']}"))
return befunde
@@ -599,6 +603,8 @@ PRUEFUNGEN = {
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
# Messwerte des Proxmox-Hosts (seit 24.09.2026): zehn Minuten über 95 % RAM oder 90 °C = gelb.
PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe_host,) if ROLLE == "homelab" else ()
# Speicherpool, NAS und Sicherungen (seit 25.09.2026): gelb ab 80 %, rot bei 10 % frei; Sicherung älter als erlaubt.
PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else ()
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
+241
View File
@@ -0,0 +1,241 @@
"""Speicherpool, NAS und Sicherungen (25.09.2026). Die Zahlen stammen aus dem echten Bericht vom 25.09.:
local-lvm zu 49 % belegt, davon 161 GB die Platte der Arcane-VM, die innen nur 35 GB nutzt (Discard aus)."""
import importlib.util
import math
import subprocess
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from services.homelab import speicher
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
JETZT = 1_790_330_000.0 # 25.09.2026 ~11:13
NACHT = 1_790_296_203 # 25.09.2026 02:30 — die Sicherungen der Nacht
def _ausfuehrer():
spec = importlib.util.spec_from_file_location("ausfuehrer_speicher", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def _bericht(pool_anteil: float = 0.4916, meta: float = 0.0209, nas_anteil: float = 0.3945) -> dict:
gesamt = 374_538_764_288
return {
"host": {
"pools": [{"speicher": "local-lvm", "art": "lvmthin", "aktiv": True, "belegt": math.ceil(gesamt * pool_anteil),
"gesamt": gesamt, "meta_anteil": meta, "verwaist": []}],
"netzlaufwerke": [{"ziel": "/mnt/qnap-backup", "quelle": "192.168.178.62:/Backup", "art": "nfs",
"eingebunden": True, "server": "192.168.178.62", "zustand": "ok",
"platz": {"gesamt": 3_847_503_609_856, "belegt": int(3_847_503_609_856 * nas_anteil)}}],
"sicherung_jobs": [{"id": "j1", "zeitplan": "02:30", "speicher": "pbs-qnap", "an": True, "alle": True,
"vmids": [], "ausgenommen": []}],
"sicherungsziele": {"pbs-qnap": {"art": "pbs", "aktiv": True, "belegt": 1, "gesamt": 2,
"neueste": {"102": NACHT, "104": NACHT, "106": NACHT}}},
},
"gaeste": [
{"vmid": 102, "art": "lxc", "name": "netbird", "app": {"kennung": "netbird"}, "pool_belegt": 2_076_616_687,
"platte": {"belegt": 888_725_504, "gesamt": 4_143_677_440}, "snapshots": [], "ungenutzt": []},
{"vmid": 104, "art": "lxc", "name": "gitea", "app": {"kennung": "gitea"}, "pool_belegt": 6_442_450_944,
"platte": {"belegt": 4_867_121_152, "gesamt": 8_350_298_112}, "snapshots": ["mc2-20260924-192352"],
"ungenutzt": []},
{"vmid": 106, "art": "qemu", "name": "arcane", "pool_belegt": 160_948_530_708, "discard": False,
"platte": {"belegt": 35_454_582_784, "gesamt": 151_205_154_816}, "snapshots": [], "ungenutzt": []},
],
}
# --- Ausführer: reine Auswertungen --------------------------------------------------------------------------------
def test_thin_pool_mit_echter_belegung_je_volume():
a = _ausfuehrer()
status = [{"storage": "local-lvm", "type": "lvmthin", "active": 1, "used": 184_123_256_523, "total": 374_538_764_288},
{"storage": "local", "type": "dir", "active": 1, "used": 1, "total": 2}]
konfig = [{"storage": "local-lvm", "type": "lvmthin", "vgname": "pve", "thinpool": "data"}]
lvs = [{"vg_name": "pve", "lv_name": "data", "lv_size": "374538764288", "data_percent": "49.16",
"metadata_percent": "2.09", "pool_lv": "", "origin": ""},
{"vg_name": "pve", "lv_name": "vm-106-disk-0", "lv_size": "161061273600", "data_percent": "99.93",
"metadata_percent": "", "pool_lv": "data", "origin": ""},
{"vg_name": "pve", "lv_name": "snap_vm-104-disk-0_mc2-20260924-192352", "lv_size": "8589934592",
"data_percent": "", "metadata_percent": "", "pool_lv": "data", "origin": "vm-104-disk-0"},
{"vg_name": "pve", "lv_name": "root", "lv_size": "103079215104", "data_percent": "", "pool_lv": "", "origin": ""}]
pools, zuteilung = a.thin_pools(status, konfig, lvs)
assert pools == [{"speicher": "local-lvm", "art": "lvmthin", "aktiv": True, "belegt": 184_123_256_523,
"gesamt": 374_538_764_288, "meta_anteil": pytest.approx(0.0209), "verwaist": []}]
# Der Snapshot teilt seine Blöcke mit dem Original und zählt nicht mit.
assert zuteilung == {"local-lvm:vm-106-disk-0": int(161_061_273_600 * 0.9993)}
def test_volumes_discard_und_ungenutzte_platten():
a = _ausfuehrer()
assert a.gast_volumes({"rootfs": "local-lvm:vm-105-disk-0,size=10G",
"mp0": "/mnt/qnap-backup/pbs-datastore,mp=/mnt/datastore"}) == (
["local-lvm:vm-105-disk-0"], [], True)
vm = {"scsi0": "local-lvm:vm-106-disk-0,iothread=1,size=150G", "ide2": "none,media=cdrom",
"efidisk0": "local-lvm:vm-106-disk-1,efitype=4m,size=4M", "unused0": "local-lvm:vm-106-disk-2", "cores": 4}
assert a.gast_volumes(vm) == (["local-lvm:vm-106-disk-0", "local-lvm:vm-106-disk-1"], ["local-lvm:vm-106-disk-2"],
False)
vm["scsi0"] += ",discard=on"
assert a.gast_volumes(vm)[2] is True
def test_vm_belegung_ueber_den_gast_agenten():
a = _ausfuehrer()
daten = {"result": [
{"name": "sda2", "type": "ext4", "mountpoint": "/", "used-bytes": 35_000_000_000, "total-bytes": 150_000_000_000},
{"name": "sda2", "type": "ext4", "mountpoint": "/var/snap", "used-bytes": 35_000_000_000,
"total-bytes": 150_000_000_000},
{"name": "sda1", "type": "vfat", "mountpoint": "/boot/efi", "used-bytes": 6_000_000, "total-bytes": 1_000_000_000},
{"name": "overlay", "type": "overlay", "mountpoint": "/var/lib/docker/x", "used-bytes": 9, "total-bytes": 9},
{"name": "loop3", "type": "squashfs", "mountpoint": "/snap/core", "used-bytes": 9, "total-bytes": 9}]}
assert a.fs_belegung(daten) == {"belegt": 35_006_000_000, "gesamt": 151_000_000_000}
assert a.fs_belegung({"result": []}) is None and a.fs_belegung(None) is None
def test_netzlaufwerke_eingebunden_und_fehlend():
a = _ausfuehrer()
findmnt = {"filesystems": [{"target": "/mnt/qnap-backup", "source": "192.168.178.62:/Backup", "fstype": "nfs"}]}
fstab = ("192.168.178.62:/Backup /mnt/qnap-backup nfs vers=3,hard,_netdev 0 0\n"
"//192.168.178.62/Medien /mnt/medien cifs credentials=/root/.smb,_netdev 0 0\n"
"# 192.168.178.9:/alt /mnt/alt nfs defaults 0 0\n"
"192.168.178.62:/Selten /mnt/selten nfs noauto 0 0\n"
"/dev/pve/root / ext4 errors=remount-ro 0 1\n")
assert a.netzlaufwerke(findmnt, fstab) == [
{"ziel": "/mnt/medien", "quelle": "//192.168.178.62/Medien", "art": "cifs", "eingebunden": False,
"server": "192.168.178.62"},
{"ziel": "/mnt/qnap-backup", "quelle": "192.168.178.62:/Backup", "art": "nfs", "eingebunden": True,
"server": "192.168.178.62"}]
def test_sicherungsauftraege_und_juengste_sicherung():
a = _ausfuehrer()
assert a.sicherungs_jobs([{"id": "j1", "schedule": "02:30", "storage": "pbs-qnap", "all": 1, "type": "vzdump"},
{"id": "j2", "schedule": "sat 03:00", "storage": "local", "vmid": "100,101",
"exclude": "", "enabled": 0}]) == [
{"id": "j1", "zeitplan": "02:30", "speicher": "pbs-qnap", "an": True, "alle": True, "vmids": [], "ausgenommen": []},
{"id": "j2", "zeitplan": "sat 03:00", "speicher": "local", "an": False, "alle": False, "vmids": [100, 101],
"ausgenommen": []}]
assert a.neueste_sicherungen([{"vmid": 100, "ctime": 5}, {"vmid": 100, "ctime": 9}, {"vmid": "101", "ctime": 7},
{"volid": "kaputt"}]) == {"100": 9, "101": 7}
def test_haengender_nfs_mount_haelt_den_bericht_nicht_auf(monkeypatch):
a = _ausfuehrer()
monkeypatch.setattr(a, "NETZ_ZEITLIMIT_S", 0.05)
gestartet: list[list[str]] = []
class Haengt:
returncode = None
def __init__(self, befehl, **_):
gestartet.append(befehl)
def poll(self):
return None
def kill(self):
pass # im Zustand D wirkt das nicht — der Prozess bleibt
monkeypatch.setattr(subprocess, "Popen", Haengt)
anfang = time.monotonic()
assert a._netz_platz("/mnt/qnap-backup") == ("haengt", None)
assert a._netz_platz("/mnt/qnap-backup") == ("haengt", None)
assert len(gestartet) == 1 and time.monotonic() - anfang < 2 # kein zweiter Prozess, kein Warten
# --- Homelab-Teil: Auswertung, Empfehlungen, Hinweise ---------------------------------------------------------
def test_pool_stufen_fuer_daten_und_metadaten():
assert speicher.pools(_bericht(0.79))[0]["stufe"] == "ok"
assert speicher.pools(_bericht(0.80))[0]["stufe"] == "gelb"
assert speicher.pools(_bericht(0.90))[0]["stufe"] == "rot" # 10 % frei (User-Vorgabe)
assert speicher.pools(_bericht(0.40, meta=0.91))[0]["stufe"] == "rot"
p = speicher.pools(_bericht())[0]
assert (p["speicher"], p["frei"]) == ("local-lvm", p["gesamt"] - p["belegt"])
def test_empfehlungen_groesster_gewinn_zuerst():
tipps = speicher.empfehlungen(_bericht())
assert [t["art"] for t in tipps] == ["trim-vm", "trim", "snapshot"]
arcane = tipps[0]
assert arcane["platz"] == 160_948_530_708 - 35_454_582_784 and arcane["titel"].startswith("Arcane (Docker): 125 GB")
assert "„Discard“ anhaken" in arcane["text"] and arcane["kurz"].endswith("(Discard einschalten)")
container = tipps[1]
assert container["platz"] == (2_076_616_687 - 888_725_504) + (6_442_450_944 - 4_867_121_152)
assert "pct fstrim 104 --ignore-mountpoints" in container["text"] and "Gitea 1,6 GB" in container["text"]
assert tipps[2]["titel"] == "Gitea: Snapshot mc2-20260924-192352" and "vom 24.09. 19:23" in tipps[2]["text"]
def test_kleine_gewinne_sind_keine_empfehlung():
b = _bericht()
b["gaeste"] = [b["gaeste"][0]] # NetBird allein: 1,2 GB — über der Schwelle, weil ≥ 1 GB
assert [t["art"] for t in speicher.empfehlungen(b)] == ["trim"]
b["gaeste"][0]["pool_belegt"] = b["gaeste"][0]["platte"]["belegt"] + 600_000_000
assert speicher.empfehlungen(b) == []
def test_nas_antwortet_nicht_haengt_oder_ist_voll():
assert speicher.nas(_bericht(), pruefen=lambda s, p: True)[0]["stufe"] == "ok"
weg = speicher.nas(_bericht(), pruefen=lambda s, p: False)[0]
assert (weg["stufe"], weg["problem"]) == ("rot", "Das NAS (192.168.178.62) antwortet nicht.")
b = _bericht()
b["host"]["netzlaufwerke"][0].update(zustand="haengt", platz=None)
assert "hängt" in speicher.nas(b, pruefen=lambda s, p: True)[0]["problem"]
assert speicher.nas(_bericht(nas_anteil=0.85), pruefen=lambda s, p: True)[0]["stufe"] == "gelb"
geprueft = []
speicher.nas(_bericht(), pruefen=lambda s, p: geprueft.append((s, p)) or True)
assert geprueft == [("192.168.178.62", 2049)]
def test_sicherungen_frisch_fehlend_und_ziel_weg():
s = speicher.sicherungen(_bericht(), JETZT)
assert (s["stufe"], s["erfasst"], s["fehlend"], s["zuletzt"]) == ("ok", 3, [], NACHT)
b = _bericht()
b["host"]["sicherungsziele"]["pbs-qnap"]["neueste"]["104"] = NACHT - 3 * 86400
s = speicher.sicherungen(b, JETZT)
assert s["stufe"] == "gelb" and [f["name"] for f in s["fehlend"]] == ["Gitea"]
# Wöchentlicher Auftrag: drei Tage alt ist in Ordnung.
b["host"]["sicherung_jobs"][0]["zeitplan"] = "sat 02:30"
assert speicher.sicherungen(b, JETZT)["stufe"] == "ok"
b = _bericht()
b["host"]["sicherungsziele"]["pbs-qnap"]["neueste"] = {}
assert speicher.sicherungen(b, JETZT)["stufe"] == "rot" # keiner gesichert: Auftrag läuft nicht
b = _bericht()
b["host"]["sicherungsziele"]["pbs-qnap"]["aktiv"] = False
s = speicher.sicherungen(b, JETZT)
assert s["stufe"] == "rot" and s["probleme"] == ["Das Sicherungsziel pbs-qnap ist nicht erreichbar."]
b["host"]["sicherung_jobs"][0]["ausgenommen"] = [102, 104, 106]
assert speicher.sicherungen(b, JETZT)["erfasst"] == 0
b["host"]["sicherung_jobs"] = []
assert speicher.sicherungen(b, JETZT)["stufe"] == "keine"
def test_waechter_befunde(monkeypatch):
monkeypatch.setattr(speicher, "antwortet", lambda s, p: True)
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht())
assert speicher.pruefe_speicher(JETZT) == []
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht(0.91, nas_anteil=0.93))
befunde = {b.id: b for b in speicher.pruefe_speicher(JETZT)}
pool = befunde["pool:local-lvm"]
assert (pool.stufe, pool.titel) == ("rot", "Speicherpool local-lvm zu 91 % voll")
assert "Platz bringt: 125 GB bei Arcane (Docker) per TRIM (Discard einschalten)" in pool.text
assert befunde["nas:/mnt/qnap-backup"].titel == "NAS (192.168.178.62) zu 93 % voll"
monkeypatch.setattr(speicher, "_bericht", dict)
assert speicher.pruefe_speicher(JETZT) == []
def test_schnittstelle(monkeypatch):
from routers import homelab
monkeypatch.setattr(speicher, "antwortet", lambda s, p: True)
monkeypatch.setattr(speicher, "_bericht", lambda: _bericht())
app = FastAPI()
app.include_router(homelab.router)
daten = TestClient(app).get("/api/homelab/speicher").json()
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["empfehlungen"][0]["art"] == "trim-vm"
+1 -1
View File
@@ -220,7 +220,7 @@ from services import waechter
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
""")
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host"]
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
+24
View File
@@ -76,6 +76,18 @@ laufende_jobs() {
2>/dev/null || echo 0
}
# Läuft im Homelab ein Update oder „Alle aktualisieren“? Schritt 8 startet den Homelab-Teil neu, und das bräche den
# Lauf mittendrin ab (seit 25.09.2026 geprüft). Ist der Homelab-Teil nicht zu erreichen: 0 — dann gibt es nichts
# abzubrechen, und Schritt 8 meldet selbst, dass er nicht nachziehen konnte.
homelab_beschaeftigt() {
if curl -sf -m 5 "$API/api/partner/homelab/laeufe" "$API/api/partner/homelab/sammellauf" 2>/dev/null \
| grep -q '"status": *"laeuft"'; then
echo 1
else
echo 0
fi
}
stufe1() {
exec 9>"${XDG_RUNTIME_DIR}/mc2-deploy.lock"
flock -n 9 || { echo "Es läuft schon ein Deploy."; exit 1; }
@@ -84,6 +96,10 @@ stufe1() {
echo "Es läuft ein Update-Auftrag. Deploy verschoben — später erneut, oder MC_DEPLOY_TROTZDEM=1."
exit 1
fi
if [ "$(homelab_beschaeftigt)" = "1" ] && [ "${MC_DEPLOY_TROTZDEM:-0}" != "1" ]; then
echo "Im Homelab läuft gerade ein Update. Deploy verschoben — später erneut, oder MC_DEPLOY_TROTZDEM=1."
exit 1
fi
local vorher; vorher="$(git rev-parse HEAD)"
echo "Starte Deploy (vorher ${vorher:0:7})…"
git fetch -q origin main
@@ -233,6 +249,14 @@ homelab_mitziehen() {
[ "${MC_DEPLOY_OHNE_HOMELAB:-0}" = "1" ] && return 0
ziel="$(grep -o 'MC_PARTNER_URL=http://[0-9.]*' "$conf" 2>/dev/null | head -n 1 | cut -d/ -f3)"
[ -n "$ziel" ] || return 0
if [ "$(homelab_beschaeftigt)" = "1" ]; then
# Erst während der Prüfungen gestartet: nicht mittendrin neu starten. Die Box ist schon live; nachziehen,
# wenn der Lauf fertig ist (deploy.sh noch einmal).
echo "✗ Im Homelab läuft gerade ein Update — der Homelab-Teil ($ziel) wird nicht nachgezogen."
bash deploy/notify.sh -s "[Deploy]" \
"Die Box ist auf dem neuen Stand; den Homelab-Teil ($ziel) nicht nachgezogen, weil dort gerade ein Update lief. Nach dem Lauf deploy.sh noch einmal." || true
return 0
fi
echo "Ziehe den Homelab-Teil ($ziel) nach…"
if bash deploy/homelab/ausrollen.sh "$ziel" > /tmp/mc2-homelab-ausrollen.log 2>&1; then
echo "✅ Homelab-Teil ($ziel) hat denselben Stand."
+7
View File
@@ -15,6 +15,13 @@ NUR_LESEN=false
[ "${2:-}" = "--nur-lesen" ] && NUR_LESEN=true
PVE="${MC_PVE:-pve}"
# Nicht mitten in ein Update: Der Neustart des Ausführers bräche einen laufenden Auftrag ab (apt in einem Gast!).
if [ "${MC_TROTZDEM:-0}" != "1" ] && curl -sf -m 5 "http://$IP:9001/api/homelab/laeufe" \
"http://$IP:9001/api/homelab/sammellauf" 2>/dev/null | grep -q '"status": *"laeuft"'; then
echo "Im Homelab läuft gerade ein Update — Ausführer später einrichten (oder MC_TROTZDEM=1)." >&2
exit 1
fi
ssh -o BatchMode=yes "$PVE" "install -d -m 755 /usr/local/lib/mc2"
ssh -o BatchMode=yes "$PVE" "cat > /usr/local/lib/mc2/ausfuehrer.py && chmod 755 /usr/local/lib/mc2/ausfuehrer.py" \
< deploy/homelab/ausfuehrer.py
+243
View File
@@ -19,6 +19,9 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
• Messwerte jede Minute (seit 24.09.2026) in einem eigenen Faden, unabhängig vom Bericht und von
Aufträgen: Host aus /proc und /sys, Gäste aus einem einzigen pvesh-Aufruf. Nur lesend;
Fehler bleiben still.
• Speicher im Bericht (seit 25.09.2026): Thin-Pools mit der echten Belegung je Volume, Netzlaufwerke
(NAS; ein hängender NFS-Mount hält den Bericht nicht auf), Sicherungsaufträge und je Gast die
jüngste Sicherung auf deren Ziel; bei VMs die Belegung innen über den Gast-Agenten.
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false}
@@ -349,6 +352,233 @@ def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]:
return eigene_sicherungen(_pvesh(f"/nodes/{NODE}/storage/{speicher}/content", *parameter), vmid)
# --- Speicher: Thin-Pool, NAS, Sicherungen (seit 25.09.2026; reine Auswertungen, ohne Host testbar) --------------
PLATTEN_SCHLUESSEL = re.compile(r"rootfs|mp\d+|scsi\d+|virtio\d+|sata\d+|ide\d+|efidisk\d+|tpmstate\d+")
VM_PLATTE = re.compile(r"(?:scsi|virtio|sata|ide)\d+")
ECHTE_DATEISYSTEME = {"ext2", "ext3", "ext4", "xfs", "btrfs", "f2fs", "vfat", "ntfs"}
NETZ_ARTEN = {"nfs", "nfs4", "cifs", "smb3"}
NETZ_ZEITLIMIT_S = 10
def _prozent(wert: object) -> float | None:
try:
return float(str(wert).strip()) / 100
except ValueError:
return None
def thin_pools(status: list[dict], konfig: list[dict], lvs: list[dict]) -> tuple[list[dict], dict[str, int]]:
"""Die Thin-Pools des Hosts (Füllstand von Daten und Metadaten) und je Volume, wie viel davon im Pool wirklich
belegt ist — Proxmox zeigt nur die Nenngröße. Rückgabe: (Pools, {volid: belegte Bytes}). Snapshot-Volumes
(origin gesetzt) zählen nicht mit: Ihre Blöcke teilen sie mit dem Original."""
ort = {k["storage"]: (k.get("vgname"), k.get("thinpool")) for k in konfig
if isinstance(k, dict) and k.get("type") == "lvmthin" and k.get("storage")}
pools: list[dict] = []
zuteilung: dict[str, int] = {}
for s in status:
if s.get("type") != "lvmthin" or s.get("storage") not in ort:
continue
vg, thin = ort[s["storage"]]
pool = {"speicher": s["storage"], "art": "lvmthin", "aktiv": bool(s.get("active")),
"belegt": int(s.get("used") or 0), "gesamt": int(s.get("total") or 0), "meta_anteil": None,
"verwaist": []}
for lv in lvs:
if lv.get("vg_name") != vg:
continue
if lv.get("lv_name") == thin:
pool["meta_anteil"] = _prozent(lv.get("metadata_percent"))
elif lv.get("pool_lv") == thin and not lv.get("origin"):
anteil = _prozent(lv.get("data_percent"))
if anteil is not None and str(lv.get("lv_size") or "").isdigit():
zuteilung[f"{s['storage']}:{lv['lv_name']}"] = int(int(lv["lv_size"]) * anteil)
pools.append(pool)
return pools, zuteilung
def gast_volumes(konfig: dict) -> tuple[list[str], list[str], bool]:
"""Die Volumes eines Gasts (ohne Bind-Mounts und CD-Laufwerke), die ungenutzten (unusedN) und ob an jeder
VM-Platte Discard an ist — nur dann kommt Platz, den die VM per TRIM freigibt, im Pool an."""
volumes: list[str] = []
ungenutzt: list[str] = []
discard = True
for schluessel, wert in konfig.items():
text = str(wert)
volid = text.split(",")[0]
if re.fullmatch(r"unused\d+", schluessel):
ungenutzt.append(volid)
continue
if (not PLATTEN_SCHLUESSEL.fullmatch(schluessel) or ":" not in volid or volid.startswith("/")
or "media=cdrom" in text):
continue
volumes.append(volid)
if VM_PLATTE.fullmatch(schluessel) and "discard=on" not in text:
discard = False
return volumes, ungenutzt, discard
def fs_belegung(daten: object) -> dict | None:
"""Belegung der Dateisysteme in einer VM laut Gast-Agent (get-fsinfo), jedes Gerät einmal."""
gesehen: set[str] = set()
belegt = gesamt = 0
for fs in (daten.get("result") or []) if isinstance(daten, dict) else []:
if not isinstance(fs, dict) or fs.get("type") not in ECHTE_DATEISYSTEME or fs.get("total-bytes") is None:
continue
if fs.get("name") in gesehen:
continue
gesehen.add(fs.get("name"))
belegt += int(fs.get("used-bytes") or 0)
gesamt += int(fs["total-bytes"])
return {"belegt": belegt, "gesamt": gesamt} if gesamt else None
def _server(quelle: object) -> str | None:
"""„192.168.178.62:/Backup“ (NFS) oder „//nas/freigabe“ (SMB) → der Rechner."""
q = str(quelle or "")
if q.startswith("//"):
return q[2:].split("/")[0] or None
if ":" not in q:
return None
return q.split(":")[0] or None
def netzlaufwerke(findmnt: object, fstab: str) -> list[dict]:
"""Netzlaufwerke des Hosts: eingebunden laut findmnt, dazu jedes aus /etc/fstab, das es sein sollte (ohne noauto)."""
da: dict[str, dict] = {}
for fs in (findmnt.get("filesystems") or []) if isinstance(findmnt, dict) else []:
if isinstance(fs, dict) and fs.get("fstype") in NETZ_ARTEN and fs.get("target"):
da[fs["target"]] = {"ziel": fs["target"], "quelle": fs.get("source"), "art": fs["fstype"],
"eingebunden": True}
for zeile in fstab.splitlines():
teile = zeile.split()
if len(teile) < 3 or teile[0].startswith("#") or teile[2] not in NETZ_ARTEN:
continue
if "noauto" in (teile[3] if len(teile) > 3 else "").split(","):
continue
da.setdefault(teile[1], {"ziel": teile[1], "quelle": teile[0], "art": teile[2], "eingebunden": False})
for m in da.values():
m["server"] = _server(m["quelle"])
return sorted(da.values(), key=lambda m: m["ziel"])
def _zahlen(wert: object) -> list[int]:
return [int(x) for x in re.findall(r"\d+", str(wert or ""))]
def sicherungs_jobs(jobs: object) -> list[dict]:
"""Die Sicherungsaufträge des Clusters (Rechenzentrum → Backup)."""
ergebnis = []
for j in jobs if isinstance(jobs, list) else []:
if not isinstance(j, dict) or j.get("type", "vzdump") != "vzdump":
continue
ergebnis.append({"id": j.get("id"), "zeitplan": j.get("schedule") or j.get("starttime"),
"speicher": j.get("storage"), "an": str(j.get("enabled", 1)) not in ("0", "false"),
"alle": str(j.get("all", 0)) in ("1", "true"), "vmids": _zahlen(j.get("vmid")),
"ausgenommen": _zahlen(j.get("exclude"))})
return ergebnis
def neueste_sicherungen(inhalt: object) -> dict[str, int]:
"""Je Gast die jüngste Sicherung auf einem Speicher (Unix-Zeit)."""
neu: dict[str, int] = {}
for e in inhalt if isinstance(inhalt, list) else []:
try:
vmid, zeit = str(int(e["vmid"])), int(e["ctime"])
except (KeyError, TypeError, ValueError):
continue
neu[vmid] = max(zeit, neu.get(vmid, 0))
return neu
_haengend: dict[str, subprocess.Popen] = {}
def _netz_platz(ziel: str) -> tuple[str, dict | None]:
"""df auf ein Netzlaufwerk, ohne mitzuhängen: Ein harter NFS-Mount blockiert jeden Zugriff, solange das NAS fehlt —
auch das Beenden des Prozesses. Der bleibt dann zurück (ein zweiter kommt nicht dazu), und es heißt „haengt“."""
alt = _haengend.get(ziel)
if alt is not None and alt.poll() is None:
return "haengt", None
_haengend.pop(ziel, None)
try:
prozess = subprocess.Popen(["df", "-B1", "--output=size,used", ziel], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, errors="replace")
except OSError:
return "fehler", None
ende = time.monotonic() + NETZ_ZEITLIMIT_S
while prozess.poll() is None:
if time.monotonic() >= ende:
try:
prozess.kill()
except OSError:
pass
_haengend[ziel] = prozess
return "haengt", None
time.sleep(0.2)
aus, _ = prozess.communicate()
zahlen = aus.split()[-2:] if prozess.returncode == 0 else []
if len(zahlen) == 2 and all(z.isdigit() for z in zahlen):
return "ok", {"gesamt": int(zahlen[0]), "belegt": int(zahlen[1])}
return "fehler", None
def _lvs() -> list[dict]:
code, text = _laufen(["lvs", "--reportformat", "json", "--units", "b", "--nosuffix", "-o",
"vg_name,lv_name,lv_size,data_percent,metadata_percent,pool_lv,origin"], 30)
if code != 0:
raise RuntimeError(f"lvs: {text[:200]}")
return json.loads(text)["report"][0]["lv"]
def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict:
"""Pools, Netzlaufwerke und Sicherungen für den Bericht. Jeder Teil für sich: Scheitert einer, fehlt nur er."""
lage: dict = {}
try:
pools, zuteilung = thin_pools(speicher, _pvesh("/storage") or [], _lvs())
vmids = {g["vmid"] for g in gaeste}
for g in gaeste:
eigene = [v for v in g.get("volumes") or [] if v in zuteilung]
if eigene:
g["pool_belegt"] = sum(zuteilung[v] for v in eigene)
for u in g.get("ungenutzt") or []:
u["belegt"] = zuteilung.get(u["volume"])
for p in pools:
for volid, belegt in sorted(zuteilung.items()):
m = re.match(rf"{re.escape(p['speicher'])}:(?:vm|base)-(\d+)-", volid)
if m and int(m.group(1)) not in vmids:
p["verwaist"].append({"volume": volid, "belegt": belegt})
lage["pools"] = pools
except Exception as exc:
lage["pools_fehler"] = str(exc)[:200]
try:
_, findmnt = _laufen(["findmnt", "-J", "-l", "-t", ",".join(sorted(NETZ_ARTEN)), "-o", "TARGET,SOURCE,FSTYPE"], 10)
laufwerke = netzlaufwerke(json.loads(findmnt) if findmnt.strip().startswith("{") else {}, _lesen("/etc/fstab"))
for m in laufwerke:
if m["eingebunden"]:
m["zustand"], m["platz"] = _netz_platz(m["ziel"])
lage["netzlaufwerke"] = laufwerke
except Exception as exc:
lage["netz_fehler"] = str(exc)[:200]
try:
jobs = sicherungs_jobs(_pvesh("/cluster/backup"))
lage["sicherung_jobs"] = jobs
ziele = {}
for name in sorted({j["speicher"] for j in jobs if j["an"] and j["speicher"]}):
s = next((x for x in speicher if x.get("storage") == name), {})
ziel = {"art": s.get("type"), "aktiv": bool(s.get("active")), "belegt": s.get("used"),
"gesamt": s.get("total"), "neueste": {}}
try:
ziel["neueste"] = neueste_sicherungen(
_pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup"))
except Exception as exc:
ziel["fehler"] = str(exc)[:200]
ziele[name] = ziel
lage["sicherungsziele"] = ziele
except Exception as exc:
lage["sicherung_fehler"] = str(exc)[:200]
return lage
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[str]]) -> dict:
art, vmid = eintrag["type"], int(eintrag["vmid"])
etiketten = _etiketten(eintrag.get("tags"))
@@ -365,6 +595,18 @@ def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: di
gast["fehler"] = str(exc)[:200]
return gast
gast["onboot"] = bool(konfig.get("onboot"))
volumes, ungenutzt, discard = gast_volumes(konfig)
gast["volumes"] = volumes
gast["ungenutzt"] = [{"volume": v, "belegt": None} for v in ungenutzt]
if art != "lxc":
gast["discard"] = discard
agent = str(konfig.get("agent") or "0")
if gast["status"] == "running" and agent.split(",")[0] in ("1", "enabled=1"):
try:
# Wie voll die VM innen ist, weiß nur der Gast-Agent (Proxmox sieht nur die Nenngröße der Platte).
gast["platte"] = fs_belegung(_pvesh(f"/nodes/{NODE}/qemu/{vmid}/agent/get-fsinfo"))
except Exception:
pass
gast["snapshot_moeglich"], gast["snapshot_grund"] = snapshot_moeglich(konfig, arten)
if art == "lxc" and not gast["snapshot_moeglich"]:
# Kein Snapshot: Geht wenigstens eine Sicherung (Speicher da, genug Platz)?
@@ -415,6 +657,7 @@ def bericht() -> dict:
gaeste.append(_gast(eintrag, arten, sicherung, sicherungen))
host = _host()
host["sicherung"] = sicherung
host.update(_speicherlage(speicher, gaeste))
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host,
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
+12
View File
@@ -338,6 +338,18 @@ flowchart LR
Platten der laufenden Container (ab 80 % gelb, ab 90 % rot — ext4 hält 5 % für root zurück; der Ausführer schickt `platte` mit), das
wöchentliche Suchen (gelb, wenn es wiederholt scheitert) und seit 24.09. den Proxmox-Host selbst aus den Messwerten
(gelb, wenn er 10 Minuten lang über 95 % RAM oder 90 °C CPU liegt).
- **Speicher und Sicherungen** (`speicher.py`, `GET /api/homelab/speicher`, seit 25.09.): Der Bericht des Ausführers
enthält dafür `host.pools` (Thin-Pools: belegt, gesamt, Metadaten-Anteil, verwaiste Volumes), je Gast `volumes`,
`pool_belegt` (echte Belegung im Pool laut `lvs`), `ungenutzt` (unusedN) und bei VMs `discard` und `platte` (über
den Gast-Agenten, `get-fsinfo`), dazu `host.netzlaufwerke` (findmnt + fstab; `df` läuft mit 10 s Zeitlimit in einem
eigenen Prozess, den der Ausführer bei einem hängenden harten NFS-Mount zurücklässt: „haengt“), `host.sicherung_jobs`
(`/cluster/backup`) und `host.sicherungsziele` (je Zielspeicher aktiv, Belegung und die jüngste Sicherung je Gast).
Der Homelab-Teil prüft selbst, ob das NAS auf dem Port seines Dienstes antwortet (NFS 2049, SMB 445). Wächter:
Pool und NAS gelb ab 80 %, rot ab 90 % (User-Vorgabe: bei 10 % frei), Metadaten ebenso; NAS antwortet nicht,
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,
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).
- **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
laufendem Update vor offenen Updates) und eine Lampe je Gerät —, darunter die Hinweise des Homelab-Wächters und
+5
View File
@@ -109,6 +109,11 @@ Werte zu jedem Zeitpunkt; „Werte als Tabelle“ klappt die letzten Punkte als
- **Hinweise** des Homelab-Wächters, falls es welche gibt.
- **Geräte:** wie es jedem Gerät geht — Netz, CPU, Speicher, Platte (ab 80 % bernstein, ab 90 % rot) und wie lange
es schon läuft. Probleme stehen oben. Ein Klick auf den Namen öffnet das Gerät im Monitoring.
- **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
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
Proxmox, größter zuerst; aufgeklappt, sobald der Pool knapp wird. Gelöscht wird nichts von selbst.
## Homelab · Updates
+6
View File
@@ -201,6 +201,12 @@ Log und die dringende Meldung „[Alarm] Deploy".
Schalter: `MC_DEPLOY_TROTZDEM=1` (trotz laufendem Update-Auftrag), `MC_DEPLOY_OHNE_TESTS=1` (Prüftor überspringen, nur im
Notfall), `MC_DEPLOY_SKIP_SWAP_CONFIG=1` (llama-swap-Config nie anfassen).
Läuft im Homelab ein Update oder „Alle aktualisieren“ (`/api/partner/homelab/laeufe` bzw. `…/sammellauf`, Status
`laeuft`), wird der Deploy verschoben (seit 25.09.) — Schritt 8 startet den Homelab-Teil neu und bräche den Lauf ab.
Beginnt ein Lauf erst während der Prüfungen, bleibt die Box live und nur Schritt 8 entfällt (Meldung „[Deploy]“).
`ausfuehrer-einrichten.sh` wartet genauso (`MC_TROTZDEM=1` erzwingt): Ein Neustart des Ausführers bräche einen
laufenden Auftrag ab, etwa apt in einem Gast.
Nicht Teil des Deploys: das Frontend bauen (`frontend/dist` kommt fertig aus Git), die Root-Kopie von `warmup.sh`,
Neustarts von llama-swap und Hermes.
+8
View File
@@ -35,9 +35,17 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“;
laufende und die vorige Datei; die jetzige (ab 09.08.) wird beim ersten Rotieren zur vorigen und fällt 14 Tage
später weg. Danach bleiben rund 300 MB. Volle Gastplatten meldet seit 24.09. der Wächter.
1. **Arcane-VM (106): Discard einschalten (User).** Ihre Platte belegt im Speicherpool 161 GB, genutzt sind darin
35 GB (25.09.): Discard ist aus, freigegebener Platz kommt nie im Pool an. In Proxmox: VM 106 → Hardware →
Festplatte → „Discard“ anhaken, VM einmal neu starten (Docker-Dienste kurz weg); danach gibt Ubuntu freien Platz
wöchentlich zurück. Steht als Empfehlung in der Karte „Speicher und Sicherungen“; nötig erst, wenn der Pool knapp
wird (heute 49 %).
Erledigt am 25.09.: Arcane-API-Schlüssel (vom User eingetragen, Docker-Updates echt), feste IP 192.168.178.31 für
Container 107 (Fritzbox, User), Etikett `watcher` für die Arcane-VM 106 (User-Ja; der Ausführer legt vor echten
Docker-Updates einen Snapshot an und geht bei Rot zurück). OS-Updates der VM über den Ausführer gibt es weiter nicht.
Überwachung von Speicherpool, NAS und Sicherungen (User-Wunsch; Karte im Homelab-Cockpit, Hinweise ab 80 %, rot bei
10 % frei). Deploy und Ausführer-Einrichtung warten, solange im Homelab ein Update läuft.
## Offene Einzelpunkte (Box-Wart)
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{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{At as a,Et as o,I as s,Xt as c,tt as l,zt as u}from"./index-C0aLfVi8.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-BOT7swIA.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=l(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(),[l,_]=(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 u(`/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: ${a(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:l===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":l===e.unit,onClick:()=>_(l===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`})]}),l===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-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};
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-C0aLfVi8.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-BOT7swIA.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-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};
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{a as n,i as r,n as i,r as a,t as o}from"./sheet-BOT7swIA.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-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};
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{Gt as e}from"./index-C0aLfVi8.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-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};
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
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Gt as e}from"./index-C0aLfVi8.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-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};
@@ -1 +1 @@
import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Bt as i}from"./index-C0aLfVi8.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-Cqf1HmXO.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-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};
+2 -2
View File
@@ -9,11 +9,11 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-C0aLfVi8.js"></script>
<script type="module" crossorigin src="/assets/index-COh3_SSc.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js">
<link rel="stylesheet" crossorigin href="/assets/index-BAuTgr4Q.css">
<link rel="stylesheet" crossorigin href="/assets/index-CXja6cgU.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,70 @@
import { render, screen, within } from "@testing-library/react"
import { auftraegeText, groesse, zeitplanText } from "@/lib/speicher"
import type { HomelabSpeicher } from "@/lib/typen"
import { Speicher } from "./Speicher"
// Die Zahlen vom 25.09.2026: local-lvm zu 49 %, das NAS (QNAP per NFS) zu 39 %, alle 8 Geräte heute Nacht gesichert.
const daten: HomelabSpeicher = {
bericht: true,
pools: [{ speicher: "local-lvm", belegt: 184_123_256_523, gesamt: 374_538_764_288, frei: 190_415_507_765,
anteil: 0.4916, meta_anteil: 0.0209, aktiv: true, stufe: "ok" }],
nas: [{ server: "192.168.178.62", ziel: "/mnt/qnap-backup", quelle: "192.168.178.62:/Backup", art: "nfs",
eingebunden: true, zustand: "ok", erreichbar: true, belegt: 1_517_685_571_584, gesamt: 3_847_503_609_856,
anteil: 0.3945, stufe: "ok", problem: null }],
sicherungen: { jobs: [{ zeitplan: "02:30", speicher: "pbs-qnap", alle: true }], ziele: [], erfasst: 8, fehlend: [],
zuletzt: 1_790_296_485, probleme: [], stufe: "ok" },
empfehlungen: [
{ art: "trim-vm", titel: "Arcane (Docker): 125 GB an den Pool zurückgeben", platz: 125_493_947_924,
kurz: "125 GB bei Arcane (Docker) per TRIM (Discard einschalten)",
text: "Im Pool belegt die VM 160,9 GB, genutzt sind darin 35,5 GB. Discard ist aus …" },
{ art: "snapshot", titel: "Gitea: Snapshot mc2-20260924-192352", platz: null, kurz: "Snapshot bei Gitea",
text: "Rückweg des Updates vom 24.09. 19:23. …" },
],
}
describe("Speicher", () => {
it("zeigt Pool, NAS und Sicherungen mit Füllstand", () => {
render(<Speicher daten={daten} />)
expect(screen.getByRole("meter", { name: "Speicherpool local-lvm: 49 % belegt" })).toBeInTheDocument()
expect(screen.getByText(/184 GB von 375 GB/)).toBeInTheDocument()
expect(screen.getByText("190 GB frei")).toBeInTheDocument()
expect(screen.getByText("NAS 192.168.178.62")).toBeInTheDocument()
expect(screen.getByText(/1,5 TB von 3,8 TB · 2,3 TB frei/)).toBeInTheDocument()
expect(screen.getByText("täglich 02:30 auf pbs-qnap")).toBeInTheDocument()
expect(screen.getByText(/Alle 8 Geräte gesichert/)).toBeInTheDocument()
})
it("klappt die Vorschläge erst auf, wenn der Pool knapp wird", () => {
const { container, rerender } = render(<Speicher daten={daten} />)
const klappe = container.querySelector("details")!
expect(klappe.open).toBe(false)
expect(within(klappe).getByText("2 Vorschläge · bis zu 125 GB")).toBeInTheDocument()
expect(within(klappe).getByText("≈ 125 GB")).toBeInTheDocument()
expect(within(klappe).getByText("Größe unbekannt")).toBeInTheDocument()
rerender(<Speicher daten={{ ...daten, pools: [{ ...daten.pools[0], anteil: 0.91, stufe: "rot" }] }} />)
expect(container.querySelector("details")!.open).toBe(true)
expect(screen.getByText("fast voll")).toBeInTheDocument()
})
it("nennt Störungen des NAS und fehlende Sicherungen", () => {
render(<Speicher daten={{
...daten,
nas: [{ ...daten.nas[0], erreichbar: false, stufe: "rot", problem: "Das NAS (192.168.178.62) antwortet nicht." }],
sicherungen: { ...daten.sicherungen, stufe: "gelb", fehlend: [{ ziel: "ct-104", name: "Gitea", zuletzt: null }] },
}} />)
expect(screen.getByText("Das NAS (192.168.178.62) antwortet nicht.")).toBeInTheDocument()
expect(screen.getByText("gestört")).toBeInTheDocument()
expect(screen.getByText("Gitea: noch nie gesichert")).toBeInTheDocument()
})
})
describe("Speicher-Texte", () => {
it("rundet Größen und liest den Zeitplan", () => {
expect(groesse(5_200_000_000)).toBe("5,2 GB")
expect(groesse(184_123_256_523)).toBe("184 GB")
expect(groesse(3_847_503_609_856)).toBe("3,8 TB")
expect(zeitplanText("02:30")).toBe("täglich 02:30")
expect(zeitplanText("sat 03:00")).toBe("Zeitplan „sat 03:00“")
expect(auftraegeText({ ...daten.sicherungen, jobs: [] })).toBe("Kein Sicherungsauftrag in Proxmox")
})
})
@@ -0,0 +1,168 @@
import type { ReactNode } from "react"
import { ChevronRight } from "lucide-react"
import { cn } from "cn"
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher"
import type { HomelabSpeicher, NasStand, SicherungsLage, SpeicherPool, SpeicherStufe } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const CHIP: Record<SpeicherStufe | "keine", ChipArt> = { ok: "gruen", gelb: "bernstein", rot: "rot", keine: "grau" }
const FARBE: Record<SpeicherStufe, string> = { ok: "var(--reihe-1)", gelb: "var(--bernstein)", rot: "var(--rot)" }
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
/** Füllstand mit Marken bei 80 % (gelb) und 90 % (rot, 10 % frei). */
function Fuellstand({ anteil, stufe, name }: { anteil: number; stufe: SpeicherStufe; name: string }) {
const prozent = Math.round(anteil * 100)
return (
<div
role="meter"
aria-label={`${name}: ${prozent} % belegt`}
aria-valuenow={prozent}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-2 w-full overflow-hidden rounded-full bg-erhaben"
>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, prozent))}%`, background: FARBE[stufe] }} />
<span aria-hidden className="absolute inset-y-0 left-[80%] w-px bg-bernstein/70" />
<span aria-hidden className="absolute inset-y-0 left-[90%] w-px bg-rot/80" />
</div>
)
}
function Kachel({ titel, unter, chip, chipText, children }: {
titel: string
unter?: string
chip: ChipArt
chipText: string
children: ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie bg-erhaben/40 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{titel}</span>
{unter && <span className="text-sm break-words text-text-3">{unter}</span>}
</div>
<Chip art={chip}>{chipText}</Chip>
</div>
{children}
</div>
)
}
function PoolKachel({ p }: { p: SpeicherPool }) {
const prozent = Math.round(p.anteil * 100)
return (
<Kachel titel={`Speicherpool ${p.speicher}`} unter="Platten der Container und VMs" chip={CHIP[p.stufe]} chipText={STUFE_TEXT[p.stufe]}>
<Fuellstand anteil={Math.max(p.anteil, p.meta_anteil ?? 0)} stufe={p.stufe} name={`Speicherpool ${p.speicher}`} />
<p className="ziffern text-sm text-text-2">
{prozent} % · {groesse(p.belegt)} von {groesse(p.gesamt)} · <span className={cn(p.stufe !== "ok" && "text-foreground")}>{groesse(p.frei)} frei</span>
</p>
{p.meta_anteil !== null && (
<p className="text-xs text-text-3">Metadaten {Math.round(p.meta_anteil * 100)} % belegt</p>
)}
</Kachel>
)
}
function NasKachel({ n }: { n: NasStand }) {
const chipText = n.problem ? (n.stufe === "rot" ? "gestört" : "unklar") : STUFE_TEXT[n.stufe]
return (
<Kachel
titel={`NAS ${n.server ?? ""}`.trim()}
unter={`${n.art.toUpperCase()} · ${n.quelle ?? "?"} → ${n.ziel}`}
chip={CHIP[n.stufe]}
chipText={chipText}
>
{n.problem ? (
<p className={cn("text-sm", n.stufe === "rot" ? "text-rot-text" : "text-bernstein")}>{n.problem}</p>
) : (
n.anteil !== null && (
<>
<Fuellstand anteil={n.anteil} stufe={n.stufe} name={`NAS ${n.server ?? ""}`} />
<p className="ziffern text-sm text-text-2">
{Math.round(n.anteil * 100)} % · {groesse(n.belegt)} von {groesse(n.gesamt)} · {groesse((n.gesamt ?? 0) - (n.belegt ?? 0))} frei
</p>
</>
)
)}
<p className="text-xs text-text-3">
{n.erreichbar === false ? "antwortet nicht" : "antwortet"} · {n.eingebunden ? "auf dem Proxmox-Host eingebunden" : "nicht eingebunden"}
</p>
</Kachel>
)
}
function SicherungsKachel({ s }: { s: SicherungsLage }) {
const chipText = { ok: "aktuell", gelb: "Lücke", rot: "gestört", keine: "kein Auftrag" }[s.stufe]
return (
<Kachel titel="Sicherungen" unter={auftraegeText(s)} chip={CHIP[s.stufe]} chipText={chipText}>
{s.probleme.map((p) => (
<p key={p} className="text-sm text-rot-text">{p}</p>
))}
{s.stufe === "ok" && (
<p className="text-sm text-text-2">
Alle {s.erfasst} Geräte gesichert{s.zuletzt ? <>, zuletzt <span className="ziffern">{zeitAus(s.zuletzt)}</span></> : null}.
</p>
)}
{s.fehlend.length > 0 && (
<ul className="flex flex-col gap-1 text-sm">
{s.fehlend.map((f) => (
<li key={f.ziel} className="text-bernstein">
{f.name}: {f.zuletzt ? <>zuletzt <span className="ziffern">{zeitAus(f.zuletzt)}</span></> : "noch nie gesichert"}
</li>
))}
</ul>
)}
</Kachel>
)
}
/**
* Speicher und Sicherungen des Homelabs: Speicherpool, NAS und die nächtlichen Sicherungen, darunter was im Pool
* Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %).
*/
export function Speicher({ daten }: { daten: HomelabSpeicher }) {
const knapp = daten.pools.some((p) => p.stufe !== "ok")
const summe = daten.empfehlungen.reduce((n, e) => n + (e.platz ?? 0), 0)
return (
<div className="flex flex-col gap-4">
<div className="grid gap-3 md:grid-cols-3">
{daten.pools.map((p) => <PoolKachel key={p.speicher} p={p} />)}
{daten.nas.length > 0 ? (
daten.nas.map((n) => <NasKachel key={n.ziel} n={n} />)
) : (
<Kachel titel="NAS" chip="grau" chipText="keins">
<p className="text-sm text-text-3">Auf dem Proxmox-Host ist kein Netzlaufwerk eingebunden.</p>
</Kachel>
)}
<SicherungsKachel s={daten.sicherungen} />
</div>
{daten.empfehlungen.length > 0 && (
<details open={knapp} className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
Was im Speicherpool Platz bringt
</span>
<span className="ziffern text-sm text-text-2">
{daten.empfehlungen.length} {daten.empfehlungen.length === 1 ? "Vorschlag" : "Vorschläge"}
{summe > 0 && ` · bis zu ${groesse(summe)}`}
</span>
</summary>
<ol className="flex flex-col">
{daten.empfehlungen.map((e) => (
<li key={e.titel} className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<span className="text-[15px] font-medium break-words">{e.titel}</span>
<span className="ziffern text-sm text-text-2">{e.platz ? `≈ ${groesse(e.platz)}` : "Größe unbekannt"}</span>
</div>
<p className="text-sm break-words text-text-2">{e.text}</p>
</li>
))}
</ol>
</details>
)}
</div>
)
}
+11 -1
View File
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -423,6 +423,16 @@ export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) =>
retry: false,
})
/** Speicherpool, NAS und Sicherungen samt Empfehlungen — der Bericht dahinter kommt alle 10 Minuten. */
export const useHomelabSpeicher = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-speicher"],
queryFn: () => api<HomelabSpeicher>("/api/homelab/speicher"),
enabled: aktiv,
refetchInterval: takt(60_000),
retry: false,
})
export const useHomelabEinstellungen = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-einstellungen"],
+26
View File
@@ -0,0 +1,26 @@
import type { SpeicherStufe, SicherungsLage } from "@/lib/typen"
// Speicher und Sicherungen des Homelabs (seit 25.09.2026): die kleinen Texte der Karte im Homelab-Cockpit.
const komma = (n: number, stellen = 1) => n.toFixed(stellen).replace(".", ",")
/** „184 GB“, „5,2 GB“, „2,3 TB“ — ab 10 GB ohne Nachkommastelle. */
export function groesse(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return "–"
if (bytes >= 1e12) return `${komma(bytes / 1e12)} TB`
return bytes >= 10e9 ? `${Math.round(bytes / 1e9)} GB` : `${komma(bytes / 1e9)} GB`
}
/** Der Zeitplan eines Sicherungsauftrags in Worten: „02:30“ heißt bei Proxmox täglich um 02:30. */
export function zeitplanText(zeitplan: string | null): string {
if (!zeitplan) return "ohne Zeitplan"
return /^\s*\d{1,2}:\d{2}\s*$/.test(zeitplan) ? `täglich ${zeitplan.trim()}` : `Zeitplan „${zeitplan}“`
}
/** „täglich 02:30 auf pbs-qnap“ — alle Aufträge in einer Zeile. */
export function auftraegeText(s: SicherungsLage): string {
if (s.jobs.length === 0) return "Kein Sicherungsauftrag in Proxmox"
return s.jobs.map((j) => `${zeitplanText(j.zeitplan)} auf ${j.speicher ?? "?"}`).join(", ")
}
export const STUFE_TEXT: Record<SpeicherStufe, string> = { ok: "in Ordnung", gelb: "wird knapp", rot: "fast voll" }
+56
View File
@@ -461,3 +461,59 @@ export interface HomelabEinstellungen {
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
}
// --- Speicher und Sicherungen des Homelabs (seit 25.09.2026) -------------------------------------------------
export type SpeicherStufe = "ok" | "gelb" | "rot"
export interface SpeicherPool {
speicher: string
belegt: number
gesamt: number
frei: number
anteil: number
meta_anteil: number | null
aktiv: boolean
stufe: SpeicherStufe
}
export interface NasStand {
server: string | null
ziel: string
quelle: string | null
art: string
eingebunden: boolean
zustand: "ok" | "haengt" | "fehler" | null
erreichbar: boolean | null
belegt: number | null
gesamt: number | null
anteil: number | null
stufe: SpeicherStufe
problem: string | null
}
export interface SicherungsLage {
jobs: { zeitplan: string | null; speicher: string | null; alle: boolean }[]
ziele: { speicher: string; art: string | null; aktiv: boolean | null; belegt: number | null; gesamt: number | null }[]
erfasst: number
fehlend: { ziel: string; name: string; zuletzt: number | null }[]
zuletzt: number | null
probleme: string[]
stufe: SpeicherStufe | "keine"
}
export interface SpeicherEmpfehlung {
art: "trim" | "trim-vm" | "ungenutzt" | "verwaist" | "snapshot"
titel: string
text: string
kurz: string
platz: number | null
}
export interface HomelabSpeicher {
bericht: boolean
pools: SpeicherPool[]
nas: NasStand[]
sicherungen: SicherungsLage
empfehlungen: SpeicherEmpfehlung[]
}
+9 -1
View File
@@ -4,10 +4,12 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Gesundheit } from "@/components/homelab/Gesundheit"
import { Lagebild } from "@/components/homelab/Lagebild"
import { Speicher } from "@/components/homelab/Speicher"
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
import {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useInstanz, usePartner, useSammellauf,
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
useSammellauf,
useZielAktion,
} from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
@@ -187,6 +189,7 @@ export function HomelabSeite() {
const navigate = useNavigate()
const { ersatz, stand, laufend, hinweise } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand)
if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
@@ -220,6 +223,11 @@ export function HomelabSeite() {
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
)}
</Panel>
{speicher.data?.bericht && (
<Panel titel="Speicher und Sicherungen" id="speicher">
<Speicher daten={speicher.data} />
</Panel>
)}
</>
)
}