Welle 2 · Docker-Dienste unter Arcane einzeln: Stacks, Zustand, Gesundheit, Update-Stand; neue Stacks unter „Neu entdeckt“
Statt „Arcane (Docker) · 11 Images“ zeigt das Homelab-Cockpit je Stack jeden Container mit Zustand, Healthcheck und Update-Stand („Update verfügbar“, „lokal gebaut“ für NerdQuiz und Rippy, „aktuell“). Der Wächter meldet gelb, wenn ein Container ungesund ist oder ständig neu startet; beendete Container sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (eigenes Register, nur „Übernehmen“), Lucy meldet sie einmal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c7c396f56d
commit
6af42bd42c
@@ -12,7 +12,8 @@
|
|||||||
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
|
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
|
||||||
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
|
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
|
||||||
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
|
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
|
||||||
GET /api/homelab/neu „Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt
|
GET /api/homelab/neu „Neu entdeckt“: Geräte und Docker-Stacks, die er noch nicht kennt
|
||||||
|
GET /api/homelab/docker Docker-Dienste unter Arcane je Stack: Zustand, Gesundheit, Update
|
||||||
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
|
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
|
||||||
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
||||||
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
||||||
@@ -28,6 +29,7 @@ Dünn: die Logik liegt in services/homelab/.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
@@ -168,7 +170,14 @@ def speicher_lage() -> dict:
|
|||||||
def neu_entdeckt() -> dict:
|
def neu_entdeckt() -> dict:
|
||||||
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
|
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
|
||||||
from services.homelab import entdecken
|
from services.homelab import entdecken
|
||||||
return {"geraete": entdecken.neue()}
|
return {"geraete": entdecken.neue(), "docker": entdecken.neue_docker()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docker")
|
||||||
|
def docker_dienste() -> dict:
|
||||||
|
"""Die Docker-Container unter Arcane, je Stack (seit 25.09.2026)."""
|
||||||
|
from services.homelab import docker
|
||||||
|
return docker.lage()
|
||||||
|
|
||||||
|
|
||||||
class Wahl(BaseModel):
|
class Wahl(BaseModel):
|
||||||
@@ -308,6 +317,7 @@ def bericht(daten: dict) -> dict:
|
|||||||
try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf
|
try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf
|
||||||
entdecken.bericht_gesehen(daten)
|
entdecken.bericht_gesehen(daten)
|
||||||
katalog.ergaenzen(daten)
|
katalog.ergaenzen(daten)
|
||||||
|
threading.Thread(target=entdecken.docker_abgleichen, name="docker-abgleich", daemon=True).start()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True)
|
log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -200,6 +200,25 @@ def _images_mit_update(url: str) -> list[dict]:
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def container_liste(url: str) -> list[dict] | None:
|
||||||
|
"""Alle Container über alle Umgebungen, wie Arcane sie liefert (Docker-Dienste einzeln, seit 25.09.2026). None
|
||||||
|
ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
||||||
|
if not schluessel():
|
||||||
|
return None
|
||||||
|
return _gemerkt(f"container-liste:{url}", lambda: _container_liste(url))
|
||||||
|
|
||||||
|
|
||||||
|
def _container_liste(url: str) -> list[dict] | None:
|
||||||
|
ergebnis: list[dict] = []
|
||||||
|
try:
|
||||||
|
for umgebung in umgebungen(url):
|
||||||
|
daten = _get(url, f"/environments/{umgebung['id']}/containers?limit=500")
|
||||||
|
ergebnis += [c for c in (daten or {}).get("data") or [] if isinstance(c, dict)]
|
||||||
|
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def container_zahlen(url: str) -> dict | None:
|
def container_zahlen(url: str) -> dict | None:
|
||||||
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
|
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
|
||||||
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, seit 25.09.2026).
|
||||||
|
|
||||||
|
Bis zum 25.09. stand im Orchestrator nur „Arcane (Docker) · 11 Images“. Jetzt fragt er Arcane nach jedem Container
|
||||||
|
(GET /environments/{id}/containers, mit dem Schlüssel aus den Einstellungen, höchstens alle zwei Minuten) und zeigt je
|
||||||
|
Stack (Compose-Projekt) Zustand, Gesundheit, Image und Update-Stand:
|
||||||
|
• „Update verfügbar“, wenn Arcane für das Image einen neueren Stand kennt,
|
||||||
|
• „lokal gebaut“, wenn das Image aus keiner Registry kommt (Arcane: updateType „local“, oder die Registry kennt es
|
||||||
|
nicht — NerdQuiz und Rippy baut der User selbst) — dort gibt es keinen Abgleich,
|
||||||
|
• sonst „aktuell“.
|
||||||
|
Der Wächter meldet gelb, wenn ein Container ungesund ist (Healthcheck) oder immer wieder neu startet; beendete
|
||||||
|
Container (etwa einmalige Aufgaben) sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (entdecken.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from services.homelab import apps, arcane, einstellungen, kanal
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_GESUND = re.compile(r"\((healthy|unhealthy|health: starting)\)")
|
||||||
|
_LOKAL = re.compile(r"distribution inspect|manifest unknown|pull access denied|repository does not exist", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def container_zeile(c: dict) -> dict:
|
||||||
|
"""Ein Container aus Arcanes Liste in der Form der Oberfläche."""
|
||||||
|
labels = c.get("labels") if isinstance(c.get("labels"), dict) else {}
|
||||||
|
info = c.get("updateInfo") if isinstance(c.get("updateInfo"), dict) else {}
|
||||||
|
gesund = _GESUND.search(str(c.get("status") or ""))
|
||||||
|
fehler = str(info.get("error") or "")
|
||||||
|
if info.get("hasUpdate"):
|
||||||
|
update = "neu"
|
||||||
|
elif info.get("updateType") == "local" or _LOKAL.search(fehler):
|
||||||
|
update = "lokal"
|
||||||
|
elif fehler:
|
||||||
|
update = "unbekannt"
|
||||||
|
else:
|
||||||
|
update = "aktuell"
|
||||||
|
name = str((c.get("names") or ["?"])[0]).lstrip("/")
|
||||||
|
return {"id": str(c.get("id") or name)[:12], "name": name, "stack": labels.get("com.docker.compose.project"),
|
||||||
|
"dienst": labels.get("com.docker.compose.service"), "image": c.get("image"),
|
||||||
|
"zustand": str(c.get("state") or "unbekannt"), "status": c.get("status"),
|
||||||
|
"gesund": {"healthy": True, "unhealthy": False}.get(gesund.group(1)) if gesund else None,
|
||||||
|
"update": update, "aktuell": info.get("currentVersion") or None, "neu": info.get("latestVersion") or None}
|
||||||
|
|
||||||
|
|
||||||
|
def lage() -> dict:
|
||||||
|
"""Für GET /api/homelab/docker: {"verfuegbar", "grund", "stacks": [{"name", "container": […]}]}."""
|
||||||
|
url = einstellungen.arcane_url()
|
||||||
|
if not url:
|
||||||
|
return {"verfuegbar": False, "grund": "Arcane steht nicht im Bericht des Ausführers.", "stacks": []}
|
||||||
|
if not arcane.schluessel():
|
||||||
|
return {"verfuegbar": False, "grund": "Es fehlt der Arcane-API-Schlüssel (Einstellungen → Homelab).", "stacks": []}
|
||||||
|
roh = arcane.container_liste(url)
|
||||||
|
if roh is None:
|
||||||
|
return {"verfuegbar": False, "grund": "Arcane antwortet gerade nicht.", "stacks": []}
|
||||||
|
stacks: dict[str, list[dict]] = {}
|
||||||
|
for c in roh:
|
||||||
|
zeile = container_zeile(c)
|
||||||
|
stacks.setdefault(zeile["stack"] or zeile["name"], []).append(zeile)
|
||||||
|
return {"verfuegbar": True, "grund": None,
|
||||||
|
"stacks": [{"name": n, "container": sorted(cs, key=lambda z: z["name"])} for n, cs in sorted(stacks.items())]}
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_ziel() -> str:
|
||||||
|
"""Die Ziel-ID der Arcane-VM (für die Hinweise), aus dem Bericht; sonst „arcane“."""
|
||||||
|
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
if app and app.kennung == "arcane":
|
||||||
|
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
return "arcane"
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_docker() -> list:
|
||||||
|
"""Wächter: gelb, wenn ein Container ungesund ist oder ständig neu startet. Ohne Arcane oder Schlüssel: nichts."""
|
||||||
|
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||||
|
befunde = []
|
||||||
|
quelle = None
|
||||||
|
for s in lage()["stacks"]:
|
||||||
|
for c in s["container"]:
|
||||||
|
if c["gesund"] is False:
|
||||||
|
titel, text = (f"Docker-Dienst {c['name']} ist ungesund",
|
||||||
|
f"Sein Healthcheck schlägt fehl ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
|
||||||
|
elif c["zustand"] == "restarting":
|
||||||
|
titel, text = (f"Docker-Dienst {c['name']} startet immer wieder neu",
|
||||||
|
f"Er kommt nicht hoch ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
quelle = quelle or arcane_ziel()
|
||||||
|
befunde.append(Befund(id=f"docker:{c['name']}", stufe="gelb", titel=titel, text=text, quelle=quelle))
|
||||||
|
return befunde
|
||||||
@@ -8,6 +8,8 @@ den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wi
|
|||||||
Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen.
|
Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen.
|
||||||
• „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht.
|
• „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht.
|
||||||
• „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt.
|
• „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt.
|
||||||
|
Neue Docker-Stacks unter Arcane (Compose-Projekte, docker.py) kommen genauso dazu, mit eigenem Register
|
||||||
|
(docker-bekannt.json) und nur „Übernehmen“ — Etiketten gibt es dort nicht.
|
||||||
Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da.
|
Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da.
|
||||||
Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“.
|
Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“.
|
||||||
Der eigene Container (Etikett mc2) erscheint nie.
|
Der eigene Container (Etikett mc2) erscheint nie.
|
||||||
@@ -33,20 +35,23 @@ ZEITLIMIT_S = 60
|
|||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _pfad() -> Path:
|
def _pfad(name: str = "geraete-bekannt.json") -> Path:
|
||||||
return einstellungen().daten_dir / "geraete-bekannt.json"
|
return einstellungen().daten_dir / name
|
||||||
|
|
||||||
|
|
||||||
def _lesen() -> dict | None:
|
DOCKER = "docker-bekannt.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _lesen(name: str = "geraete-bekannt.json") -> dict | None:
|
||||||
try:
|
try:
|
||||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
daten = json.loads(_pfad(name).read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return None
|
return None
|
||||||
return daten if isinstance(daten, dict) else None
|
return daten if isinstance(daten, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _schreiben(daten: dict) -> None:
|
def _schreiben(daten: dict, name: str = "geraete-bekannt.json") -> None:
|
||||||
pfad = _pfad()
|
pfad = _pfad(name)
|
||||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = pfad.with_suffix(".tmp")
|
tmp = pfad.with_suffix(".tmp")
|
||||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
@@ -127,18 +132,74 @@ def neue() -> list[dict]:
|
|||||||
return liste
|
return liste
|
||||||
|
|
||||||
|
|
||||||
def _markieren(zid: str) -> None:
|
def _markieren(zid: str, name: str = "geraete-bekannt.json") -> None:
|
||||||
with _lock:
|
with _lock:
|
||||||
register = _lesen() or {}
|
register = _lesen(name) or {}
|
||||||
if zid in register:
|
if zid in register:
|
||||||
register[zid]["zustand"] = "bekannt"
|
register[zid]["zustand"] = "bekannt"
|
||||||
_schreiben(register)
|
_schreiben(register, name)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Docker-Stacks (seit 25.09.2026) ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def docker_gesehen(stacks: list[dict], jetzt: float | None = None, melden=None) -> list[str]:
|
||||||
|
"""Die Stacks aus docker.lage() mit dem Register abgleichen. Rückgabe: die IDs („docker:<stack>“), die neu sind."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
melden = melden or _melden
|
||||||
|
aktuell = {f"docker:{s['name']}": s for s in stacks if s.get("name")}
|
||||||
|
if not aktuell:
|
||||||
|
return []
|
||||||
|
with _lock:
|
||||||
|
register = _lesen(DOCKER)
|
||||||
|
if register is None:
|
||||||
|
_schreiben({sid: {"erstmals": jetzt, "zustand": "bekannt"} for sid in aktuell}, DOCKER)
|
||||||
|
return []
|
||||||
|
neu = [sid for sid in aktuell if sid not in register]
|
||||||
|
for sid in neu:
|
||||||
|
register[sid] = {"erstmals": jetzt, "zustand": "neu"}
|
||||||
|
for sid in [s for s in register if s not in aktuell]:
|
||||||
|
register.pop(sid)
|
||||||
|
_schreiben(register, DOCKER)
|
||||||
|
for sid in neu:
|
||||||
|
namen = ", ".join(c["name"] for c in aktuell[sid].get("container") or [])
|
||||||
|
melden(f"Neu unter Arcane: der Docker-Stack „{aktuell[sid]['name']}“ ({namen}). Im Homelab-Cockpit unter „Neu "
|
||||||
|
"entdeckt“ übernehmen.")
|
||||||
|
return neu
|
||||||
|
|
||||||
|
|
||||||
|
def docker_abgleichen() -> None:
|
||||||
|
"""Nach jedem Bericht im eigenen Faden: Arcane fragen (gemerkt) und neue Stacks eintragen."""
|
||||||
|
from services.homelab import docker
|
||||||
|
try:
|
||||||
|
lage = docker.lage()
|
||||||
|
if lage["verfuegbar"]:
|
||||||
|
docker_gesehen(lage["stacks"])
|
||||||
|
except Exception:
|
||||||
|
log.warning("entdecken: Docker-Abgleich gescheitert", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def neue_docker() -> list[dict]:
|
||||||
|
"""Neue Docker-Stacks für GET /api/homelab/neu."""
|
||||||
|
from services.homelab import docker
|
||||||
|
register = _lesen(DOCKER) or {}
|
||||||
|
offen = {sid: e for sid, e in register.items() if e.get("zustand") == "neu"}
|
||||||
|
if not offen:
|
||||||
|
return []
|
||||||
|
lage = docker.lage()
|
||||||
|
return [{"id": f"docker:{s['name']}", "name": s["name"], "erstmals": offen[f"docker:{s['name']}"].get("erstmals"),
|
||||||
|
"container": [c["name"] for c in s["container"]]}
|
||||||
|
for s in lage["stacks"] if f"docker:{s['name']}" in offen]
|
||||||
|
|
||||||
|
|
||||||
def uebernehmen(zid: str, wahl: str) -> dict:
|
def uebernehmen(zid: str, wahl: str) -> dict:
|
||||||
"""POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“."""
|
"""POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“."""
|
||||||
if wahl not in WAHL:
|
if wahl not in WAHL:
|
||||||
return {"ok": False, "detail": "Unbekannte Wahl."}
|
return {"ok": False, "detail": "Unbekannte Wahl."}
|
||||||
|
if zid.startswith("docker:"):
|
||||||
|
if wahl != "beobachten" or zid not in {s["id"] for s in neue_docker()}:
|
||||||
|
return {"ok": False, "detail": "Dieser Docker-Stack steht nicht (mehr) unter „Neu entdeckt“."}
|
||||||
|
_markieren(zid, DOCKER)
|
||||||
|
return {"ok": True, "text": f"Der Stack {zid.split(':', 1)[1]} ist übernommen."}
|
||||||
geraet = next((g for g in neue() if g["id"] == zid), None)
|
geraet = next((g for g in neue() if g["id"] == zid), None)
|
||||||
if not geraet:
|
if not geraet:
|
||||||
return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."}
|
return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."}
|
||||||
|
|||||||
@@ -624,6 +624,8 @@ PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe
|
|||||||
PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else ()
|
PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else ()
|
||||||
# Zertifikats-Wache (seit 25.09.2026): gelb, wenn die Erneuerung hängt; rot kurz vor Ablauf.
|
# Zertifikats-Wache (seit 25.09.2026): gelb, wenn die Erneuerung hängt; rot kurz vor Ablauf.
|
||||||
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else ()
|
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else ()
|
||||||
|
# Docker-Dienste unter Arcane (seit 25.09.2026): gelb, wenn einer ungesund ist oder ständig neu startet.
|
||||||
|
PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docker,) 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,92 @@
|
|||||||
|
"""Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, 25.09.2026). Die Container stammen aus Arcanes
|
||||||
|
Antwort vom 25.09. (sieben Container in drei Stacks; NerdQuiz und Rippy baut der User selbst)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from kern import einstellungen as einstellungen_mod
|
||||||
|
from services.homelab import arcane, docker, einstellungen, entdecken
|
||||||
|
|
||||||
|
JETZT = 1_790_340_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def _c(name: str, stack: str, image: str, status: str = "Up 3 hours", state: str = "running", **info) -> dict:
|
||||||
|
return {"id": f"{name}0123456789ab", "names": [f"/{name}"], "image": image, "state": state, "status": status,
|
||||||
|
"labels": {"com.docker.compose.project": stack, "com.docker.compose.service": name.split("-")[1]
|
||||||
|
if "-" in name else name}, "updateInfo": info}
|
||||||
|
|
||||||
|
|
||||||
|
ARCANE = [
|
||||||
|
_c("arcane", "arcane", "ghcr.io/getarcaneapp/manager:latest", updateType="digest", currentVersion="latest"),
|
||||||
|
_c("nerdquiz", "deploy", "nerdquiz:latest", "Up 3 hours (healthy)", currentVersion="1.18.2",
|
||||||
|
error="distribution inspect of docker.io/library/nerdquiz:1.18.2 failed: Error response from daemon"),
|
||||||
|
_c("rippy-api-1", "rippy", "rippy-api", "Up 3 hours (healthy)", updateType="local"),
|
||||||
|
_c("rippy-postgres-1", "rippy", "postgres:16-alpine", "Up 3 hours (healthy)", updateType="digest", hasUpdate=True,
|
||||||
|
currentVersion="16-alpine"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_zeile():
|
||||||
|
zeilen = {z["name"]: z for z in map(docker.container_zeile, ARCANE)}
|
||||||
|
assert zeilen["arcane"]["update"] == "aktuell" and zeilen["arcane"]["gesund"] is None
|
||||||
|
assert zeilen["nerdquiz"]["update"] == "lokal" and zeilen["nerdquiz"]["gesund"] is True # kein Fehler, lokal gebaut
|
||||||
|
assert zeilen["rippy-api-1"]["update"] == "lokal" and zeilen["rippy-api-1"]["stack"] == "rippy"
|
||||||
|
assert zeilen["rippy-postgres-1"]["update"] == "neu" and zeilen["rippy-postgres-1"]["dienst"] == "postgres"
|
||||||
|
krank = docker.container_zeile(_c("x", "s", "i", "Up 2 minutes (unhealthy)", error="timeout"))
|
||||||
|
assert (krank["gesund"], krank["update"]) == (False, "unbekannt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lage_je_stack(monkeypatch):
|
||||||
|
monkeypatch.setattr(einstellungen, "arcane_url", lambda: None)
|
||||||
|
assert docker.lage() == {"verfuegbar": False, "grund": "Arcane steht nicht im Bericht des Ausführers.", "stacks": []}
|
||||||
|
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://192.168.178.28:3552")
|
||||||
|
monkeypatch.setattr(arcane, "schluessel", lambda: "")
|
||||||
|
assert docker.lage()["grund"].startswith("Es fehlt der Arcane-API-Schlüssel")
|
||||||
|
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
|
||||||
|
monkeypatch.setattr(arcane, "container_liste", lambda url: None)
|
||||||
|
assert docker.lage()["grund"] == "Arcane antwortet gerade nicht."
|
||||||
|
monkeypatch.setattr(arcane, "container_liste", lambda url: ARCANE)
|
||||||
|
lage = docker.lage()
|
||||||
|
assert [(s["name"], [c["name"] for c in s["container"]]) for s in lage["stacks"]] == [
|
||||||
|
("arcane", ["arcane"]), ("deploy", ["nerdquiz"]), ("rippy", ["rippy-api-1", "rippy-postgres-1"])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_waechter_meldet_ungesunde_und_neustartende(monkeypatch):
|
||||||
|
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://arcane")
|
||||||
|
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
|
||||||
|
monkeypatch.setattr(docker, "arcane_ziel", lambda: "vm-106")
|
||||||
|
krank = [*ARCANE, _c("rippy-worker-1", "rippy", "rippy-worker", "Up 1 minute (unhealthy)"),
|
||||||
|
_c("einmal", "jobs", "busybox", "Exited (0) 2 hours ago", state="exited"),
|
||||||
|
_c("hakt", "jobs", "busybox", "Restarting (1) 5 seconds ago", state="restarting")]
|
||||||
|
monkeypatch.setattr(arcane, "container_liste", lambda url: krank)
|
||||||
|
befunde = {b.id: b for b in docker.pruefe_docker()}
|
||||||
|
assert set(befunde) == {"docker:rippy-worker-1", "docker:hakt"} # beendet ist kein Befund
|
||||||
|
assert befunde["docker:rippy-worker-1"].titel == "Docker-Dienst rippy-worker-1 ist ungesund"
|
||||||
|
assert befunde["docker:hakt"].quelle == "vm-106" and befunde["docker:hakt"].stufe == "gelb"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def daten(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
yield tmp_path
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_neue_stacks(daten, monkeypatch):
|
||||||
|
meldungen: list[str] = []
|
||||||
|
stacks = [{"name": "arcane", "container": [{"name": "arcane"}]}, {"name": "rippy", "container": [{"name": "rippy-api-1"}]}]
|
||||||
|
assert entdecken.docker_gesehen(stacks, JETZT, meldungen.append) == [] # erster Abgleich: alles bekannt
|
||||||
|
neu = [*stacks, {"name": "immich", "container": [{"name": "immich-server"}, {"name": "immich-ml"}]}]
|
||||||
|
assert entdecken.docker_gesehen(neu, JETZT + 600, meldungen.append) == ["docker:immich"]
|
||||||
|
assert meldungen == [("Neu unter Arcane: der Docker-Stack „immich“ (immich-server, immich-ml). Im Homelab-Cockpit "
|
||||||
|
"unter „Neu entdeckt“ übernehmen.")]
|
||||||
|
monkeypatch.setattr(docker, "lage", lambda: {"verfuegbar": True, "grund": None, "stacks": [
|
||||||
|
{"name": s["name"], "container": [{"name": c["name"]} for c in s["container"]]} for s in neu]})
|
||||||
|
(stack,) = entdecken.neue_docker()
|
||||||
|
assert (stack["id"], stack["container"]) == ("docker:immich", ["immich-server", "immich-ml"])
|
||||||
|
assert entdecken.uebernehmen("docker:immich", "freigeben")["ok"] is False # Etiketten gibt es dort nicht
|
||||||
|
assert entdecken.uebernehmen("docker:immich", "beobachten") == {"ok": True, "text": "Der Stack immich ist übernommen."}
|
||||||
|
assert entdecken.neue_docker() == []
|
||||||
|
register = json.loads((daten / "docker-bekannt.json").read_text(encoding="utf-8"))
|
||||||
|
assert register["docker:immich"]["zustand"] == "bekannt"
|
||||||
@@ -221,7 +221,7 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
|
|||||||
""")
|
""")
|
||||||
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_speicher",
|
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
|
||||||
"pruefe_zertifikate"]
|
"pruefe_zertifikate", "pruefe_docker"]
|
||||||
|
|
||||||
|
|
||||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen, within } from "@testing-library/react"
|
||||||
|
import type { DockerContainer, DockerLage } from "@/lib/typen"
|
||||||
|
import { DockerDienste } from "./DockerDienste"
|
||||||
|
|
||||||
|
const c = (name: string, mehr: Partial<DockerContainer> = {}): DockerContainer => ({
|
||||||
|
id: name, name, stack: "rippy", dienst: name.split("-")[1] ?? name, image: `${name}:latest`, zustand: "running",
|
||||||
|
status: "Up 3 hours", gesund: null, update: "aktuell", aktuell: null, neu: null, ...mehr,
|
||||||
|
})
|
||||||
|
|
||||||
|
const lage: DockerLage = {
|
||||||
|
verfuegbar: true, grund: null,
|
||||||
|
stacks: [
|
||||||
|
{ name: "deploy", container: [c("nerdquiz", { stack: "deploy", gesund: true, update: "lokal" })] },
|
||||||
|
{ name: "rippy", container: [
|
||||||
|
c("rippy-postgres-1", { image: "postgres:16-alpine", update: "neu" }),
|
||||||
|
c("rippy-worker-1", { gesund: false }),
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DockerDienste", () => {
|
||||||
|
it("zeigt je Stack Zustand, Gesundheit und Update-Stand", () => {
|
||||||
|
render(<DockerDienste lage={lage} />)
|
||||||
|
const deploy = screen.getByRole("region", { name: "Stack deploy" })
|
||||||
|
expect(within(deploy).getByText("läuft, gesund")).toBeInTheDocument()
|
||||||
|
expect(within(deploy).getByText("lokal gebaut")).toBeInTheDocument()
|
||||||
|
const rippy = screen.getByRole("region", { name: "Stack rippy" })
|
||||||
|
expect(within(rippy).getByText("2 von 2 laufen · 1 mit Update")).toBeInTheDocument()
|
||||||
|
expect(within(rippy).getByText("Update verfügbar")).toBeInTheDocument()
|
||||||
|
expect(within(rippy).getByText("ungesund")).toHaveClass("text-rot-text")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("zeigt nichts ohne Arcane", () => {
|
||||||
|
const { container } = render(<DockerDienste lage={{ verfuegbar: false, grund: "Arcane antwortet gerade nicht.", stacks: [] }} />)
|
||||||
|
expect(container).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { cn } from "cn"
|
||||||
|
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||||
|
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
|
||||||
|
import type { DockerContainer, DockerLage } from "@/lib/typen"
|
||||||
|
|
||||||
|
// Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, seit 25.09.2026): je Stack die Container mit
|
||||||
|
// Zustand, Gesundheit und Update-Stand — statt nur „Arcane (Docker) · 11 Images“.
|
||||||
|
|
||||||
|
const UPDATE: Record<DockerContainer["update"], { text: string; klasse: string }> = {
|
||||||
|
neu: { text: "Update verfügbar", klasse: "text-cyan-text" },
|
||||||
|
lokal: { text: "lokal gebaut", klasse: "text-text-3" },
|
||||||
|
aktuell: { text: "aktuell", klasse: "text-text-3" },
|
||||||
|
unbekannt: { text: "Update-Stand unklar", klasse: "text-bernstein" },
|
||||||
|
}
|
||||||
|
|
||||||
|
function punkt(c: DockerContainer): Punktzustand {
|
||||||
|
if (c.zustand === "restarting" || c.gesund === false) return "rot"
|
||||||
|
if (c.zustand === "running") return "ok"
|
||||||
|
return "aus"
|
||||||
|
}
|
||||||
|
|
||||||
|
function zustandText(c: DockerContainer): string {
|
||||||
|
if (c.gesund === false) return "ungesund"
|
||||||
|
if (c.zustand === "restarting") return "startet immer wieder neu"
|
||||||
|
if (c.zustand === "running") return c.gesund ? "läuft, gesund" : "läuft"
|
||||||
|
if (c.zustand === "exited") return "beendet"
|
||||||
|
if (c.zustand === "paused") return "angehalten"
|
||||||
|
return c.zustand
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aus „ghcr.io/getarcaneapp/manager:latest“ wird „manager“ — für das Logo. */
|
||||||
|
const imageName = (image: string | null) => (image ?? "").split("/").at(-1)?.split(":")[0] ?? ""
|
||||||
|
|
||||||
|
function Zeile({ c }: { c: DockerContainer }) {
|
||||||
|
const u = UPDATE[c.update] ?? UPDATE.unbekannt
|
||||||
|
return (
|
||||||
|
<li className="flex min-w-0 items-center gap-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
|
||||||
|
<Geraetesymbol bild={geraetebild({ art: "container", name: c.dienst ?? imageName(c.image) })} groesse="sm" punkt={punkt(c)} />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
|
||||||
|
<span className="truncate text-[12.5px] text-text-3">{c.image}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 flex-col items-end text-right">
|
||||||
|
<span className={cn("text-[13px]", punkt(c) === "rot" ? "text-rot-text" : "text-text-2")}>{zustandText(c)}</span>
|
||||||
|
<span className={cn("text-[12px]", u.klasse)}>{u.text}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerDienste({ lage }: { lage: DockerLage }) {
|
||||||
|
if (!lage.verfuegbar || lage.stacks.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{lage.stacks.map((s) => {
|
||||||
|
const laufen = s.container.filter((c) => c.zustand === "running").length
|
||||||
|
const neu = s.container.filter((c) => c.update === "neu").length
|
||||||
|
return (
|
||||||
|
<section key={s.name} aria-label={`Stack ${s.name}`} className="flaeche-innen flex min-w-0 flex-col rounded-2xl px-4 py-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-3 pb-1">
|
||||||
|
<h3 className="m-0 truncate text-[15px] font-semibold">{s.name}</h3>
|
||||||
|
<span className="shrink-0 text-[12.5px] text-text-3">
|
||||||
|
{laufen} von {s.container.length} laufen{neu ? ` · ${neu} mit Update` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="m-0 list-none p-0">
|
||||||
|
{s.container.map((c) => <Zeile key={c.id} c={c} />)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
|||||||
import { Panel } from "@/components/cockpit/Panel"
|
import { Panel } from "@/components/cockpit/Panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { geraetebild } from "@/lib/geraetebild"
|
import { geraetebild } from "@/lib/geraetebild"
|
||||||
import type { NeuesGeraet, NeuWahl } from "@/lib/typen"
|
import type { NeuerStack, NeuesGeraet, NeuWahl } from "@/lib/typen"
|
||||||
import { flugplanZeit } from "@/lib/zeit"
|
import { flugplanZeit } from "@/lib/zeit"
|
||||||
|
|
||||||
// „Neu entdeckt“ (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt, mit
|
// „Neu entdeckt“ (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt, mit
|
||||||
@@ -76,24 +76,47 @@ const FRAGE: Record<NeuWahl, { titel: string; text: string; knopf: string }> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NeuEntdeckt({ geraete, gesperrt, onWahl }: {
|
/** Ein neuer Docker-Stack: nur „Übernehmen“ — Etiketten gibt es unter Arcane nicht. */
|
||||||
|
function StackKarte({ s, gesperrt, onWahl }: { s: NeuerStack; gesperrt: boolean; onWahl: (w: NeuWahl) => void }) {
|
||||||
|
return (
|
||||||
|
<li className="flaeche-innen flex flex-col gap-3 rounded-2xl p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Geraetesymbol bild={geraetebild({ art: "container", name: s.name })} groesse="md" punkt="ok" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="text-[15px] font-semibold break-words">Docker-Stack {s.name}</span>
|
||||||
|
<span className="text-[13px] text-text-3">
|
||||||
|
unter Arcane{s.erstmals ? ` · entdeckt ${flugplanZeit(new Date(s.erstmals * 1000).toISOString()).toLowerCase()}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="m-0 text-sm break-words text-text-2">Container: {s.container.join(", ") || "–"}</p>
|
||||||
|
<Button size="sm" className="self-start" disabled={gesperrt} onClick={() => onWahl("beobachten")}>Übernehmen</Button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NeuEntdeckt({ geraete, stacks = [], gesperrt, onWahl }: {
|
||||||
geraete: NeuesGeraet[]
|
geraete: NeuesGeraet[]
|
||||||
|
/** Neue Docker-Stacks unter Arcane (seit 25.09.2026). */
|
||||||
|
stacks?: NeuerStack[]
|
||||||
gesperrt: boolean
|
gesperrt: boolean
|
||||||
onWahl: (id: string, wahl: NeuWahl) => void
|
onWahl: (id: string, wahl: NeuWahl) => void
|
||||||
}) {
|
}) {
|
||||||
const [frage, setFrage] = useState<{ g: NeuesGeraet; wahl: NeuWahl } | null>(null)
|
const [frage, setFrage] = useState<{ g: NeuesGeraet; wahl: NeuWahl } | null>(null)
|
||||||
if (geraete.length === 0) return null
|
const anzahl = geraete.length + stacks.length
|
||||||
|
if (anzahl === 0) return null
|
||||||
return (
|
return (
|
||||||
<Panel titel="Neu entdeckt" id="neu">
|
<Panel titel="Neu entdeckt" id="neu">
|
||||||
<p className={cn("-mt-2 text-[15px] text-text-2")}>
|
<p className={cn("-mt-2 text-[15px] text-text-2")}>
|
||||||
{geraete.length === 1 ? "Ein Gerät kennt" : `${geraete.length} Geräte kennt`} der Orchestrator noch nicht. Entscheide,
|
{anzahl === 1 ? "Eins kennt" : `${anzahl} kennt`} der Orchestrator noch nicht. Entscheide, ob er
|
||||||
ob er {geraete.length === 1 ? "es" : "sie"} nur beobachtet oder auch aktualisieren darf.
|
{anzahl === 1 ? " es" : " sie"} nur beobachtet oder auch aktualisieren darf.
|
||||||
</p>
|
</p>
|
||||||
<ul className="m-0 grid list-none gap-3 p-0 md:grid-cols-2">
|
<ul className="m-0 grid list-none gap-3 p-0 md:grid-cols-2">
|
||||||
{geraete.map((g) => (
|
{geraete.map((g) => (
|
||||||
<Karte key={g.id} g={g} gesperrt={gesperrt}
|
<Karte key={g.id} g={g} gesperrt={gesperrt}
|
||||||
onWahl={(wahl) => (wahl === "beobachten" ? onWahl(g.id, wahl) : setFrage({ g, wahl }))} />
|
onWahl={(wahl) => (wahl === "beobachten" ? onWahl(g.id, wahl) : setFrage({ g, wahl }))} />
|
||||||
))}
|
))}
|
||||||
|
{stacks.map((s) => <StackKarte key={s.id} s={s} gesperrt={gesperrt} onWahl={(wahl) => onWahl(s.id, wahl)} />)}
|
||||||
</ul>
|
</ul>
|
||||||
<Bestaetigen
|
<Bestaetigen
|
||||||
offen={!!frage}
|
offen={!!frage}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
|
|||||||
import { stromVerbunden } from "./strom"
|
import { stromVerbunden } from "./strom"
|
||||||
import type {
|
import type {
|
||||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuWahl, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, 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"
|
||||||
@@ -496,7 +496,17 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
|
|||||||
export const useNeuEntdeckt = (aktiv: boolean) =>
|
export const useNeuEntdeckt = (aktiv: boolean) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ["homelab-neu"],
|
queryKey: ["homelab-neu"],
|
||||||
queryFn: () => api<{ geraete: NeuesGeraet[] }>("/api/homelab/neu"),
|
queryFn: () => api<{ geraete: NeuesGeraet[]; docker?: NeuerStack[] }>("/api/homelab/neu"),
|
||||||
|
enabled: aktiv,
|
||||||
|
refetchInterval: takt(60_000),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Die Docker-Dienste unter Arcane je Stack (seit 25.09.2026). Arcane wird höchstens alle zwei Minuten gefragt. */
|
||||||
|
export const useHomelabDocker = (aktiv: boolean) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["homelab-docker"],
|
||||||
|
queryFn: () => api<DockerLage>("/api/homelab/docker"),
|
||||||
enabled: aktiv,
|
enabled: aktiv,
|
||||||
refetchInterval: takt(60_000),
|
refetchInterval: takt(60_000),
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -656,6 +656,37 @@ export interface NeuesGeraet {
|
|||||||
ohne_sicherung: boolean
|
ohne_sicherung: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein neuer Docker-Stack unter Arcane (seit 25.09.2026). */
|
||||||
|
export interface NeuerStack {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
erstmals: number | null
|
||||||
|
container: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Docker-Dienste unter Arcane (GET /api/homelab/docker, seit 25.09.2026) --------------------------------------
|
||||||
|
|
||||||
|
export interface DockerContainer {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
stack: string | null
|
||||||
|
dienst: string | null
|
||||||
|
image: string | null
|
||||||
|
zustand: string
|
||||||
|
status: string | null
|
||||||
|
/** Healthcheck: true gesund, false ungesund, null ohne Healthcheck. */
|
||||||
|
gesund: boolean | null
|
||||||
|
update: "neu" | "lokal" | "aktuell" | "unbekannt"
|
||||||
|
aktuell: string | null
|
||||||
|
neu: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerLage {
|
||||||
|
verfuegbar: boolean
|
||||||
|
grund: string | null
|
||||||
|
stacks: { name: string; container: DockerContainer[] }[]
|
||||||
|
}
|
||||||
|
|
||||||
// --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) -----------------------------------
|
// --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) -----------------------------------
|
||||||
|
|
||||||
export interface Zertifikat {
|
export interface Zertifikat {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link, useNavigate } from "@tanstack/react-router"
|
|||||||
import { ArrowRight } from "lucide-react"
|
import { ArrowRight } from "lucide-react"
|
||||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||||
import { Panel } from "@/components/cockpit/Panel"
|
import { Panel } from "@/components/cockpit/Panel"
|
||||||
|
import { DockerDienste } from "@/components/homelab/DockerDienste"
|
||||||
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 { NeuEntdeckt } from "@/components/homelab/NeuEntdeckt"
|
import { NeuEntdeckt } from "@/components/homelab/NeuEntdeckt"
|
||||||
@@ -10,7 +11,8 @@ import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellau
|
|||||||
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
||||||
import { Zertifikate } from "@/components/homelab/Zertifikate"
|
import { Zertifikate } from "@/components/homelab/Zertifikate"
|
||||||
import {
|
import {
|
||||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate,
|
useHomelab, useHomelabDocker, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
|
||||||
|
useHomelabZertifikate,
|
||||||
useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe,
|
useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe,
|
||||||
useZielAktion,
|
useZielAktion,
|
||||||
} from "@/lib/abfragen"
|
} from "@/lib/abfragen"
|
||||||
@@ -202,6 +204,7 @@ export function HomelabSeite() {
|
|||||||
const zertifikate = useHomelabZertifikate(!!stand)
|
const zertifikate = useHomelabZertifikate(!!stand)
|
||||||
const probe = useSicherungsProbe()
|
const probe = useSicherungsProbe()
|
||||||
const neu = useNeuEntdeckt(!!stand)
|
const neu = useNeuEntdeckt(!!stand)
|
||||||
|
const docker = useHomelabDocker(!!stand)
|
||||||
const uebernehmen = useNeuUebernehmen()
|
const uebernehmen = useNeuUebernehmen()
|
||||||
if (ersatz || !stand) return ersatz
|
if (ersatz || !stand) return ersatz
|
||||||
|
|
||||||
@@ -226,7 +229,8 @@ export function HomelabSeite() {
|
|||||||
/>
|
/>
|
||||||
<AusfuehrerHinweis stand={stand} />
|
<AusfuehrerHinweis stand={stand} />
|
||||||
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
||||||
<NeuEntdeckt geraete={neu.data?.geraete ?? []} gesperrt={uebernehmen.isPending || !stand.ausfuehrer.verbunden}
|
<NeuEntdeckt geraete={neu.data?.geraete ?? []} stacks={neu.data?.docker ?? []}
|
||||||
|
gesperrt={uebernehmen.isPending || !stand.ausfuehrer.verbunden}
|
||||||
onWahl={(id, wahl) => uebernehmen.mutate({ id, wahl })} />
|
onWahl={(id, wahl) => uebernehmen.mutate({ id, wahl })} />
|
||||||
<Panel
|
<Panel
|
||||||
titel="Geräte"
|
titel="Geräte"
|
||||||
@@ -245,6 +249,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>
|
||||||
|
{docker.data?.verfuegbar && docker.data.stacks.length > 0 && (
|
||||||
|
<Panel titel="Docker-Dienste" id="docker">
|
||||||
|
<DockerDienste lage={docker.data} />
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
{speicher.data?.bericht && (
|
{speicher.data?.bericht && (
|
||||||
<Panel titel="Speicher und Sicherungen" id="speicher">
|
<Panel titel="Speicher und Sicherungen" id="speicher">
|
||||||
<Speicher
|
<Speicher
|
||||||
|
|||||||
Reference in New Issue
Block a user