homelab: Wächter und Geräteliste sehen volle Gastplatten (AdGuard stand bei 100 %)

Der Ausfuehrer schickt die Belegung der rootfs laufender Container mit; ab 85 % gelb, ab 95 % rot, in der
Geraeteliste als eigene Spalte und auf der Lampe des Geraets. Anlass: AdGuard (2 GB) war voll, das
Abfrageprotokoll schrieb nicht mehr und die Paketsuche scheiterte, ohne dass es eine Anzeige sagte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 21:00:44 +02:00
co-authored by Claude Opus 5.5
parent eed36e9764
commit c08a144006
30 changed files with 146 additions and 32 deletions
+1
View File
@@ -49,6 +49,7 @@ class ZielStand:
geprueft: float | None = None # Unix-Sekunden der letzten Prüfung geprueft: float | None = None # Unix-Sekunden der letzten Prüfung
rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup" rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup"
rueckweg_art: str | None = None # Kurzform für die Oberfläche: „snapshot“, „sicherung“ oder „keiner“ rueckweg_art: str | None = None # Kurzform für die Oberfläche: „snapshot“, „sicherung“ oder „keiner“
platte: dict | None = None # {"belegt", "gesamt"} in Bytes, wo bekannt (laufende Container)
def als_dict(self) -> dict: def als_dict(self) -> dict:
daten = asdict(self) daten = asdict(self)
+1 -1
View File
@@ -210,7 +210,7 @@ def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
laeuft = gast.get("status") == "running" laeuft = gast.get("status") == "running"
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab", ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None), erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
rueckweg=_rueckweg(gast), rueckweg_art=_rueckweg_art(gast)) rueckweg=_rueckweg(gast), rueckweg_art=_rueckweg_art(gast), platte=gast.get("platte"))
if not laeuft: if not laeuft:
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt", ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}.")) grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
+32 -1
View File
@@ -558,12 +558,43 @@ def pruefe_gaeste() -> list[Befund]:
for e in inventar.erreichbarkeit() if not e["ok"]] for e in inventar.erreichbarkeit() if not e["ok"]]
GAST_PLATTE_GELB = 0.85
GAST_PLATTE_ROT = 0.95
def _gb_komma(n: float) -> str:
return f"{n / 1e9:.1f}".replace(".", ",")
def pruefe_gast_platten() -> list[Befund]:
"""Homelab: Läuft die Platte eines Containers voll? Am 24.09.2026 stand AdGuard bei 100 %: Das Abfrageprotokoll
schrieb nicht mehr, die Paketsuche scheiterte — und keine Anzeige sagte es."""
from services.homelab import apps, kanal
daten = kanal.bericht()
befunde = []
for g in (daten or {}).get("bericht", {}).get("gaeste") or []:
platte = g.get("platte") or {}
gesamt, belegt = platte.get("gesamt"), platte.get("belegt") or 0
if not gesamt or belegt / gesamt < GAST_PLATTE_GELB:
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
name = app.name if app else str(g.get("name") or g["vmid"])
anteil = belegt / gesamt
befunde.append(Befund(
id=f"gast-platte:{g['vmid']}", stufe="rot" if anteil >= GAST_PLATTE_ROT else "gelb",
titel=f"Platte von {name} zu {min(100, round(anteil * 100))} % voll",
text=(f"{_gb_komma(belegt)} von {_gb_komma(gesamt)} GB belegt (Container {g['vmid']}). Platz schaffen "
"oder in Proxmox die Platte vergrößern: Container → Ressourcen → Laufwerk → Größe ändern."),
quelle=f"ct-{g['vmid']}"))
return befunde
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil # Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
# den Ausführer auf dem Proxmox-Host und seine Gäste. # den Ausführer auf dem Proxmox-Host und seine Gäste.
PRUEFUNGEN = { PRUEFUNGEN = {
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte, "box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung), pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung),
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste), "homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste, pruefe_gast_platten),
}[ROLLE] }[ROLLE]
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 ()
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"), BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
+22
View File
@@ -361,3 +361,25 @@ def test_ausfuehrer_erkennt_beide_formate_von_update():
assert a.app_kennung_aus(alt) == "adguard" assert a.app_kennung_aus(alt) == "adguard"
assert a.app_kennung_aus(neu) == "gitea" assert a.app_kennung_aus(neu) == "gitea"
assert a.app_kennung_aus("") is None assert a.app_kennung_aus("") is None
def test_waechter_und_uebersicht_sehen_volle_gastplatten(daten):
"""24.09.2026: AdGuard stand bei 100 % seiner 2 GB — jetzt sagen es Wächter und Geräteliste."""
from services import waechter
bericht = json.loads(json.dumps(BERICHT))
for g in bericht["gaeste"]:
if g["vmid"] == 100:
g["platte"] = {"belegt": 2_000_000_000, "gesamt": 2_040_000_000}
if g["vmid"] == 104:
g["platte"] = {"belegt": 1_800_000_000, "gesamt": 2_000_000_000}
if g["vmid"] == 101:
g["platte"] = {"belegt": 500_000_000, "gesamt": 2_000_000_000}
kanal.bericht_speichern(bericht)
befunde = {b.id: b for b in waechter.pruefe_gast_platten()}
assert set(befunde) == {"gast-platte:100", "gast-platte:104"}
assert (befunde["gast-platte:100"].stufe, befunde["gast-platte:104"].stufe) == ("rot", "gelb")
assert befunde["gast-platte:100"].titel == "Platte von AdGuard Home zu 98 % voll"
assert "2,0 von 2,0 GB" in befunde["gast-platte:100"].text
z = {z["id"]: z for z in inventar.ziele()["ziele"]}
assert z["ct-100"]["platte"] == {"belegt": 2_000_000_000, "gesamt": 2_040_000_000} and z["pve"]["platte"] is None
+1 -1
View File
@@ -220,7 +220,7 @@ from services import waechter
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)})) 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_paketlisten"] "pruefe_gast_platten", "pruefe_paketlisten"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path): def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
+4
View File
@@ -349,6 +349,10 @@ def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: di
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"), gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"), "etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
"snapshots": _snapshots(art, vmid), "sicherungen": sicherungen.get(vmid, [])} "snapshots": _snapshots(art, vmid), "sicherungen": sicherungen.get(vmid, [])}
if art == "lxc" and gast["status"] == "running" and eintrag.get("maxdisk"):
# Belegung der rootfs (Proxmox liest sie bei laufenden Containern mit). Am 24.09.2026 stand AdGuard bei
# 100 % — das Abfrageprotokoll schrieb nicht mehr, die Paketsuche scheiterte, und niemand sah es.
gast["platte"] = {"belegt": int(eintrag.get("disk") or 0), "gesamt": int(eintrag["maxdisk"])}
try: try:
konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config") konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config")
except Exception as exc: except Exception as exc:
+2 -1
View File
@@ -283,7 +283,8 @@ flowchart LR
(`MC_ARCANE_KEY`, Kopfzeile `X-API-Key`). Docker-Updates laufen zuerst nur als Probelauf (`dryRun`); echt erst (`MC_ARCANE_KEY`, Kopfzeile `X-API-Key`). Docker-Updates laufen zuerst nur als Probelauf (`dryRun`); echt erst
mit `MC_ARCANE_ECHT=1`. Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht beteiligt ist. mit `MC_ARCANE_ECHT=1`. Die Arcane-VM braucht dafür kein Etikett, weil der Ausführer nicht beteiligt ist.
- **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot), - **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot),
jede Weboberfläche der freigegebenen Gäste (außer mitten in ihrem Update-Lauf; das Ergebnis meldet der Lauf) und das jede Weboberfläche der freigegebenen Gäste (außer mitten in ihrem Update-Lauf; das Ergebnis meldet der Lauf), die
Platten der laufenden Container (ab 85 % gelb, ab 95 % rot; der Ausführer schickt `platte` mit) und das
wöchentliche Suchen (gelb, wenn es wiederholt scheitert). wöchentliche Suchen (gelb, wenn es wiederholt scheitert).
- **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
+2 -1
View File
@@ -89,7 +89,8 @@ Wichtige Knöpfe fragen vorher nach. Was du auslöst, bestätigt eine kurze Meld
- **Eine Lampe je Gerät** (Proxmox-Host, die Container, Arcane): grün mit der laufenden Version, cyan mit der neuen - **Eine Lampe je Gerät** (Proxmox-Host, die Container, Arcane): grün mit der laufenden Version, cyan mit der neuen
Version oder der Zahl der Pakete, rot bei „antwortet nicht“. Version oder der Zahl der Pakete, rot bei „antwortet nicht“.
- **Hinweise** des Homelab-Wächters, falls es welche gibt. - **Hinweise** des Homelab-Wächters, falls es welche gibt.
- **Geräte:** je Gerät App-Version, Stand der Pakete und ob es im Netz antwortet. „unklar“ heißt: Die Paketlisten im - **Geräte:** je Gerät App-Version, Stand der Pakete, wie voll die Platte ist (ab 85 % bernstein, ab 95 % rot) und
ob es im Netz antwortet. „unklar“ heißt: Die Paketlisten im
Gast sind alt. Der Homelab-Teil erneuert sie jede Nacht selbst, sobald sie älter als eine Woche sind; mit dem Gast sind alt. Der Homelab-Teil erneuert sie jede Nacht selbst, sobald sie älter als eine Woche sind; mit dem
kleinen Pfeil daneben geht es sofort. kleinen Pfeil daneben geht es sofort.
+6
View File
@@ -26,6 +26,12 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“;
## Offene Einzelpunkte (Homelab) ## Offene Einzelpunkte (Homelab)
0. **‼ AdGuard (Container 100): Platte voll (24.09. abends, User-Entscheidung).** 2 GB rootfs, 100 % belegt:
Das Abfrageprotokoll (`/opt/AdGuardHome/data/querylog.json` 462 MB, dazu `querylog.json.1` 539 MB vom 09.08.)
hat sie gefüllt; seit etwa 17:33 schreibt es nicht mehr, und die Paketsuche scheitert („No space left on
device“). Wege: in AdGuard die Aufbewahrung des Abfrageprotokolls kürzen bzw. es leeren, die alte Datei vom
August löschen oder die Platte vergrößern (Proxmox: Container → Ressourcen → Laufwerk → Größe ändern). Gelöscht
oder vergrößert wird nur mit User-Ja. Seit 24.09. meldet der Wächter volle Gastplatten selbst.
1. **Arcane-API-Schlüssel fehlt.** Ohne ihn zeigt die Arcane-Karte die Docker-Images als „unklar“. Anlegen in Arcane 1. **Arcane-API-Schlüssel fehlt.** Ohne ihn zeigt die Arcane-Karte die Docker-Images als „unklar“. Anlegen in Arcane
(Einstellungen → API-Schlüssel), dann `MC_ARCANE_KEY=…` in `/etc/mc2/homelab.env` im Container 107 und (Einstellungen → API-Schlüssel), dann `MC_ARCANE_KEY=…` in `/etc/mc2/homelab.env` im Container 107 und
`systemctl restart mc2-homelab mc2-homelab-steward`. Docker-Updates bleiben Probelauf bis `MC_ARCANE_ECHT=1`. `systemctl restart mc2-homelab mc2-homelab-steward`. Docker-Updates bleiben Probelauf bis `MC_ARCANE_ECHT=1`.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{c as e,i as t,p as n,s as r,t as i,u as a}from"./button-BheHBEml.js";import{L as o,S as s,bt as c,ft as l,ot as u,rt as d}from"./index-_ndC28sQ.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-CKp82pOA.js";var _=r(),v=n(a(),1),y=e();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=o(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:n}){let r=c(),a=s(),[o,_]=(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 l(`/api/maintenance/restart`,{service:e.unit});t.ok?d(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):d(`fehler`,`${e.name} ließ sich nicht starten: ${u(t)}`),r.invalidateQueries({queryKey:[`dienste`]})}catch(e){d(`fehler`,e.message)}finally{w(null)}}}let E;return E=a.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):a.isError&&!a.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,a.error.message]}):a.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:a.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:t(`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)(i,{variant:o===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":o===e.unit,onClick:()=>_(o===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(i,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(i,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(i,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),o===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&&n(),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{c as e,i as t,p as n,s as r,t as i,u as a}from"./button-BheHBEml.js";import{B as o,Ct as s,T as c,ht as l,lt as u,ot as d}from"./index-BAH01Yfc.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-CKp82pOA.js";var _=r(),v=n(a(),1),y=e();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=o(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:n}){let r=s(),a=c(),[o,_]=(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 l(`/api/maintenance/restart`,{service:e.unit});t.ok?d(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):d(`fehler`,`${e.name} ließ sich nicht starten: ${u(t)}`),r.invalidateQueries({queryKey:[`dienste`]})}catch(e){d(`fehler`,e.message)}finally{w(null)}}}let E;return E=a.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):a.isError&&!a.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,a.error.message]}):a.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:a.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:t(`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)(i,{variant:o===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":o===e.unit,onClick:()=>_(o===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(i,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(i,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(i,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),o===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&&n(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{c as e,s as t}from"./button-BheHBEml.js";import{a as n,o as r}from"./index-_ndC28sQ.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-CKp82pOA.js";var l=t(),u=e();function d(e){let t=(0,l.c)(10),{onDienste:d,onEinstellungen:f,onSchliessen:p}=e,m;t[0]===p?m=t[1]:(m=e=>!e&&p(),t[0]=p,t[1]=m);let h;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=h):h=t[2];let g;t[3]!==d||t[4]!==f||t[5]!==p?(g=(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:[h,(0,u.jsx)(r,{onDienste:d,onEinstellungen:f,onGewaehlt:p})]}),t[3]=d,t[4]=f,t[5]=p,t[6]=g):g=t[6];let _;return t[7]!==m||t[8]!==g?(_=(0,u.jsx)(c,{open:!0,onOpenChange:m,children:g}),t[7]=m,t[8]=g,t[9]=_):_=t[9],_}export{d as MenueSchublade}; import{c as e,s as t}from"./button-BheHBEml.js";import{a as n,o as r}from"./index-BAH01Yfc.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-CKp82pOA.js";var l=t(),u=e();function d(e){let t=(0,l.c)(10),{onDienste:d,onEinstellungen:f,onSchliessen:p}=e,m;t[0]===p?m=t[1]:(m=e=>!e&&p(),t[0]=p,t[1]=m);let h;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=h):h=t[2];let g;t[3]!==d||t[4]!==f||t[5]!==p?(g=(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:[h,(0,u.jsx)(r,{onDienste:d,onEinstellungen:f,onGewaehlt:p})]}),t[3]=d,t[4]=f,t[5]=p,t[6]=g):g=t[6];let _;return t[7]!==m||t[8]!==g?(_=(0,u.jsx)(c,{open:!0,onOpenChange:m,children:g}),t[7]=m,t[8]=g,t[9]=_):_=t[9],_}export{d as MenueSchublade};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,9 +7,9 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Homelab Orchestrator</title> <title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-_ndC28sQ.js"></script> <script type="module" crossorigin src="/assets/index-BAH01Yfc.js"></script>
<link rel="modulepreload" crossorigin href="/assets/button-BheHBEml.js"> <link rel="modulepreload" crossorigin href="/assets/button-BheHBEml.js">
<link rel="stylesheet" crossorigin href="/assets/index-XtuDiTus.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cb8UJMqV.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -3,7 +3,9 @@ import { RefreshCw } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { appBaustein, artText, listenAlter, systemBaustein, versionText } from "@/lib/homelab" import {
appBaustein, artText, listenAlter, PLATTE_GELB, PLATTE_ROT, plattenAnteil, systemBaustein, versionText,
} from "@/lib/homelab"
import type { BausteinStand, ZielAktion, ZielStand } from "@/lib/typen" import type { BausteinStand, ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
@@ -40,6 +42,19 @@ function SystemText({ b }: { b: BausteinStand }) {
) )
} }
/** Belegung der Platte: ruhig bis 85 %, dann bernstein, ab 95 % rot. */
function PlatteZelle({ ziel }: { ziel: ZielStand }) {
const anteil = plattenAnteil(ziel)
if (anteil === null) return <span className="text-text-3">–</span>
const farbe = anteil >= PLATTE_ROT ? "text-rot-text" : anteil >= PLATTE_GELB ? "text-bernstein" : "text-text-2"
const gb = (n: number) => (n / 1e9).toFixed(1).replace(".", ",")
return (
<span className={cn("ziffern text-sm", farbe)} title={`${gb(ziel.platte!.belegt)} von ${gb(ziel.platte!.gesamt)} GB belegt`}>
{Math.min(100, Math.round(anteil * 100))} %{anteil >= PLATTE_ROT ? " voll" : ""}
</span>
)
}
function Zeile({ ziel, laeuft, gesperrt, onFrage }: { function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
ziel: ZielStand ziel: ZielStand
laeuft: string | null laeuft: string | null
@@ -53,7 +68,7 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
return ( return (
<div <div
role="row" role="row"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-linie py-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_130px] md:items-center" className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-linie py-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_90px_130px] md:items-center"
> >
<div role="rowheader" className="flex min-w-0 flex-col"> <div role="rowheader" className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span> <span className="text-[15px] font-semibold break-words">{ziel.name}</span>
@@ -72,6 +87,10 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
</Button> </Button>
)} )}
</span> </span>
<span role="cell" className="col-span-2 md:col-span-1">
<span className="text-sm text-text-3 md:hidden">Platte </span>
<PlatteZelle ziel={ziel} />
</span>
<span <span
role="cell" role="cell"
className={cn( className={cn(
@@ -101,11 +120,12 @@ export function GeraeteListe({ ziele, laufend, gesperrt, onAktion }: {
<div role="table" aria-label="Geräte im Homelab" className="flex flex-col"> <div role="table" aria-label="Geräte im Homelab" className="flex flex-col">
<div <div
role="row" role="row"
className="schild hidden grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_130px] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid" className="schild hidden grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_90px_130px] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
> >
<span role="columnheader">Gerät</span> <span role="columnheader">Gerät</span>
<span role="columnheader">App</span> <span role="columnheader">App</span>
<span role="columnheader">Pakete</span> <span role="columnheader">Pakete</span>
<span role="columnheader">Platte</span>
<span role="columnheader">Netz</span> <span role="columnheader">Netz</span>
</div> </div>
{ziele.map((z) => ( {ziele.map((z) => (
@@ -80,4 +80,10 @@ describe("GeraeteListe", () => {
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Nach Updates suchen" })) fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Nach Updates suchen" }))
expect(start).toHaveBeenCalledWith(suchen) expect(start).toHaveBeenCalledWith(suchen)
}) })
it("zeigt die Belegung der Platte und sagt „voll“ ab 95 %", () => {
render(<GeraeteListe ziele={[{ ...netbird, platte: { belegt: 1_960_000_000, gesamt: 2_000_000_000 } }]}
laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText("98 % voll")).toHaveAttribute("title", "2,0 von 2,0 GB belegt")
})
}) })
+6
View File
@@ -53,6 +53,12 @@ describe("lampeFuer", () => {
expect(lampeFuer(arcane, false).label).toBe("Arcane") expect(lampeFuer(arcane, false).label).toBe("Arcane")
}) })
it("warnt bei voller Platte vor allem anderen außer Stille", () => {
expect(lampeFuer({ ...netbird, platte: { belegt: 98, gesamt: 100 } }, true)).toMatchObject({ zustand: "fehler", wert: "Platte voll" })
expect(lampeFuer({ ...npm, platte: { belegt: 90, gesamt: 100 } }, false)).toMatchObject({ zustand: "warn", wert: "Platte 90 %" })
expect(lampeFuer({ ...npm, platte: { belegt: 50, gesamt: 100 } }, false).zustand).toBe("ok")
})
it("meldet ein stummes Gerät rot und ein laufendes Update als Info", () => { it("meldet ein stummes Gerät rot und ein laufendes Update als Info", () => {
expect(lampeFuer({ ...npm, erreichbar: false }, false).zustand).toBe("fehler") expect(lampeFuer({ ...npm, erreichbar: false }, false).zustand).toBe("fehler")
expect(lampeFuer(npm, true)).toMatchObject({ zustand: "info", wert: "Update läuft" }) expect(lampeFuer(npm, true)).toMatchObject({ zustand: "info", wert: "Update läuft" })
+14
View File
@@ -81,10 +81,24 @@ export function neuText(b: BausteinStand): string {
return b.kurz || b.verfuegbar || "neu" return b.kurz || b.verfuegbar || "neu"
} }
/** Anteil der belegten Platte (0…1) oder null, wo unbekannt. Ab 85 % gelb, ab 95 % rot — wie der Wächter. */
export function plattenAnteil(ziel: ZielStand): number | null {
const p = ziel.platte
return p && p.gesamt > 0 ? p.belegt / p.gesamt : null
}
export const PLATTE_GELB = 0.85
export const PLATTE_ROT = 0.95
export function lampeFuer(ziel: ZielStand, laeuft: boolean): Lampe { export function lampeFuer(ziel: ZielStand, laeuft: boolean): Lampe {
const label = kurzname(ziel) const label = kurzname(ziel)
const platte = plattenAnteil(ziel)
if (ziel.erreichbar === false) return { id: ziel.id, label, zustand: "fehler", wert: "antwortet nicht" } if (ziel.erreichbar === false) return { id: ziel.id, label, zustand: "fehler", wert: "antwortet nicht" }
if (platte !== null && platte >= PLATTE_ROT) return { id: ziel.id, label, zustand: "fehler", wert: "Platte voll" }
if (laeuft) return { id: ziel.id, label, zustand: "info", wert: "Update läuft" } if (laeuft) return { id: ziel.id, label, zustand: "info", wert: "Update läuft" }
if (platte !== null && platte >= PLATTE_GELB) {
return { id: ziel.id, label, zustand: "warn", wert: `Platte ${Math.round(platte * 100)} %` }
}
const neu = ziel.bausteine.filter((b) => b.zustand === "neu") const neu = ziel.bausteine.filter((b) => b.zustand === "neu")
if (neu.length > 0) { if (neu.length > 0) {
const b = neu.find((x) => APP.has(x.id)) ?? neu[0] const b = neu.find((x) => APP.has(x.id)) ?? neu[0]
+2
View File
@@ -280,6 +280,8 @@ export interface ZielStand {
rueckweg: string | null rueckweg: string | null
/** Kurzform des Rückwegs; ältere Stände liefern nur den Text. */ /** Kurzform des Rückwegs; ältere Stände liefern nur den Text. */
rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null
/** Belegung der Platte in Bytes, wo bekannt (laufende Container). */
platte?: { belegt: number; gesamt: number } | null
zaehlung: Partial<Record<BausteinZustand, number>> zaehlung: Partial<Record<BausteinZustand, number>>
offen: number offen: number
} }