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:
co-authored by
Claude Opus 5.5
parent
9867affc5a
commit
903fbbfbb9
@@ -126,6 +126,13 @@ def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
|
|||||||
return {"eintraege": protokoll.eintraege(grenze, berichte)}
|
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")
|
@router.get("/einstellungen")
|
||||||
def einstellungen_stand() -> dict:
|
def einstellungen_stand() -> dict:
|
||||||
from services.homelab import einstellungen as homelab_einstellungen
|
from services.homelab import einstellungen as homelab_einstellungen
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -580,12 +580,16 @@ def pruefe_gast_platten() -> list[Befund]:
|
|||||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
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"])
|
name = app.name if app else str(g.get("name") or g["vmid"])
|
||||||
anteil = belegt / gesamt
|
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(
|
befunde.append(Befund(
|
||||||
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb",
|
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",
|
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 "
|
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt ({'VM' if vm else 'Container'} {g['vmid']}). "
|
||||||
"oder in Proxmox die Platte vergrößern: Container → Ressourcen → Laufwerk → Größe ändern."),
|
f"Platz schaffen oder in Proxmox die Platte vergrößern: {vergroessern}."),
|
||||||
quelle=f"ct-{g['vmid']}"))
|
quelle=f"{'vm' if vm else 'ct'}-{g['vmid']}"))
|
||||||
return befunde
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
@@ -599,6 +603,8 @@ PRUEFUNGEN = {
|
|||||||
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
|
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.
|
# 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 ()
|
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]"),
|
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
|
||||||
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
|
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -220,7 +220,7 @@ from services import waechter
|
|||||||
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
|
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",
|
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):
|
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ laufende_jobs() {
|
|||||||
2>/dev/null || echo 0
|
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() {
|
stufe1() {
|
||||||
exec 9>"${XDG_RUNTIME_DIR}/mc2-deploy.lock"
|
exec 9>"${XDG_RUNTIME_DIR}/mc2-deploy.lock"
|
||||||
flock -n 9 || { echo "Es läuft schon ein Deploy."; exit 1; }
|
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."
|
echo "Es läuft ein Update-Auftrag. Deploy verschoben — später erneut, oder MC_DEPLOY_TROTZDEM=1."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)"
|
local vorher; vorher="$(git rev-parse HEAD)"
|
||||||
echo "Starte Deploy (vorher ${vorher:0:7})…"
|
echo "Starte Deploy (vorher ${vorher:0:7})…"
|
||||||
git fetch -q origin main
|
git fetch -q origin main
|
||||||
@@ -233,6 +249,14 @@ homelab_mitziehen() {
|
|||||||
[ "${MC_DEPLOY_OHNE_HOMELAB:-0}" = "1" ] && return 0
|
[ "${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)"
|
ziel="$(grep -o 'MC_PARTNER_URL=http://[0-9.]*' "$conf" 2>/dev/null | head -n 1 | cut -d/ -f3)"
|
||||||
[ -n "$ziel" ] || return 0
|
[ -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…"
|
echo "Ziehe den Homelab-Teil ($ziel) nach…"
|
||||||
if bash deploy/homelab/ausrollen.sh "$ziel" > /tmp/mc2-homelab-ausrollen.log 2>&1; then
|
if bash deploy/homelab/ausrollen.sh "$ziel" > /tmp/mc2-homelab-ausrollen.log 2>&1; then
|
||||||
echo "✅ Homelab-Teil ($ziel) hat denselben Stand."
|
echo "✅ Homelab-Teil ($ziel) hat denselben Stand."
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ NUR_LESEN=false
|
|||||||
[ "${2:-}" = "--nur-lesen" ] && NUR_LESEN=true
|
[ "${2:-}" = "--nur-lesen" ] && NUR_LESEN=true
|
||||||
PVE="${MC_PVE:-pve}"
|
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" "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" \
|
ssh -o BatchMode=yes "$PVE" "cat > /usr/local/lib/mc2/ausfuehrer.py && chmod 755 /usr/local/lib/mc2/ausfuehrer.py" \
|
||||||
< deploy/homelab/ausfuehrer.py
|
< deploy/homelab/ausfuehrer.py
|
||||||
|
|||||||
@@ -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
|
• 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;
|
Aufträgen: Host aus /proc und /sys, Gäste aus einem einzigen pvesh-Aufruf. Nur lesend;
|
||||||
Fehler bleiben still.
|
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
|
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}
|
{"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)
|
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:
|
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[str]]) -> dict:
|
||||||
art, vmid = eintrag["type"], int(eintrag["vmid"])
|
art, vmid = eintrag["type"], int(eintrag["vmid"])
|
||||||
etiketten = _etiketten(eintrag.get("tags"))
|
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]
|
gast["fehler"] = str(exc)[:200]
|
||||||
return gast
|
return gast
|
||||||
gast["onboot"] = bool(konfig.get("onboot"))
|
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)
|
gast["snapshot_moeglich"], gast["snapshot_grund"] = snapshot_moeglich(konfig, arten)
|
||||||
if art == "lxc" and not gast["snapshot_moeglich"]:
|
if art == "lxc" and not gast["snapshot_moeglich"]:
|
||||||
# Kein Snapshot: Geht wenigstens eine Sicherung (Speicher da, genug Platz)?
|
# Kein Snapshot: Geht wenigstens eine Sicherung (Speicher da, genug Platz)?
|
||||||
@@ -415,6 +657,7 @@ def bericht() -> dict:
|
|||||||
gaeste.append(_gast(eintrag, arten, sicherung, sicherungen))
|
gaeste.append(_gast(eintrag, arten, sicherung, sicherungen))
|
||||||
host = _host()
|
host = _host()
|
||||||
host["sicherung"] = sicherung
|
host["sicherung"] = sicherung
|
||||||
|
host.update(_speicherlage(speicher, gaeste))
|
||||||
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host,
|
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host,
|
||||||
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
|
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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).
|
(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/…`):
|
- **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
|
||||||
|
|||||||
@@ -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.
|
- **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
|
- **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.
|
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
|
## Homelab · Updates
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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).
|
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`,
|
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.
|
Neustarts von llama-swap und Hermes.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
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.
|
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)
|
## Offene Einzelpunkte (Box-Wart)
|
||||||
|
|
||||||
|
|||||||
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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};
|
||||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||||
+2
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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};
|
||||||
Vendored
+2
-2
@@ -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-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/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-BAuTgr4Q.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CXja6cgU.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
|
|||||||
import { stromVerbunden } from "./strom"
|
import { stromVerbunden } from "./strom"
|
||||||
import type {
|
import type {
|
||||||
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
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,
|
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||||
ZielStand, Zeitraum,
|
ZielStand, Zeitraum,
|
||||||
} from "./typen"
|
} from "./typen"
|
||||||
@@ -423,6 +423,16 @@ export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) =>
|
|||||||
retry: false,
|
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) =>
|
export const useHomelabEinstellungen = (aktiv: boolean) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ["homelab-einstellungen"],
|
queryKey: ["homelab-einstellungen"],
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -461,3 +461,59 @@ export interface HomelabEinstellungen {
|
|||||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
|||||||
import { Panel } from "@/components/cockpit/Panel"
|
import { Panel } from "@/components/cockpit/Panel"
|
||||||
import { Gesundheit } from "@/components/homelab/Gesundheit"
|
import { Gesundheit } from "@/components/homelab/Gesundheit"
|
||||||
import { Lagebild } from "@/components/homelab/Lagebild"
|
import { Lagebild } from "@/components/homelab/Lagebild"
|
||||||
|
import { Speicher } from "@/components/homelab/Speicher"
|
||||||
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
|
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
|
||||||
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
||||||
import {
|
import {
|
||||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useInstanz, usePartner, useSammellauf,
|
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
|
||||||
|
useSammellauf,
|
||||||
useZielAktion,
|
useZielAktion,
|
||||||
} from "@/lib/abfragen"
|
} from "@/lib/abfragen"
|
||||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
||||||
@@ -187,6 +189,7 @@ export function HomelabSeite() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { ersatz, stand, laufend, hinweise } = useHomelabBereich()
|
const { ersatz, stand, laufend, hinweise } = useHomelabBereich()
|
||||||
const mess = useHomelabMesswerte("1h", !!stand)
|
const mess = useHomelabMesswerte("1h", !!stand)
|
||||||
|
const speicher = useHomelabSpeicher(!!stand)
|
||||||
if (ersatz || !stand) return ersatz
|
if (ersatz || !stand) return ersatz
|
||||||
|
|
||||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
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>
|
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
{speicher.data?.bericht && (
|
||||||
|
<Panel titel="Speicher und Sicherungen" id="speicher">
|
||||||
|
<Speicher daten={speicher.data} />
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user