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:
co-authored by
Claude Opus 5.5
parent
eed36e9764
commit
c08a144006
@@ -49,6 +49,7 @@ class ZielStand:
|
||||
geprueft: float | None = None # Unix-Sekunden der letzten Prüfung
|
||||
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“
|
||||
platte: dict | None = None # {"belegt", "gesamt"} in Bytes, wo bekannt (laufende Container)
|
||||
|
||||
def als_dict(self) -> dict:
|
||||
daten = asdict(self)
|
||||
|
||||
@@ -210,7 +210,7 @@ def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
|
||||
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",
|
||||
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:
|
||||
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
|
||||
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
|
||||
|
||||
@@ -558,12 +558,43 @@ def pruefe_gaeste() -> list[Befund]:
|
||||
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
|
||||
# den Ausführer auf dem Proxmox-Host und seine Gäste.
|
||||
PRUEFUNGEN = {
|
||||
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||
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]
|
||||
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
|
||||
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
|
||||
|
||||
@@ -361,3 +361,25 @@ def test_ausfuehrer_erkennt_beide_formate_von_update():
|
||||
assert a.app_kennung_aus(alt) == "adguard"
|
||||
assert a.app_kennung_aus(neu) == "gitea"
|
||||
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
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ from services import waechter
|
||||
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
|
||||
""")
|
||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
|
||||
"pruefe_paketlisten"]
|
||||
"pruefe_gast_platten", "pruefe_paketlisten"]
|
||||
|
||||
|
||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||
|
||||
@@ -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"),
|
||||
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
|
||||
"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:
|
||||
konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config")
|
||||
except Exception as exc:
|
||||
|
||||
+2
-1
@@ -283,7 +283,8 @@ flowchart LR
|
||||
(`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.
|
||||
- **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).
|
||||
- **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
|
||||
|
||||
+2
-1
@@ -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
|
||||
Version oder der Zahl der Pakete, rot bei „antwortet nicht“.
|
||||
- **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
|
||||
kleinen Pfeil daneben geht es sofort.
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“;
|
||||
|
||||
## 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
|
||||
(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`.
|
||||
|
||||
+2
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{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};
|
||||
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
Vendored
+1
-1
@@ -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};
|
||||
-1
File diff suppressed because one or more lines are too long
+1
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
+11
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
-11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,9 +7,9 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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="stylesheet" crossorigin href="/assets/index-XtuDiTus.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cb8UJMqV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,7 +3,9 @@ import { RefreshCw } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
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"
|
||||
|
||||
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 }: {
|
||||
ziel: ZielStand
|
||||
laeuft: string | null
|
||||
@@ -53,7 +68,7 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
|
||||
@@ -72,6 +87,10 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
|
||||
</Button>
|
||||
)}
|
||||
</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
|
||||
role="cell"
|
||||
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="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">App</span>
|
||||
<span role="columnheader">Pakete</span>
|
||||
<span role="columnheader">Platte</span>
|
||||
<span role="columnheader">Netz</span>
|
||||
</div>
|
||||
{ziele.map((z) => (
|
||||
|
||||
@@ -80,4 +80,10 @@ describe("GeraeteListe", () => {
|
||||
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Nach Updates 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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,12 @@ describe("lampeFuer", () => {
|
||||
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", () => {
|
||||
expect(lampeFuer({ ...npm, erreichbar: false }, false).zustand).toBe("fehler")
|
||||
expect(lampeFuer(npm, true)).toMatchObject({ zustand: "info", wert: "Update läuft" })
|
||||
|
||||
@@ -81,10 +81,24 @@ export function neuText(b: BausteinStand): string {
|
||||
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 {
|
||||
const label = kurzname(ziel)
|
||||
const platte = plattenAnteil(ziel)
|
||||
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 (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")
|
||||
if (neu.length > 0) {
|
||||
const b = neu.find((x) => APP.has(x.id)) ?? neu[0]
|
||||
|
||||
@@ -280,6 +280,8 @@ export interface ZielStand {
|
||||
rueckweg: string | null
|
||||
/** Kurzform des Rückwegs; ältere Stände liefern nur den Text. */
|
||||
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>>
|
||||
offen: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user