From 6af42bd42cc361f3ad5854306ab0d0f5628dbe90 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 17:31:05 +0200 Subject: [PATCH] =?UTF-8?q?Welle=202=20=C2=B7=20Docker-Dienste=20unter=20A?= =?UTF-8?q?rcane=20einzeln:=20Stacks,=20Zustand,=20Gesundheit,=20Update-St?= =?UTF-8?q?and;=20neue=20Stacks=20unter=20=E2=80=9ENeu=20entdeckt=E2=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/homelab.py | 14 ++- backend/services/homelab/arcane.py | 19 ++++ backend/services/homelab/docker.py | 91 ++++++++++++++++++ backend/services/homelab/entdecken.py | 79 ++++++++++++++-- backend/services/waechter.py | 2 + backend/tests/test_docker.py | 92 +++++++++++++++++++ backend/tests/test_partner.py | 2 +- .../components/homelab/DockerDienste.test.tsx | 37 ++++++++ .../src/components/homelab/DockerDienste.tsx | 74 +++++++++++++++ .../src/components/homelab/NeuEntdeckt.tsx | 33 ++++++- frontend/src/lib/abfragen.ts | 14 ++- frontend/src/lib/typen.ts | 31 +++++++ frontend/src/views/Homelab.tsx | 13 ++- 13 files changed, 480 insertions(+), 21 deletions(-) create mode 100644 backend/services/homelab/docker.py create mode 100644 backend/tests/test_docker.py create mode 100644 frontend/src/components/homelab/DockerDienste.test.tsx create mode 100644 frontend/src/components/homelab/DockerDienste.tsx diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index 83eba04..87a9a00 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -12,7 +12,8 @@ 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) 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 GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers @@ -28,6 +29,7 @@ Dünn: die Logik liegt in services/homelab/. import asyncio import json import logging +import threading import time from fastapi import APIRouter, Depends, Header, HTTPException, Request @@ -168,7 +170,14 @@ def speicher_lage() -> dict: def neu_entdeckt() -> dict: """Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026).""" 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): @@ -308,6 +317,7 @@ def bericht(daten: dict) -> dict: try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf entdecken.bericht_gesehen(daten) katalog.ergaenzen(daten) + threading.Thread(target=entdecken.docker_abgleichen, name="docker-abgleich", daemon=True).start() except Exception: log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True) return {"ok": True} diff --git a/backend/services/homelab/arcane.py b/backend/services/homelab/arcane.py index cd1339b..11a27cf 100644 --- a/backend/services/homelab/arcane.py +++ b/backend/services/homelab/arcane.py @@ -200,6 +200,25 @@ def _images_mit_update(url: str) -> list[dict]: 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: """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.""" diff --git a/backend/services/homelab/docker.py b/backend/services/homelab/docker.py new file mode 100644 index 0000000..1227eb7 --- /dev/null +++ b/backend/services/homelab/docker.py @@ -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 diff --git a/backend/services/homelab/entdecken.py b/backend/services/homelab/entdecken.py index 0ecf577..f20d9ee 100644 --- a/backend/services/homelab/entdecken.py +++ b/backend/services/homelab/entdecken.py @@ -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. • „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. +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. 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. @@ -33,20 +35,23 @@ ZEITLIMIT_S = 60 _lock = threading.Lock() -def _pfad() -> Path: - return einstellungen().daten_dir / "geraete-bekannt.json" +def _pfad(name: str = "geraete-bekannt.json") -> Path: + return einstellungen().daten_dir / name -def _lesen() -> dict | None: +DOCKER = "docker-bekannt.json" + + +def _lesen(name: str = "geraete-bekannt.json") -> dict | None: try: - daten = json.loads(_pfad().read_text(encoding="utf-8")) + daten = json.loads(_pfad(name).read_text(encoding="utf-8")) except (OSError, ValueError): return None return daten if isinstance(daten, dict) else None -def _schreiben(daten: dict) -> None: - pfad = _pfad() +def _schreiben(daten: dict, name: str = "geraete-bekannt.json") -> None: + pfad = _pfad(name) pfad.parent.mkdir(parents=True, exist_ok=True) tmp = pfad.with_suffix(".tmp") tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") @@ -127,18 +132,74 @@ def neue() -> list[dict]: return liste -def _markieren(zid: str) -> None: +def _markieren(zid: str, name: str = "geraete-bekannt.json") -> None: with _lock: - register = _lesen() or {} + register = _lesen(name) or {} if zid in register: 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:“), 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: """POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“.""" if wahl not in 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) if not geraet: return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."} diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 3b8a17a..24d3f92 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -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 () # 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 () +# 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]"), "homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE] diff --git a/backend/tests/test_docker.py b/backend/tests/test_docker.py new file mode 100644 index 0000000..03091f5 --- /dev/null +++ b/backend/tests/test_docker.py @@ -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" diff --git a/backend/tests/test_partner.py b/backend/tests/test_partner.py index 8854bc9..d222ee6 100644 --- a/backend/tests/test_partner.py +++ b/backend/tests/test_partner.py @@ -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", "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): diff --git a/frontend/src/components/homelab/DockerDienste.test.tsx b/frontend/src/components/homelab/DockerDienste.test.tsx new file mode 100644 index 0000000..eca5e31 --- /dev/null +++ b/frontend/src/components/homelab/DockerDienste.test.tsx @@ -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 => ({ + 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() + 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() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/frontend/src/components/homelab/DockerDienste.tsx b/frontend/src/components/homelab/DockerDienste.tsx new file mode 100644 index 0000000..fefffa6 --- /dev/null +++ b/frontend/src/components/homelab/DockerDienste.tsx @@ -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 = { + 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 ( +
  • + + + {c.name} + {c.image} + + + {zustandText(c)} + {u.text} + +
  • + ) +} + +export function DockerDienste({ lage }: { lage: DockerLage }) { + if (!lage.verfuegbar || lage.stacks.length === 0) return null + return ( +
    + {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 ( +
    +
    +

    {s.name}

    + + {laufen} von {s.container.length} laufen{neu ? ` · ${neu} mit Update` : ""} + +
    +
      + {s.container.map((c) => )} +
    +
    + ) + })} +
    + ) +} diff --git a/frontend/src/components/homelab/NeuEntdeckt.tsx b/frontend/src/components/homelab/NeuEntdeckt.tsx index 3495c3c..90a41de 100644 --- a/frontend/src/components/homelab/NeuEntdeckt.tsx +++ b/frontend/src/components/homelab/NeuEntdeckt.tsx @@ -5,7 +5,7 @@ import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol" import { Panel } from "@/components/cockpit/Panel" import { Button } from "@/components/ui/button" 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" // „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 = { }, } -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 ( +
  • +
    + +
    + Docker-Stack {s.name} + + unter Arcane{s.erstmals ? ` · entdeckt ${flugplanZeit(new Date(s.erstmals * 1000).toISOString()).toLowerCase()}` : ""} + +
    +
    +

    Container: {s.container.join(", ") || "–"}

    + +
  • + ) +} + +export function NeuEntdeckt({ geraete, stacks = [], gesperrt, onWahl }: { geraete: NeuesGeraet[] + /** Neue Docker-Stacks unter Arcane (seit 25.09.2026). */ + stacks?: NeuerStack[] gesperrt: boolean onWahl: (id: string, wahl: NeuWahl) => void }) { 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 (

    - {geraete.length === 1 ? "Ein Gerät kennt" : `${geraete.length} Geräte kennt`} der Orchestrator noch nicht. Entscheide, - ob er {geraete.length === 1 ? "es" : "sie"} nur beobachtet oder auch aktualisieren darf. + {anzahl === 1 ? "Eins kennt" : `${anzahl} kennt`} der Orchestrator noch nicht. Entscheide, ob er + {anzahl === 1 ? " es" : " sie"} nur beobachtet oder auch aktualisieren darf.

      {geraete.map((g) => ( (wahl === "beobachten" ? onWahl(g.id, wahl) : setFrage({ g, wahl }))} /> ))} + {stacks.map((s) => onWahl(s.id, wahl)} />)}
    export const useNeuEntdeckt = (aktiv: boolean) => useQuery({ 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("/api/homelab/docker"), enabled: aktiv, refetchInterval: takt(60_000), retry: false, diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 1ab1325..ea22119 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -656,6 +656,37 @@ export interface NeuesGeraet { 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) ----------------------------------- export interface Zertifikat { diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index e93e6bb..226a731 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -2,6 +2,7 @@ import { Link, useNavigate } from "@tanstack/react-router" import { ArrowRight } from "lucide-react" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" +import { DockerDienste } from "@/components/homelab/DockerDienste" import { Gesundheit } from "@/components/homelab/Gesundheit" import { Lagebild } from "@/components/homelab/Lagebild" import { NeuEntdeckt } from "@/components/homelab/NeuEntdeckt" @@ -10,7 +11,8 @@ import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellau import { UpdateTabelle } from "@/components/homelab/UpdateTabelle" import { Zertifikate } from "@/components/homelab/Zertifikate" import { - useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate, + useHomelab, useHomelabDocker, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, + useHomelabZertifikate, useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe, useZielAktion, } from "@/lib/abfragen" @@ -202,6 +204,7 @@ export function HomelabSeite() { const zertifikate = useHomelabZertifikate(!!stand) const probe = useSicherungsProbe() const neu = useNeuEntdeckt(!!stand) + const docker = useHomelabDocker(!!stand) const uebernehmen = useNeuUebernehmen() if (ersatz || !stand) return ersatz @@ -226,7 +229,8 @@ export function HomelabSeite() { /> {hinweise.length > 0 && } - uebernehmen.mutate({ id, wahl })} /> CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.

    )}
    + {docker.data?.verfuegbar && docker.data.stacks.length > 0 && ( + + + + )} {speicher.data?.bericht && (