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:
Hitonabi
2026-09-25 17:31:05 +02:00
co-authored by Claude Opus 5.5
parent c7c396f56d
commit 6af42bd42c
13 changed files with 480 additions and 21 deletions
+12 -2
View File
@@ -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}
+19
View File
@@ -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."""
+91
View File
@@ -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
+70 -9
View File
@@ -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“."}
+2
View File
@@ -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]
+92
View File
@@ -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"
+1 -1
View File
@@ -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}
+12 -2
View File
@@ -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,
+31
View File
@@ -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 {
+11 -2
View File
@@ -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