phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete

- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil
  ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten
  Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig.
- services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen
  Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot ->
  Update -> Pruefung -> bei Rot zurueck + dringende Meldung
- Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht
- kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen)
- Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber
  /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage
- Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch
  nicht ausgefuehrt, jeder Schritt braucht das User-OK
- frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 18:39:59 +02:00
co-authored by Claude Opus 5.5
parent a47489fa85
commit 5e9227c4a3
66 changed files with 2472 additions and 170 deletions
+6
View File
@@ -72,6 +72,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
from services import jobengine from services import jobengine
await asyncio.to_thread(jobengine.wiederaufnehmen) await asyncio.to_thread(jobengine.wiederaufnehmen)
tasks = _box_hintergrund() if ROLLE == "box" else [] tasks = _box_hintergrund() if ROLLE == "box" else []
if ROLLE == "homelab":
from services.homelab import updates
updates.unterbrochene_abschliessen() # Läufe des vorigen Prozesses gelten als unterbrochen
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
app.state.gw_client = httpx.AsyncClient( app.state.gw_client = httpx.AsyncClient(
@@ -160,6 +163,9 @@ app.include_router(health.router)
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
if ROLLE == "box": if ROLLE == "box":
_box_router(app) _box_router(app)
else:
from routers import homelab
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
+44
View File
@@ -0,0 +1,44 @@
"""GitHub-Abfragen mit Zwischenspeicher — für beide Rollen (Box: Motor und llama-swap, Homelab: die Apps).
GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte früher
neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
"""
import os
import re
import threading
import time
import httpx
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
_cache: dict[str, tuple[float, object]] = {}
_lock = threading.Lock()
def github_json(pfad: str, timeout: float = 8) -> object:
"""GET https://api.github.com/<pfad>, 15 Minuten gemerkt. Wirft bei HTTP-Fehlern."""
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
return eintrag[1]
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
r.raise_for_status()
daten = r.json()
with _lock:
_cache[pfad] = (jetzt, daten)
return daten
_VERSION = re.compile(r"^v?(\d+(?:\.\d+)+|\d{4}-\d{2}-\d{2}(?:-r\d+)?)", re.IGNORECASE)
def neueste_version(repo: str) -> dict | None:
"""Neueste Veröffentlichung eines Repos: {"tag", "version", "datum"} oder None. Manche Projekte
veröffentlichen „untagged“ und tragen die Version nur im Namen (PVE Scripts Local, 09/2026)."""
daten = github_json(f"repos/{repo}/releases/latest")
if not isinstance(daten, dict) or not daten.get("tag_name"):
return None
tag, name = str(daten["tag_name"]), str(daten.get("name") or "")
roh = tag if _VERSION.match(tag) or not _VERSION.match(name) else name
return {"tag": roh, "version": roh.lstrip("vV"), "datum": str(daten.get("published_at") or "")[:10] or None}
+1
View File
@@ -23,6 +23,7 @@ class Aktion:
methode: str # "POST" methode: str # "POST"
pfad: str # "/api/maintenance/engine-update" pfad: str # "/api/maintenance/engine-update"
frage: str # Rückfrage vor dem Klick frage: str # Rückfrage vor dem Klick
label: str = "Jetzt updaten"
@dataclass @dataclass
+96
View File
@@ -0,0 +1,96 @@
"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
Dünn: die Logik liegt in services/homelab/.
"""
import time
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from services.homelab import inventar, kanal, updates
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
if not kanal.token_gueltig(x_mc2_ausfuehrer):
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
kanal.kontakt()
@router.get("/ziele")
def ziele() -> dict:
return inventar.ziele()
def _antwort(ergebnis: dict) -> dict:
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
return ergebnis
@router.post("/ziele/{ziel_id}/update")
def update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
@router.post("/ziele/{ziel_id}/os-update")
def os_update(ziel_id: str) -> dict:
return _antwort(updates.starten(ziel_id, "os"))
@router.post("/ziele/pve/neustart")
def host_neustart() -> dict:
return _antwort(updates.starten("pve", "neustart"))
@router.post("/ziele/{ziel_id}/suchen")
def suchen(ziel_id: str) -> dict:
gast = inventar.gast(ziel_id)
if not gast or not gast.get("erlaubt"):
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
@router.get("/laeufe")
def laeufe() -> dict:
return {"laeufe": updates.laeufe()}
@router.get("/ausfuehrer")
def ausfuehrer() -> dict:
zuletzt = kanal.zuletzt()
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"auftraege": kanal.liste()[:20]}
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
def auftrag_abholen() -> dict:
return kanal.naechster() or {}
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
def bericht(daten: dict) -> dict:
kanal.bericht_speichern(daten)
return {"ok": True}
class Ergebnis(BaseModel):
code: int
text: str = ""
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
+2 -2
View File
@@ -20,13 +20,13 @@ import time
from pathlib import Path from pathlib import Path
import httpx import httpx
from config import MODELS_DIR from kern.einstellungen import einstellungen
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh") NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json"))) STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(einstellungen().daten_dir / "mc2-announce.json")))
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200")) MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses # Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
+7
View File
@@ -0,0 +1,7 @@
"""Der Homelab-Teil (Rolle homelab, Phase 3/4): Proxmox-Host, Container und Arcane.
apps.py was in welchem Container steckt, wo seine Oberfläche liegt, woher die neueste Version kommt
kanal.py Aufträge und Berichte des Ausführers auf dem Proxmox-Host (gemeinsames Geheimnis)
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
updates.py „Jetzt updaten“: Snapshot → Update → Prüfung → bei Rot zurück, mit Meldung
"""
+47
View File
@@ -0,0 +1,47 @@
"""Was in welchem Container steckt (Community-Scripts-Kennung aus /usr/bin/update im Gast).
Stand der Bestandsaufnahme vom 24.09.2026: sechs Container, alle mit dem Etikett community-script.
Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class App:
kennung: str
name: str
quelle: str | None = None # GitHub owner/repo für die neueste Version; None = über die Pakete des Gasts
vergleich: str = "version" # „version“ oder „datum“ (Docker-Images ohne Versionsnummer)
port: int | None = None # Weboberfläche; None = keine (z. B. NetBird-Client)
https: bool = False
pfad: str = "/"
KATALOG: dict[str, App] = {a.kennung: a for a in (
App("adguard", "AdGuard Home", "AdguardTeam/AdGuardHome", port=8080),
App("npmplus", "NPMplus", "ZoeyVid/NPMplus", vergleich="datum", port=81, https=True),
App("netbird", "NetBird", "netbirdio/netbird"),
App("pve-scripts-local", "PVE Scripts Local", "community-scripts/ProxmoxVE-Local", port=3000),
App("gitea", "Gitea", "go-gitea/gitea", port=3000),
App("proxmox-backup-server", "Proxmox Backup Server", port=8007, https=True),
)}
# Gäste, die keine Community-Scripts sind, erkennt man am Namen.
NACH_NAME: dict[str, App] = {
"arcane": App("arcane", "Arcane (Docker)", port=3552),
}
def app_fuer(kennung: str | None, name: str | None) -> App | None:
if kennung and kennung in KATALOG:
return KATALOG[kennung]
return NACH_NAME.get(str(name or "").lower())
def adresse(app: App | None, ip: str | None) -> str | None:
"""Adresse der Weboberfläche, falls es eine gibt und die IP bekannt ist."""
if not app or not app.port or not ip:
return None
return f"{'https' if app.https else 'http'}://{ip}:{app.port}{app.pfad}"
+209
View File
@@ -0,0 +1,209 @@
"""Vom Bericht des Ausführers zu Zielen im gemeinsamen Modell (kern/ziele.py) — Phase 3, 24.09.2026.
Je Gerät ein Ziel: der Proxmox-Host (Pakete, Neustart) und jeder freigegebene Gast (Etikett
community-script oder watcher, nicht watcher-aus) mit seinen Bausteinen „App“ (Community-Script,
Vergleich mit der neuesten Veröffentlichung auf GitHub) und „Pakete“ (Betriebssystem im Gast).
Dazu prüft dieser Teil selbst, ob die Weboberfläche jedes Gasts antwortet.
Wie ehrlich der Stand ist, steht dabei: Sind die Paketlisten im Gast älter als zwei Wochen, heißt es
„unbekannt“ (mit Knopf zum Suchen) statt „aktuell“; ist der Bericht älter als eine halbe Stunde, sagt
die Übersicht, dass der Ausführer schweigt.
"""
import re
import threading
import time
from datetime import datetime
import httpx
from kern.github import neueste_version
from kern.zeit import LOCAL_TZ
from kern.ziele import Aktion, BausteinStand, ZielStand
from services.homelab import apps, kanal
BERICHT_ALT_S = 30 * 60
LISTEN_ALT_S = 14 * 24 * 3600
ERREICHBAR_CACHE_S = 60
_erreichbar_cache: dict[str, tuple[float, bool]] = {}
_erreichbar_lock = threading.Lock()
def erreichbar(url: str) -> bool:
"""Antwortet die Weboberfläche? Alles unter 500 zählt (eine Anmeldeseite ist eine Antwort)."""
jetzt = time.time()
with _erreichbar_lock:
if (eintrag := _erreichbar_cache.get(url)) and jetzt - eintrag[0] < ERREICHBAR_CACHE_S:
return eintrag[1]
try:
# Selbstsignierte Zertifikate sind im Heimnetz üblich (PBS, NPMplus); geprüft wird nur, ob jemand antwortet.
ok = httpx.get(url, timeout=5.0, verify=False, follow_redirects=False).status_code < 500
except httpx.HTTPError:
ok = False
with _erreichbar_lock:
_erreichbar_cache[url] = (jetzt, ok)
return ok
def erreichbar_frisch(url: str) -> bool:
"""Wie erreichbar(), aber ohne den Zwischenspeicher — für die Prüfung direkt nach einem Update."""
with _erreichbar_lock:
_erreichbar_cache.pop(url, None)
return erreichbar(url)
def _teile(version: str) -> tuple[int, ...]:
return tuple(int(x) for x in re.findall(r"\d+", version.split("-")[0])[:4])
def neuer(verfuegbar: str, installiert: str) -> bool | None:
"""Ist verfuegbar neuer als installiert? None, wenn sich das nicht sagen lässt."""
a, b = _teile(verfuegbar), _teile(installiert)
if not a or not b:
return None
return a > b
def _datum(ts: float | None) -> str:
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.%Y") if ts else "?"
def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
installiert = (gast.get("app") or {}).get("version")
stand = BausteinStand(id="app", name=app.name, zustand="aktuell", installiert=installiert)
if not app.quelle:
return stand
try:
neu = neueste_version(app.quelle)
except Exception:
neu = None
if not neu:
stand.zustand, stand.grund = "unbekannt", "Die neueste Version ist gerade nicht abrufbar (GitHub)."
return stand
stand.verfuegbar = neu["version"]
if app.vergleich == "datum":
# Docker-Image ohne Versionsnummer: neuer, wenn die Veröffentlichung jünger ist als das Image.
bild = re.search(r"\d{4}-\d{2}-\d{2}", installiert or "")
ist_neuer = (neu["datum"] or "") > bild.group(0) if bild else None
else:
ist_neuer = neuer(neu["version"], installiert) if installiert else None
if ist_neuer is None:
stand.zustand, stand.grund = "unbekannt", "Die installierte Version ließ sich nicht lesen."
elif ist_neuer:
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
rueckweg = ("Vorher wird ein Snapshot angelegt; ist die Prüfung danach rot, geht es von selbst zurück."
if gast.get("snapshot_moeglich") else
"Ein Snapshot ist hier nicht möglich — scheitert das Update, gibt es keinen automatischen Rückweg.")
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/update", f"{app.name} jetzt aktualisieren? {rueckweg}")
return stand
def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
os_stand = gast.get("os_updates") or {}
stand = BausteinStand(id="os", name="Pakete", zustand="aktuell")
anzahl, listen = os_stand.get("anzahl"), os_stand.get("listen_stand")
suchen = Aktion("POST", f"/api/homelab/ziele/{zid}/suchen", "Die Paketlisten im Gast jetzt erneuern?",
label="Nach Updates suchen")
if anzahl is None:
stand.zustand, stand.grund = "unbekannt", "Die Pakete ließen sich nicht lesen."
elif listen and jetzt - listen > LISTEN_ALT_S:
stand.zustand, stand.aktion = "unbekannt", suchen
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
elif anzahl > 0:
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
f"{anzahl} Pakete im Gast jetzt einspielen? Vorher wird, wo möglich, ein Snapshot angelegt.")
return stand
def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
art = "container" if gast["art"] == "lxc" else "vm"
zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}"
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
url = apps.adresse(app, gast.get("ip"))
laeuft = gast.get("status") == "running"
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
rueckweg=("Snapshot vor jedem Update" if gast.get("snapshot_moeglich")
else gast.get("snapshot_grund") or "kein Snapshot möglich"))
if not laeuft:
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
grund=f"Der Gast ist {gast.get('status') or 'unbekannt'}."))
return ziel
if not gast.get("erlaubt"):
ziel.bausteine.append(BausteinStand(
id="freigabe", name="Freigabe", zustand="unbekannt",
grund="Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt)."))
return ziel
if app and app.kennung in apps.KATALOG:
ziel.bausteine.append(_app_baustein(gast, app, zid))
if gast["art"] == "lxc":
ziel.bausteine.append(_os_baustein(gast, zid, jetzt))
return ziel
def _host_ziel(host: dict) -> ZielStand:
ziel = ZielStand(id="pve", name="Proxmox-Host", art="proxmox-host", instanz="homelab", erreichbar=True,
rueckweg="Kein Snapshot möglich; der Host bleibt beim Update in Betrieb, neu gestartet wird getrennt.")
updates = host.get("updates")
pakete = BausteinStand(id="pakete", name="Proxmox VE und Debian", zustand="aktuell",
installiert=host.get("version"))
if updates is None:
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
elif updates:
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
ziel.bausteine.append(pakete)
if host.get("neustart_noetig"):
ziel.bausteine.append(BausteinStand(
id="neustart", name="Neustart", zustand="neu", kurz="steht an",
aktion=Aktion("POST", "/api/homelab/ziele/pve/neustart",
"Den Proxmox-Host jetzt neu starten? ALLE Container und die Arcane-VM sind dann ein paar "
"Minuten weg. Die KI-Box prüft danach von außen, ob alles wiederkommt.",
label="Jetzt neu starten")))
return ziel
def ziele() -> dict:
"""Alle Ziele des Homelabs aus dem letzten Bericht, dazu wie frisch er ist."""
eingang = kanal.bericht()
jetzt = time.time()
zuletzt = kanal.zuletzt()
ausfuehrer = {"verbunden": bool(zuletzt and jetzt - zuletzt < BERICHT_ALT_S), "zuletzt": zuletzt}
if not eingang:
return {"ziele": [], "bericht": None, "ausfuehrer": ausfuehrer}
b = eingang["bericht"]
liste = [_host_ziel(b.get("host") or {})]
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or []]
for z in liste:
z.geprueft = eingang["empfangen"]
return {"ziele": [z.als_dict() for z in liste],
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
"ausfuehrer": ausfuehrer}
def erreichbarkeit() -> list[dict]:
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen."""
eingang = kanal.bericht()
ergebnis = []
for g in (eingang or {}).get("bericht", {}).get("gaeste") or []:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
url = apps.adresse(app, g.get("ip"))
if url and g.get("erlaubt"):
zid = f"{'ct' if g['art'] == 'lxc' else 'vm'}-{g['vmid']}"
ergebnis.append({"id": zid, "name": app.name if app else g.get("name"), "url": url,
"laeuft": g.get("status") == "running",
"ok": g.get("status") == "running" and erreichbar(url)})
return ergebnis
def gast(zid: str) -> dict | None:
"""Der Gast zu einer Ziel-ID aus dem letzten Bericht (ct-104 → vmid 104)."""
m = re.fullmatch(r"(ct|vm)-(\d+)", zid)
eingang = kanal.bericht()
if not m or not eingang:
return None
return next((g for g in eingang["bericht"].get("gaeste") or [] if g.get("vmid") == int(m.group(2))), None)
+170
View File
@@ -0,0 +1,170 @@
"""Kanal zum Ausführer auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
Der Ausführer (deploy/homelab/ausfuehrer.py) holt sich Aufträge hier ab, liefert die Ergebnisse und
alle zehn Minuten einen Bericht. Er weist sich mit einem gemeinsamen Geheimnis aus (Kopfzeile
X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/ausfuehrer.token (nur
für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc/mc2-ausfuehrer.json.
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
"""
import hmac
import json
import os
import secrets
import threading
import time
import uuid
from pathlib import Path
from kern.einstellungen import einstellungen
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 60 # so viele erledigte Aufträge bleiben sichtbar
_lock = threading.Lock()
_kontakt: dict = {"zuletzt": None}
def _dir() -> Path:
return einstellungen().daten_dir
def _json_schreiben(pfad: Path, daten: object) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
tmp.replace(pfad)
def _json_lesen(pfad: Path, leer: object) -> object:
try:
return json.loads(pfad.read_text(encoding="utf-8"))
except (OSError, ValueError):
return leer
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
def token() -> str:
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
return wert
pfad = _dir() / "ausfuehrer.token"
try:
return pfad.read_text(encoding="utf-8").strip()
except OSError:
pass
wert = secrets.token_urlsafe(32)
pfad.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
return pfad.read_text(encoding="utf-8").strip()
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(wert + "\n")
return wert
def token_gueltig(wert: str | None) -> bool:
return bool(wert) and hmac.compare_digest(str(wert), token())
def kontakt() -> None:
_kontakt["zuletzt"] = time.time()
def zuletzt() -> float | None:
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
# --- Bericht ------------------------------------------------------------------------------
def bericht_speichern(daten: dict) -> None:
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": time.time(), "bericht": daten})
def bericht() -> dict | None:
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
daten = _json_lesen(_dir() / "pve-bericht.json", None)
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
# --- Aufträge -------------------------------------------------------------------------------
def _pfad() -> Path:
return _dir() / "ausfuehrer-auftraege.json"
def _alle() -> list[dict]:
daten = _json_lesen(_pfad(), [])
return daten if isinstance(daten, list) else []
def _merken(auftraege: list[dict]) -> None:
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
def anlegen(aktion: str, parameter: dict | None = None) -> str:
if aktion not in AKTIONEN:
raise ValueError(f"Unbekannte Aktion: {aktion}")
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
"code": None, "text": None}
with _lock:
auftraege = _alle()
auftraege.append(auftrag)
_merken(auftraege)
return auftrag["id"]
def naechster() -> dict | None:
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
vorher als verloren abgeschlossen."""
jetzt = time.time()
with _lock:
auftraege = _alle()
for a in auftraege:
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
if auftrag:
auftrag.update(status="laeuft", abgeholt=jetzt)
_merken(auftraege)
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
with _lock:
auftraege = _alle()
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
if auftrag is None or auftrag["status"] != "laeuft":
return False
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
text=str(text)[-20000:])
_merken(auftraege)
return True
def auftrag(auftrag_id: str) -> dict | None:
return next((a for a in _alle() if a["id"] == auftrag_id), None)
def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None:
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
ende = time.time() + zeitlimit_s
while time.time() < ende:
a = auftrag(auftrag_id)
if a and a["status"] not in ("wartet", "laeuft"):
return a
time.sleep(takt_s)
return None
def liste() -> list[dict]:
return list(reversed(_alle()))
+212
View File
@@ -0,0 +1,212 @@
"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026).
Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück
und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf.
Ablauf für einen Gast:
1. Snapshot (wo möglich; beim PBS verhindert der Bind-Mount ihn — dann ohne Rückweg, die Rückfrage sagt es)
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts
3. 20 s warten, frischen Bericht holen
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
5. Rot und Snapshot da → zurück auf den Snapshot → dringende Meldung.
Grün → ältere Snapshots des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
Jeder Lauf steht in <Datenordner>/homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel
läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen.
"""
import json
import logging
import re
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services import announce, update_verlauf
from services.homelab import apps, inventar, kanal
log = logging.getLogger(__name__)
WARTEN_NACH_UPDATE_S = 20
ZEITLIMIT_UPDATE_S = 45 * 60
ZEITLIMIT_KURZ_S = 15 * 60
BETREFF = "[Homelab-Update]"
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "homelab-laeufe.json"
def _laeufe() -> list[dict]:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
return daten if isinstance(daten, list) else []
def _merken(lauf: dict) -> None:
with _lock:
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
_pfad().parent.mkdir(parents=True, exist_ok=True)
tmp = _pfad().with_suffix(".tmp")
tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8")
tmp.replace(_pfad())
def laeufe(anzahl: int = 20) -> list[dict]:
return list(reversed(_laeufe()))[:anzahl]
def unterbrochene_abschliessen() -> None:
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess."""
for lauf in _laeufe():
if lauf.get("status") == "laeuft":
lauf.update(status="unterbrochen", ende=time.time())
lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.")
_merken(lauf)
def _melden(text: str, dringend: bool = False) -> None:
try:
announce.notify_telegram("[Alarm] Homelab-Update" if dringend else BETREFF, text)
except Exception:
log.warning("homelab: Meldung ging nicht raus", exc_info=True)
def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict:
"""Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf."""
aid = kanal.anlegen(aktion, parameter)
lauf["schritte"].append(f"{aktion} …")
_merken(lauf)
a = kanal.warten(aid, zeitlimit_s)
if a is None:
raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten")
if a["status"] != "fertig":
raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}")
lauf["schritte"][-1] = f"{aktion}: ok"
return a
def _frischer_gast(lauf: dict, vmid: int) -> dict | None:
try:
a = _auftrag(lauf, "bericht", {}, 5 * 60)
bericht = json.loads(a.get("text") or "{}")
kanal.bericht_speichern(bericht)
except (RuntimeError, ValueError):
return None
return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None)
def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]:
"""Was nach dem Update nicht stimmt (leer = grün)."""
if nachher is None:
return ["Der Gast fehlt im neuen Bericht."]
fehler = []
if nachher.get("status") != "running":
fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).")
app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name"))
url = apps.adresse(app, nachher.get("ip"))
if url and not inventar.erreichbar_frisch(url):
fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.")
if baustein == "app":
alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version")
if alt and neu and alt == neu:
fehler.append(f"Die App-Version ist unverändert ({alt}).")
return fehler
def _gast_lauf(lauf: dict, gast: dict) -> None:
vmid, baustein = gast["vmid"], lauf["baustein"]
snapshot = None
try:
if gast.get("snapshot_moeglich"):
a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S)
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
snapshot = m.group(1) if m else None
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S)
time.sleep(WARTEN_NACH_UPDATE_S)
fehler = pruefen(gast, _frischer_gast(lauf, vmid), baustein)
except RuntimeError as exc:
fehler = [str(exc)]
if not fehler:
_aufraeumen(lauf, vmid, behalten=snapshot)
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
return
if snapshot:
try:
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, ZEITLIMIT_KURZ_S)
_ende(lauf, "zurueckgerollt",
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — automatisch zurück auf den Snapshot "
f"{snapshot}. Läuft wieder wie vorher.", dringend=True)
return
except RuntimeError as exc:
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
+ ("" if snapshot else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
dringend=True)
def _aufraeumen(lauf: dict, vmid: int, behalten: str | None) -> None:
"""Ältere Snapshots des Orchestrators für diesen Gast löschen; den neuesten behalten."""
gast = inventar.gast(lauf["ziel"]) or {}
for name in gast.get("snapshots") or []:
if name.startswith("mc2-") and name != behalten:
try:
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
except RuntimeError:
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
def _host_lauf(lauf: dict) -> None:
try:
if lauf["baustein"] == "neustart":
_auftrag(lauf, "host_neustart", {}, 60)
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
return
_auftrag(lauf, "host_update", {}, 60 * 60)
_auftrag(lauf, "bericht", {}, 5 * 60)
_ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.")
except RuntimeError as exc:
_ende(lauf, "fehler", f"Proxmox-Host: Update gescheitert — {exc}", dringend=True)
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time())
_merken(lauf)
try:
start = datetime.fromtimestamp(lauf["start"], LOCAL_TZ).isoformat(timespec="seconds")
update_verlauf.eintragen(start, "Update von Hand", lauf["ziel"], ergebnis, text)
except Exception:
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
_melden(text, dringend=dringend)
def starten(ziel_id: str, baustein: str) -> dict:
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
if baustein not in ("app", "os", "pakete", "neustart"):
return {"ok": False, "detail": "Unbekannter Baustein."}
if any(x.get("ziel") == ziel_id and x.get("status") == "laeuft" for x in _laeufe()):
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
if ziel_id == "pve":
name, gast = "Proxmox-Host", None
else:
gast = inventar.gast(ziel_id)
if not gast:
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
if not gast.get("erlaubt"):
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
name = app.name if app else str(gast.get("name"))
lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft",
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []}
_merken(lauf)
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
return {"ok": True, "lauf": lauf["id"]}
+3 -15
View File
@@ -14,6 +14,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import httpx import httpx
from kern.github import github_json
from kern.zeit import LOCAL_TZ from kern.zeit import LOCAL_TZ
from services import jobengine, system, update_verlauf from services import jobengine, system, update_verlauf
@@ -51,21 +52,8 @@ _engine_cache = {"ts": 0.0, "avail": False}
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE) _ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
# GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte # GitHub-Abfragen mit 15 Minuten Zwischenspeicher (kern/github.py, seit 24.09.2026 für beide Rollen).
# bisher neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt. _github_json = github_json
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
_github_cache: dict[str, tuple[float, object]] = {}
def _github_json(pfad: str, timeout: float = 8) -> object:
jetzt = time.time()
if (eintrag := _github_cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
return eintrag[1]
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
r.raise_for_status()
daten = r.json()
_github_cache[pfad] = (jetzt, daten)
return daten
def _latest_engine_asset_release() -> dict | None: def _latest_engine_asset_release() -> dict | None:
+25 -2
View File
@@ -457,12 +457,35 @@ def pruefe_partner() -> list[Befund]:
quelle="partner", nach_takten=PARTNER_TAKTE)] quelle="partner", nach_takten=PARTNER_TAKTE)]
def pruefe_ausfuehrer() -> list[Befund]:
"""Homelab: Der Ausführer auf dem Proxmox-Host meldet sich alle zehn Minuten. Schweigt er, zeigt die
Übersicht nichts Neues mehr, und „Jetzt updaten“ bliebe liegen. Vor seinem ersten Bericht: kein Alarm."""
from services.homelab import inventar, kanal
if kanal.bericht() is None:
return []
zuletzt = kanal.zuletzt()
if zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S:
return []
return [Befund(id="ausfuehrer", stufe="rot", titel="Der Ausführer auf dem Proxmox-Host schweigt",
text="Seit über 30 Minuten kein Bericht. Läuft mc2-ausfuehrer.service auf dem Proxmox-Host?",
quelle="ausfuehrer")]
def pruefe_gaeste() -> list[Befund]:
"""Homelab: Läuft jeder freigegebene Gast, und antwortet seine Weboberfläche?"""
from services.homelab import inventar
return [Befund(id=f"gast:{e['id']}", stufe="rot", titel=f"{e['name']} antwortet nicht",
text=(f"Die Weboberfläche ({e['url']}) antwortet nicht." if e["laeuft"]
else "Der Gast läuft nicht."), quelle=e["id"])
for e in inventar.erreichbarkeit() if not e["ok"]]
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil # Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
# bekommt seine Prüfungen mit den Homelab-Bausteinen. # den Ausführer auf dem Proxmox-Host und seine Gäste.
PRUEFUNGEN = { PRUEFUNGEN = {
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte, "box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
pruefe_festgehalten, pruefe_partner), pruefe_festgehalten, pruefe_partner),
"homelab": (pruefe_platte, pruefe_partner), "homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste),
}[ROLLE] }[ROLLE]
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]
+215
View File
@@ -0,0 +1,215 @@
{
"zeit": "2026-09-24T16:26:04+00:00",
"host": {
"node": "pve",
"neustart_noetig": false,
"version": "9.2.11",
"updates": [
{
"paket": "libvirt-common",
"alt": "11.3.0-3+deb13u2",
"neu": "11.3.0-3+deb13u3",
"herkunft": "Debian"
},
{
"paket": "librados2",
"alt": "19.2.3-pve1",
"neu": "19.2.6-pve4",
"herkunft": "Proxmox"
},
{
"paket": "libperl5.40",
"alt": "5.40.1-6",
"neu": "5.40.1-6+deb13u1",
"herkunft": "Debian"
},
{
"paket": "pve-docs",
"alt": "9.2.4",
"neu": "9.2.12",
"herkunft": "Proxmox"
},
{
"paket": "pve-edk2-firmware-ovmf",
"alt": "4.2025.05-3",
"neu": "4.2026.08-1",
"herkunft": "Proxmox"
}
],
"kernel": "7.0.14-12-pve"
},
"gaeste": [
{
"vmid": 100,
"art": "lxc",
"name": "adguard",
"status": "running",
"etiketten": [
"adblock",
"community-script"
],
"erlaubt": true,
"uptime": 3366322,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.100",
"app": {
"kennung": "adguard",
"version": "0.107.79"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1761157469
}
},
{
"vmid": 101,
"art": "lxc",
"name": "npmplus",
"status": "running",
"etiketten": [
"community-script",
"nginx",
"proxy"
],
"erlaubt": true,
"uptime": 2868368,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "alpine",
"ip": "192.168.178.110",
"app": {
"kennung": "npmplus",
"version": "Image vom 2026-08-27"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1787398997
}
},
{
"vmid": 102,
"art": "lxc",
"name": "netbird",
"status": "running",
"etiketten": [
"community-script",
"network",
"vpn"
],
"erlaubt": true,
"uptime": 3366322,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.148",
"app": {
"kennung": "netbird",
"version": "0.77.1"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1787399037
}
},
{
"vmid": 103,
"art": "lxc",
"name": "pve-scripts-local",
"status": "running",
"etiketten": [
"community-script",
"pve-scripts-local"
],
"erlaubt": true,
"uptime": 1713361,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.149",
"app": {
"kennung": "pve-scripts-local",
"version": "0.5.8"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1777589263
}
},
{
"vmid": 104,
"art": "lxc",
"name": "gitea",
"status": "running",
"etiketten": [
"community-script",
"git"
],
"erlaubt": true,
"uptime": 3366321,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ostype": "debian",
"ip": "192.168.178.153",
"app": {
"kennung": "gitea",
"version": "1.27.2"
},
"os_updates": {
"anzahl": 0,
"listen_stand": 1781978337
}
},
{
"vmid": 105,
"art": "lxc",
"name": "proxmox-backup-server",
"status": "running",
"etiketten": [
"backup",
"community-script"
],
"erlaubt": true,
"uptime": 3366321,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": false,
"snapshot_grund": "Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich",
"ostype": "debian",
"ip": "192.168.178.156",
"app": {
"kennung": "proxmox-backup-server",
"version": "4.2.5-1"
},
"os_updates": {
"anzahl": 58,
"listen_stand": 1790207866
}
},
{
"vmid": 106,
"art": "qemu",
"name": "arcane",
"status": "running",
"etiketten": [],
"erlaubt": false,
"uptime": 1713735,
"snapshots": [],
"onboot": true,
"snapshot_moeglich": true,
"snapshot_grund": null,
"ip": "192.168.178.28"
}
]
}
+254
View File
@@ -0,0 +1,254 @@
"""Homelab-Teil (Phase 3/4, 24.09.2026): Kanal zum Ausführer, Ziele aus dem echten Bericht des
Proxmox-Hosts (tests/fixtures/pve-bericht.json, gelesen am 24.09.), „Jetzt updaten“ mit Rückweg."""
import importlib.util
import json
import os
import threading
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import inventar, kanal, updates
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.delenv("MC_AUSFUEHRER_TOKEN", raising=False)
einstellungen_mod.einstellungen.cache_clear()
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
# Keine Netzabfragen: neueste Versionen und Weboberflächen sind fest.
neueste = {"AdguardTeam/AdGuardHome": "0.107.79", "go-gitea/gitea": "1.27.3", "netbirdio/netbird": "0.79.0",
"community-scripts/ProxmoxVE-Local": "1.2.1"}
monkeypatch.setattr(inventar, "neueste_version", lambda repo: {
"tag": neueste.get(repo, "2026-07-24-r1"), "version": neueste.get(repo, "2026-07-24-r1"),
"datum": "2026-07-24"})
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
# --- Kanal ---------------------------------------------------------------------------------
def test_token_wird_einmal_erzeugt_und_geprueft(daten):
wert = kanal.token()
assert len(wert) > 30 and kanal.token() == wert
assert kanal.token_gueltig(wert) and not kanal.token_gueltig("falsch") and not kanal.token_gueltig(None)
if os.name == "posix":
assert (daten / "ausfuehrer.token").stat().st_mode & 0o077 == 0
def test_auftraege_der_reihe_nach_und_verloren(daten, monkeypatch):
erster = kanal.anlegen("bericht")
zweiter = kanal.anlegen("suchen", {"vmid": 104})
with pytest.raises(ValueError):
kanal.anlegen("rm -rf")
assert kanal.naechster()["id"] == erster
assert kanal.ergebnis(erster, 0, "ok") and not kanal.ergebnis(erster, 0, "doppelt")
assert kanal.naechster() == {"id": zweiter, "aktion": "suchen", "parameter": {"vmid": 104}}
monkeypatch.setattr(kanal, "VERLOREN_S", -1)
assert kanal.naechster() is None
assert kanal.auftrag(zweiter)["status"] == "verloren"
def _client() -> TestClient:
from routers import homelab
app = FastAPI()
app.include_router(homelab.router)
return TestClient(app)
def test_nur_der_ausfuehrer_darf_in_den_kanal(daten):
c = _client()
assert c.get("/api/homelab/ausfuehrer/auftrag").status_code == 403
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT,
headers={"X-MC2-Ausfuehrer": "falsch"}).status_code == 403
kopf = {"X-MC2-Ausfuehrer": kanal.token()}
assert c.post("/api/homelab/ausfuehrer/bericht", json=BERICHT, headers=kopf).json() == {"ok": True}
assert c.get("/api/homelab/ausfuehrer/auftrag", headers=kopf).json() == {}
assert c.get("/api/homelab/ausfuehrer").json()["verbunden"] is True
# --- Ziele aus dem echten Bericht ---------------------------------------------------------------
def _ziele(daten) -> dict[str, dict]:
kanal.bericht_speichern(BERICHT)
return {z["id"]: z for z in inventar.ziele()["ziele"]}
def test_ziele_aus_dem_echten_bericht(daten):
z = _ziele(daten)
assert list(z) == ["pve", "ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"]
host = {b["id"]: b for b in z["pve"]["bausteine"]}
assert host["pakete"]["zustand"] == "neu" and host["pakete"]["aktion"]["pfad"] == "/api/homelab/ziele/pve/update"
gitea = {b["id"]: b for b in z["ct-104"]["bausteine"]}
assert (gitea["app"]["zustand"], gitea["app"]["kurz"]) == ("neu", "1.27.2 → 1.27.3")
assert z["ct-104"]["name"] == "Gitea" and z["ct-104"]["rueckweg"] == "Snapshot vor jedem Update"
adguard = {b["id"]: b for b in z["ct-100"]["bausteine"]}
assert adguard["app"]["zustand"] == "aktuell"
# Paketlisten vom Oktober 2025: ehrlich „unbekannt“ mit Knopf zum Suchen statt „aktuell“.
assert adguard["os"]["zustand"] == "unbekannt" and adguard["os"]["aktion"]["label"] == "Nach Updates suchen"
pbs = {b["id"]: b for b in z["ct-105"]["bausteine"]}
assert (pbs["os"]["zustand"], pbs["os"]["kurz"]) == ("neu", "58 Pakete")
assert "Bind-Mount" in z["ct-105"]["rueckweg"]
assert {b["id"]: b for b in z["ct-103"]["bausteine"]}["app"]["kurz"] == "0.5.8 → 1.2.1"
assert {b["id"]: b for b in z["ct-101"]["bausteine"]}["app"]["zustand"] == "aktuell" # Image jünger
# NetBird hat keine Weboberfläche: erreichbar heißt hier „läuft“.
assert z["ct-102"]["erreichbar"] is True
arcane = z["vm-106"]
assert arcane["name"] == "Arcane (Docker)" and arcane["bausteine"][0]["id"] == "freigabe"
def test_ohne_bericht_leer_und_ehrlich(daten):
stand = inventar.ziele()
assert stand["ziele"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False
def test_wachter_sieht_stumme_gaeste(daten, monkeypatch):
from services import waechter
kanal.bericht_speichern(BERICHT)
monkeypatch.setattr(inventar, "erreichbar", lambda url: "153" not in url)
befunde = waechter.pruefe_gaeste()
assert [b.id for b in befunde] == ["gast:ct-104"] and befunde[0].titel == "Gitea antwortet nicht"
assert waechter.pruefe_ausfuehrer() == [] # Bericht ist frisch
# --- „Jetzt updaten“ mit einem nachgespielten Ausführer ----------------------------------------
def _ausfuehrer_spielen(stopp: threading.Event, neue_version: str, protokoll: list[str]) -> None:
while not stopp.is_set():
auftrag = kanal.naechster()
if not auftrag:
time.sleep(0.02)
continue
protokoll.append(auftrag["aktion"])
text = ""
if auftrag["aktion"] == "snapshot":
text = "snapshot=mc2-20260924-190000"
elif auftrag["aktion"] == "bericht":
neu = json.loads(json.dumps(BERICHT))
next(g for g in neu["gaeste"] if g["vmid"] == 104)["app"]["version"] = neue_version
text = json.dumps(neu)
kanal.ergebnis(auftrag["id"], 0, text)
@pytest.fixture
def ausfuehrer(daten, monkeypatch):
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
monkeypatch.setattr(kanal, "warten", _schnell_warten)
meldungen: list[tuple[str, bool]] = []
monkeypatch.setattr(updates, "_melden", lambda text, dringend=False: meldungen.append((text, dringend)))
verlauf: list[tuple] = []
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
kanal.bericht_speichern(BERICHT)
stopp, protokoll = threading.Event(), []
def starten(neue_version: str) -> None:
threading.Thread(target=_ausfuehrer_spielen, args=(stopp, neue_version, protokoll), daemon=True).start()
yield starten, protokoll, meldungen, verlauf
stopp.set()
_echtes_warten = kanal.warten
def _schnell_warten(auftrag_id, zeitlimit_s, takt_s=2.0):
return _echtes_warten(auftrag_id, min(zeitlimit_s, 10), 0.02)
def _auf_ende(lauf_id: str) -> dict:
ende = time.time() + 20
while time.time() < ende:
lauf = next((x for x in updates.laeufe() if x["id"] == lauf_id), {})
if lauf.get("status") == "fertig":
return lauf
time.sleep(0.05)
raise AssertionError(f"Lauf {lauf_id} wurde nicht fertig")
def test_update_gruen(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, verlauf = ausfuehrer
starten("1.27.3")
antwort = updates.starten("ct-104", "app")
assert antwort["ok"]
assert updates.starten("ct-104", "app")["detail"] == "Für dieses Gerät läuft schon ein Update."
lauf = _auf_ende(antwort["lauf"])
assert lauf["ergebnis"] == "eingespielt" and protokoll == ["snapshot", "update", "bericht"]
assert meldungen == [("Gitea: eingespielt, Prüfung grün.", False)]
assert verlauf[0][2:4] == ("ct-104", "eingespielt")
def test_update_rot_geht_zurueck_und_meldet_dringend(ausfuehrer, monkeypatch):
starten, protokoll, meldungen, _ = ausfuehrer
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
starten("1.27.3")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt"
assert protokoll == ["snapshot", "update", "bericht", "zurueck"]
text, dringend = meldungen[0]
assert dringend and "zurück auf den Snapshot mc2-20260924-190000" in text and "antwortet nicht" in text
def test_update_ohne_neue_version_ist_rot(ausfuehrer):
starten, _, _, _ = ausfuehrer
starten("1.27.2")
lauf = _auf_ende(updates.starten("ct-104", "app")["lauf"])
assert lauf["ergebnis"] == "zurueckgerollt" and "unverändert" in lauf["text"]
def test_nicht_freigegeben_und_unbekannt(ausfuehrer):
assert updates.starten("vm-106", "app")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
assert updates.starten("ct-999", "app")["ok"] is False
assert updates.starten("ct-104", "boese")["ok"] is False
# --- Der Ausführer selbst (läuft auf dem Proxmox-Host) -------------------------------------------
def _ausfuehrer_modul():
spec = importlib.util.spec_from_file_location("ausfuehrer", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_ausfuehrer_entscheidet_selbst_ueber_etiketten():
a = _ausfuehrer_modul()
assert a.erlaubt(["community-script", "git"]) and a.erlaubt(["watcher"])
assert not a.erlaubt(["community-script", "watcher-aus"]) and not a.erlaubt([])
def test_ausfuehrer_snapshot_faehigkeit():
a = _ausfuehrer_modul()
arten = {"local-lvm": "lvmthin", "local": "dir"}
assert a.snapshot_moeglich({"rootfs": "local-lvm:vm-104-disk-0,size=8G"}, arten) == (True, None)
ok, grund = a.snapshot_moeglich({"rootfs": "local-lvm:vm-105-disk-0", "mp0": "/mnt/qnap,mp=/mnt/ds"}, arten)
assert not ok and "Bind-Mount" in grund
assert a.snapshot_moeglich({"rootfs": "local:105/vm-105-disk-0.raw"}, arten)[0] is False
def test_ausfuehrer_lehnt_fremdes_ab(monkeypatch):
a = _ausfuehrer_modul()
monkeypatch.setattr(a, "_pvesh", lambda pfad, *p: [
{"vmid": 104, "type": "lxc", "tags": "community-script;git"},
{"vmid": 106, "type": "qemu", "tags": ""}])
ausgefuehrt: list[list[str]] = []
monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60: ausgefuehrt.append(befehl) or (0, "ok"))
assert a.ausfuehren({"aktion": "rm", "parameter": {}})["code"] == 2
assert "kein erlaubtes Etikett" in a.ausfuehren({"aktion": "update", "parameter": {"vmid": 106}})["text"]
assert a.ausfuehren({"aktion": "zurueck", "parameter": {"vmid": 104, "snapshot": "vorher"}})["code"] == 2
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": "104; reboot"}})["code"] == 2
assert ausgefuehrt == []
assert a.ausfuehren({"aktion": "update", "parameter": {"vmid": 104}})["code"] == 0
assert ausgefuehrt[-1][:4] == ["pct", "exec", "104", "--"] and "PHS_SILENT=1" in ausgefuehrt[-1][-1]
+15
View File
@@ -208,3 +208,18 @@ def test_unbekannte_rolle_bricht_ab(monkeypatch):
finally: finally:
monkeypatch.delenv("MC_ROLLE") monkeypatch.delenv("MC_ROLLE")
einstellungen_mod.einstellungen.cache_clear() einstellungen_mod.einstellungen.cache_clear()
def test_homelab_waechter_prueft_ausfuehrer_und_gaeste(tmp_path):
ergebnis = _starte("homelab", tmp_path, """
import json
import steward
from services import waechter
print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)}))
""")
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
ergebnis = _starte("homelab", tmp_path, _PROBE)
assert "/api/homelab/ziele" in ergebnis["pfade"] and "/api/ziele" not in ergebnis["pfade"]
+10 -6
View File
@@ -37,15 +37,19 @@ class _Antwort:
def test_github_antworten_werden_gemerkt_fehler_nicht(monkeypatch): def test_github_antworten_werden_gemerkt_fehler_nicht(monkeypatch):
monkeypatch.setattr(maintenance, "_github_cache", {}) from kern import github
antworten = [_Antwort(403, {"message": "API rate limit exceeded"}), _Antwort(200, {"tag_name": "v258"})]
monkeypatch.setattr(github, "_cache", {})
antworten = [_Antwort(403, {"message": "API rate limit exceeded"}),
_Antwort(200, {"tag_name": "v258", "published_at": "2026-09-20T10:00:00Z"})]
aufrufe = [] aufrufe = []
monkeypatch.setattr(maintenance.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0)) monkeypatch.setattr(github.httpx, "get", lambda url, **kw: aufrufe.append(url) or antworten.pop(0))
with pytest.raises(httpx.HTTPStatusError): with pytest.raises(httpx.HTTPStatusError):
maintenance._github_json("repos/a/b/releases/latest") github.github_json("repos/a/b/releases/latest")
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"} assert github.neueste_version("a/b") == {"tag": "v258", "version": "258", "datum": "2026-09-20"}
assert maintenance._github_json("repos/a/b/releases/latest") == {"tag_name": "v258"} assert github.github_json("repos/a/b/releases/latest")["tag_name"] == "v258"
assert len(aufrufe) == 2 assert len(aufrufe) == 2
assert maintenance._github_json is github.github_json
def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path): def test_hermes_version_auch_wenn_der_abruf_haengt(monkeypatch, tmp_path):
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# frontend-bauen-box.sh — Frontend-Prüfungen und Build auf der KI-Box statt am PC (24.09.2026).
#
# Der PC hatte zu wenig freien Speicher (Spiel lief nebenher), tsc und eslint brachen mit „heap out of
# memory" ab. Die Box hat genug davon und einen Node (den Hermes für seine Oberfläche mitbringt).
# Geschickt wird der Arbeitsstand von frontend/ (ohne node_modules und dist), zurück kommt dist/.
#
# Nutzung (Git-Bash am PC, im Repo): bash deploy/frontend-bauen-box.sh
# Ändert auf der Box nichts außer einem Wegwerf-Ordner unter /tmp.
set -euo pipefail
cd "$(dirname "$0")/.."
BOX="${MC_BOX:-hitonabi@192.168.178.151}"
ORDNER="/tmp/mc2-frontend-$$"
echo "Frontend-Prüfung und Build auf $BOX …"
tar --exclude=node_modules --exclude=dist -cf - frontend \
| ssh -o BatchMode=yes "$BOX" "rm -rf $ORDNER && mkdir -p $ORDNER && tar -x -C $ORDNER"
# shellcheck disable=SC2029 # $ORDNER soll hier (am PC) eingesetzt werden
ssh -o BatchMode=yes "$BOX" "set -e; export PATH=\$HOME/.hermes/node/bin:\$PATH; cd $ORDNER/frontend
npm ci --no-audit --no-fund --loglevel=error
echo '── Lint'; npm run --silent lint
echo '── Typen'; npx tsc --noEmit
echo '── Tests'; npm test --silent
echo '── Build'; npm run --silent build"
rm -rf frontend/dist
ssh -o BatchMode=yes "$BOX" "tar -cf - -C $ORDNER/frontend dist && rm -rf $ORDNER" | tar -x -C frontend
echo "✅ frontend/dist ist neu gebaut ($(du -sh frontend/dist | cut -f1))."
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# ausfuehrer-einrichten.sh — den Ausführer auf dem Proxmox-Host einrichten (Phase 3/4).
#
# Läuft am PC (Git-Bash, im Repo). Legt ausfuehrer.py, die Unit und die Konfiguration auf den
# Proxmox-Host und startet den Dienst. Das gemeinsame Geheimnis wandert vom Container des Homelab-Teils
# per Pipe direkt auf den Host (0600) — es steht in keiner Befehlszeile.
#
# Nutzung: bash deploy/homelab/ausfuehrer-einrichten.sh <ip-des-containers>
set -euo pipefail
cd "$(dirname "$0")/../.."
IP="${1:?Nutzung: ausfuehrer-einrichten.sh <ip-des-containers>}"
PVE="${MC_PVE:-pve}"
ssh -o BatchMode=yes "$PVE" "install -d -m 755 /usr/local/lib/mc2"
ssh -o BatchMode=yes "$PVE" "cat > /usr/local/lib/mc2/ausfuehrer.py && chmod 755 /usr/local/lib/mc2/ausfuehrer.py" \
< deploy/homelab/ausfuehrer.py
ssh -o BatchMode=yes "$PVE" "cat > /etc/systemd/system/mc2-ausfuehrer.service" < deploy/homelab/mc2-ausfuehrer.service
# shellcheck disable=SC2029 # $IP soll hier (am PC) eingesetzt werden
ssh -o BatchMode=yes "root@$IP" "cat /var/lib/mc2/ausfuehrer.token" \
| ssh -o BatchMode=yes "$PVE" "umask 077; read -r t; [ -n \"\$t\" ] || exit 1; \
printf '{\"server\": \"http://%s:9001\", \"token\": \"%s\", \"node\": \"%s\"}\n' '$IP' \"\$t\" \"\$(hostname)\" \
> /etc/mc2-ausfuehrer.json"
ssh -o BatchMode=yes "$PVE" "systemctl daemon-reload && systemctl enable --now mc2-ausfuehrer.service \
&& systemctl restart mc2-ausfuehrer.service && sleep 5 && systemctl is-active mc2-ausfuehrer.service"
echo "✅ Ausführer läuft. Der erste Bericht erscheint in etwa einer Minute unter /api/homelab/ziele."
+401
View File
@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""Ausführer des Homelab Orchestrators auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
Läuft als root auf dem Proxmox-PC (mc2-ausfuehrer.service) und holt sich seine Arbeit selbst
beim Homelab-Teil ab („Pull“, User-Entscheid 24.09.): Der Proxmox-Host öffnet keinen Port, und der
Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
• Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version,
OS-Updates, Snapshots und ob ein Snapshot überhaupt geht. Nur lesend.
• Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste
angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten:
community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht
vertraut — er kann nur aus der Liste wählen.
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve"}
Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
ausfuehrer.py --bericht Bericht einmal auf die Konsole (nur lesend, zum Prüfen)
"""
import argparse
import json
import logging
import os
import platform
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
KONFIG = os.environ.get("MC2_AUSFUEHRER_KONFIG", "/etc/mc2-ausfuehrer.json")
BERICHT_ALLE_S = 600
ABFRAGE_ALLE_S = 5
ERLAUBT = {"community-script", "watcher"}
AUSGENOMMEN = "watcher-aus"
SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$")
# Speicherarten, auf denen Proxmox Snapshots kann.
SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"}
# App-Version je Community-Script (Kennung aus /usr/bin/update im Gast). Neuere Skripte legen die Version
# in /root/.<app> ab; für die älteren steht hier, wie man sie erfährt. Festes Wissen dieses Hosts.
VERSION_PROBEN = {
"adguard": "/opt/AdGuardHome/AdGuardHome --version",
"netbird": "netbird version",
"proxmox-backup-server": "dpkg-query -W proxmox-backup-server",
"npmplus": "docker image inspect zoeyvid/npmplus:latest --format '{{.Created}}'",
}
VERSIONSDATEIEN = {"pve-scripts-local": "/root/.proxmoxve-local"}
log = logging.getLogger("ausfuehrer")
# --- Hilfen -----------------------------------------------------------------------------
def _laufen(befehl: list[str], zeitlimit: int = 60) -> tuple[int, str]:
try:
r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit)
except subprocess.TimeoutExpired:
return 124, f"Zeitlimit ({zeitlimit} s) überschritten"
except OSError as exc:
return 127, str(exc)
return r.returncode, (r.stdout + r.stderr).strip()
def _pvesh(pfad: str, *parameter: str) -> object:
code, text = _laufen(["pvesh", "get", pfad, *parameter, "--output-format", "json"], 30)
if code != 0:
raise RuntimeError(f"pvesh {pfad}: {text[:200]}")
return json.loads(text or "null")
def _im_gast(vmid: int, befehl: str, zeitlimit: int = 30) -> tuple[int, str]:
return _laufen(["pct", "exec", str(vmid), "--", "sh", "-c", befehl], zeitlimit)
def _etiketten(roh: str | None) -> list[str]:
return [t for t in (roh or "").replace(",", ";").split(";") if t]
def erlaubt(etiketten: list[str]) -> bool:
menge = set(etiketten)
return bool(menge & ERLAUBT) and AUSGENOMMEN not in menge
# --- Bericht (nur lesend) -------------------------------------------------------------------
def _speicherart() -> dict[str, str]:
try:
return {s["storage"]: s["type"] for s in _pvesh(f"/nodes/{NODE}/storage")}
except Exception:
return {}
def snapshot_moeglich(konfig: dict, arten: dict[str, str]) -> tuple[bool, str | None]:
"""Geht ein Snapshot? Bind-Mounts (mpX mit Host-Pfad) und Speicher ohne Snapshots verhindern ihn."""
for schluessel, wert in konfig.items():
if re.fullmatch(r"mp\d+", schluessel) and str(wert).split(",")[0].startswith("/"):
return False, f"Bind-Mount {schluessel} ({str(wert).split(',')[0]}) — nur Backup möglich"
for schluessel in ("rootfs", "scsi0", "virtio0", "sata0", "ide0"):
if schluessel in konfig:
speicher = str(konfig[schluessel]).split(":")[0]
art = arten.get(speicher)
if art and art not in SNAPSHOT_SPEICHER:
return False, f"Speicher {speicher} ({art}) kann keine Snapshots"
return True, None
def _app_kennung(vmid: int) -> str | None:
"""Welches Community-Script steckt im Gast? /usr/bin/update lädt ct/<kennung>.sh."""
code, text = _im_gast(vmid, "cat /usr/bin/update 2>/dev/null", 10)
m = re.search(r"/ct/([a-z0-9-]+)\.sh", text) if code == 0 else None
return m.group(1) if m else None
def _app_version(vmid: int, kennung: str) -> str | None:
befehl = VERSION_PROBEN.get(kennung)
if befehl is None:
datei = VERSIONSDATEIEN.get(kennung, f"/root/.{kennung}")
befehl = f"cat {datei} 2>/dev/null"
code, text = _im_gast(vmid, befehl, 20)
if code != 0 or not text:
return None
zeile = text.splitlines()[-1].strip()
# Docker-Apps (NPMplus) haben keine Versionsnummer, nur das Datum ihres Images.
if m := re.match(r"(\d{4}-\d{2}-\d{2})T", zeile):
return f"Image vom {m.group(1)}"
m = re.search(r"v?(\d+(?:\.\d+){1,3}(?:[-+][\w.]+)?)", zeile)
return m.group(1) if m else zeile[:40]
def _os_updates(vmid: int, ostype: str) -> dict:
"""Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur) und wie alt die Listen sind."""
if ostype == "alpine":
code, text = _im_gast(vmid, "apk version -l '<' 2>/dev/null | tail -n +2 | wc -l; "
"stat -c %Y /var/cache/apk 2>/dev/null || echo 0", 30)
else:
code, text = _im_gast(vmid, "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable; "
"stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0", 30)
zeilen = text.split() if code in (0, 1) else []
try:
return {"anzahl": int(zeilen[0]), "listen_stand": int(zeilen[1]) or None}
except (IndexError, ValueError):
return {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
def _ip_lxc(vmid: int) -> str | None:
try:
for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"):
if schnitt.get("name") in ("eth0", "ens18"):
for adresse in schnitt.get("ip-addresses", []):
if adresse.get("ip-address-type") == "inet":
return adresse["ip-address"].split("/")[0]
except Exception:
pass
return None
def _ip_vm(vmid: int) -> str | None:
try:
daten = _pvesh(f"/nodes/{NODE}/qemu/{vmid}/agent/network-get-interfaces")
for schnitt in (daten or {}).get("result", []):
if schnitt.get("name") == "lo":
continue
for adresse in schnitt.get("ip-addresses", []):
ip = adresse.get("ip-address", "")
if adresse.get("ip-address-type") == "ipv4" and not ip.startswith(("127.", "172.")):
return ip
except Exception:
pass
return None
def _snapshots(art: str, vmid: int) -> list[str]:
try:
return [s["name"] for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") if s.get("name") != "current"]
except Exception:
return []
def _gast(eintrag: dict, arten: dict[str, str]) -> dict:
art, vmid = eintrag["type"], int(eintrag["vmid"])
etiketten = _etiketten(eintrag.get("tags"))
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
"snapshots": _snapshots(art, vmid)}
try:
konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config")
except Exception as exc:
gast["fehler"] = str(exc)[:200]
return gast
gast["onboot"] = bool(konfig.get("onboot"))
gast["snapshot_moeglich"], gast["snapshot_grund"] = snapshot_moeglich(konfig, arten)
if art == "lxc":
gast["ostype"] = konfig.get("ostype")
gast["ip"] = _ip_lxc(vmid) if gast["status"] == "running" else None
if gast["status"] == "running" and gast["erlaubt"]:
kennung = _app_kennung(vmid)
gast["app"] = {"kennung": kennung, "version": _app_version(vmid, kennung) if kennung else None}
gast["os_updates"] = _os_updates(vmid, str(konfig.get("ostype") or "debian"))
else:
gast["ip"] = _ip_vm(vmid) if gast["status"] == "running" else None
return gast
def _host() -> dict:
host: dict = {"node": NODE, "neustart_noetig": os.path.exists("/var/run/reboot-required")}
try:
host["version"] = _pvesh("/version").get("version")
host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"),
"herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")]
except Exception as exc:
host["fehler"] = str(exc)[:200]
code, kernel = _laufen(["uname", "-r"], 5)
host["kernel"] = kernel if code == 0 else None
return host
def bericht() -> dict:
arten = _speicherart()
gaeste = []
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
if eintrag.get("node") == NODE and not eintrag.get("template"):
gaeste.append(_gast(eintrag, arten))
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": _host(),
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
# --- Aufträge (feste Liste) ---------------------------------------------------------------
def _gast_pruefen(vmid: object) -> tuple[str, int]:
"""Gibt (art, vmid) zurück, wenn dieser Host den Gast anfassen darf — sonst ValueError."""
if not isinstance(vmid, int) or not 100 <= vmid <= 999_999_999:
raise ValueError("ungültige Gast-Nummer")
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
if int(eintrag.get("vmid", -1)) == vmid:
if not erlaubt(_etiketten(eintrag.get("tags"))):
raise ValueError(f"Gast {vmid} hat kein erlaubtes Etikett (community-script/watcher) "
f"oder ist mit {AUSGENOMMEN} ausgenommen")
return eintrag["type"], vmid
raise ValueError(f"Gast {vmid} gibt es nicht")
def _werkzeug(art: str) -> str:
return "pct" if art == "lxc" else "qm"
def a_snapshot(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S")
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description",
"Vor einem Update durch den Homelab Orchestrator"], 600)
return code, f"snapshot={name}\n{text}" if code == 0 else text
def a_update(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
if art != "lxc":
raise ValueError("Update per Community-Script gibt es nur für Container")
# PHS_SILENT=1: das Community-Script fragt nichts. Es lädt ct/<app>.sh live von GitHub (main).
return _im_gast(vmid, "PHS_SILENT=1 bash /usr/bin/update", int(p.get("zeitlimit") or 1800))
def a_suchen(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
if art != "lxc":
raise ValueError("nur für Container")
return _im_gast(vmid, "if command -v apk >/dev/null; then apk update; else apt-get update -q; fi", 300)
def a_os_update(p: dict) -> tuple[int, str]:
"""Die Pakete des Gasts (unabhängig vom App-Update des Community-Scripts)."""
art, vmid = _gast_pruefen(p.get("vmid"))
if art != "lxc":
raise ValueError("nur für Container")
befehl = ("if command -v apk >/dev/null; then apk update && apk upgrade; else apt-get update -q && "
"DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade; fi")
return _im_gast(vmid, befehl, int(p.get("zeitlimit") or 1800))
def a_zurueck(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
name = str(p.get("snapshot") or "")
if not SNAPSHOT_NAME.match(name):
raise ValueError("nur Snapshots des Orchestrators (mc2-…) lassen sich zurückspielen")
code, text = _laufen([_werkzeug(art), "rollback", str(vmid), name], 900)
if code == 0:
code2, text2 = _laufen([_werkzeug(art), "start", str(vmid)], 120)
text += "\n" + text2
code = 0 if code2 == 0 or "already running" in text2 else code2
return code, text
def a_snapshot_loeschen(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid"))
name = str(p.get("snapshot") or "")
if not SNAPSHOT_NAME.match(name):
raise ValueError("nur Snapshots des Orchestrators (mc2-…) werden gelöscht")
return _laufen([_werkzeug(art), "delsnapshot", str(vmid), name], 600)
def a_host_update(p: dict) -> tuple[int, str]:
# Kein Neustart: der ist ein eigener Knopf (User-Entscheid 24.09.2026).
befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y "
"-o Dpkg::Options::=--force-confold dist-upgrade")
return _laufen(["bash", "-c", befehl], 3600)
def a_host_neustart(p: dict) -> tuple[int, str]:
return _laufen(["systemctl", "reboot"], 30)
def a_bericht(p: dict) -> tuple[int, str]:
return 0, json.dumps(bericht(), ensure_ascii=False)
AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update, "suchen": a_suchen,
"zurueck": a_zurueck,
"snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
"host_neustart": a_host_neustart, "bericht": a_bericht}
def ausfuehren(auftrag: dict) -> dict:
aktion = AKTIONEN.get(str(auftrag.get("aktion")))
if aktion is None:
return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"}
try:
code, text = aktion(auftrag.get("parameter") or {})
except (ValueError, RuntimeError) as exc:
return {"code": 2, "text": f"Abgelehnt: {exc}"}
return {"code": code, "text": text[-20000:]}
# --- Verbindung zum Homelab-Teil -------------------------------------------------------------
def _anfrage(methode: str, pfad: str, daten: object = None) -> object:
roh = None if daten is None else json.dumps(daten).encode()
anfrage = urllib.request.Request(SERVER + pfad, data=roh, method=methode,
headers={"Content-Type": "application/json", "X-MC2-Ausfuehrer": TOKEN})
with urllib.request.urlopen(anfrage, timeout=30) as antwort:
text = antwort.read().decode() or "null"
return json.loads(text)
def dauerbetrieb() -> None:
naechster_bericht = 0.0
while True:
try:
if time.time() >= naechster_bericht:
_anfrage("POST", "/api/homelab/ausfuehrer/bericht", bericht())
naechster_bericht = time.time() + BERICHT_ALLE_S
auftrag = _anfrage("GET", "/api/homelab/ausfuehrer/auftrag")
if isinstance(auftrag, dict) and auftrag.get("id"):
log.info("Auftrag %s: %s %s", auftrag["id"], auftrag.get("aktion"), auftrag.get("parameter"))
ergebnis = ausfuehren(auftrag)
_anfrage("POST", f"/api/homelab/ausfuehrer/ergebnis/{auftrag['id']}", ergebnis)
if auftrag.get("aktion") != "bericht":
naechster_bericht = 0.0 # nach jeder Änderung einen frischen Bericht
continue
except (urllib.error.URLError, OSError, ValueError) as exc:
log.warning("Homelab-Teil nicht erreichbar: %s", exc)
except Exception:
log.exception("Unerwarteter Fehler")
time.sleep(ABFRAGE_ALLE_S)
def _konfig_laden() -> dict:
try:
with open(KONFIG, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {}
_konfig = _konfig_laden()
SERVER = str(_konfig.get("server") or "").rstrip("/")
TOKEN = str(_konfig.get("token") or "")
NODE = str(_konfig.get("node") or platform.node())
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
teile = argparse.ArgumentParser(description=__doc__.splitlines()[0])
teile.add_argument("--bericht", action="store_true", help="Bericht einmal ausgeben (nur lesend)")
args = teile.parse_args()
if args.bericht:
json.dump(bericht(), sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
if not SERVER or not TOKEN:
log.error("Konfiguration %s fehlt oder ist unvollständig (server, token).", KONFIG)
return 1
dauerbetrieb()
return 0
if __name__ == "__main__":
sys.exit(main())
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# ausrollen.sh — den Homelab-Teil in seinen Container bringen (erstmals und bei jedem Update).
#
# Läuft am PC (Git-Bash, im Repo). Schickt den Stand von HEAD (backend, deploy, frontend/dist) per SSH in
# den Container, lässt einrichten.sh laufen und startet die Dienste neu. Der Container braucht so keinen
# Zugang zu Gitea. Vorher: bash deploy/pruefen.sh (und deploy/probelauf-box.sh).
#
# Nutzung: bash deploy/homelab/ausrollen.sh <ip-des-containers> [partner-url]
set -euo pipefail
cd "$(dirname "$0")/../.."
IP="${1:?Nutzung: ausrollen.sh <ip-des-containers> [partner-url]}"
PARTNER="${2:-http://192.168.178.151:9001}"
ZIEL="root@$IP"
echo "Rolle $(git rev-parse --short HEAD) nach $IP aus …"
git archive --format=tar HEAD backend deploy frontend/dist ruff.toml \
| ssh -o BatchMode=yes "$ZIEL" "mkdir -p /opt/mc2 && find /opt/mc2 -mindepth 1 -maxdepth 1 ! -name backend -exec rm -rf {} + \
&& find /opt/mc2/backend -mindepth 1 -maxdepth 1 ! -name .venv -exec rm -rf {} + 2>/dev/null; tar -x -C /opt/mc2"
ssh -o BatchMode=yes "$ZIEL" "bash /opt/mc2/deploy/homelab/einrichten.sh '$PARTNER'"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# box-partner.sh — der KI-Box sagen, wo der Homelab-Teil läuft (Phase 3).
#
# Läuft am PC (Git-Bash). Legt auf der Box Drop-ins für mission-control-2 und mc2-steward mit
# MC_PARTNER_URL an und startet beide neu. Die Drop-ins stehen nicht im Repo (die IP gehört zum Heimnetz),
# deploy.sh lässt sie stehen. Danach zeigt die Seite „Homelab“ die Geräte, und der Wächter der Box prüft
# den Homelab-Teil (und umgekehrt).
#
# Nutzung: bash deploy/homelab/box-partner.sh <ip-des-containers>
set -euo pipefail
IP="${1:?Nutzung: box-partner.sh <ip-des-containers>}"
BOX="${MC_BOX:-hitonabi@192.168.178.151}"
# shellcheck disable=SC2029 # $IP soll hier (am PC) eingesetzt werden
ssh -o BatchMode=yes "$BOX" "export XDG_RUNTIME_DIR=/run/user/\$(id -u); for u in mission-control-2 mc2-steward; do
mkdir -p ~/.config/systemd/user/\$u.service.d
printf '[Service]\nEnvironment=MC_PARTNER_URL=http://%s:9001\nEnvironment=MC_PARTNER_NAME=Homelab\n' '$IP' \
> ~/.config/systemd/user/\$u.service.d/partner.conf
done
systemctl --user daemon-reload && systemctl --user restart mission-control-2 mc2-steward
sleep 4; curl -s -m 10 http://127.0.0.1:9001/api/partner"
echo
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# container-anlegen.sh — den Container für den Homelab-Teil auf dem Proxmox-PC anlegen (Phase 3).
#
# Läuft am PC (Git-Bash) und spricht per SSH mit dem Proxmox-Host (Alias „pve“, Schlüssel id_lucy_infra).
# Legt EINEN unprivilegierten Debian-13-Container an: 1 Kern, 1 GB RAM, 4 GB Platte, DHCP, Autostart.
# Etikett „mc2“ — bewusst NICHT community-script/watcher: Der Orchestrator aktualisiert sich nicht selbst.
#
# Nutzung: bash deploy/homelab/container-anlegen.sh [vmid]
# Danach: bash deploy/homelab/ausrollen.sh <ip>
set -euo pipefail
PVE="${MC_PVE:-pve}"
VORLAGE="${MC_VORLAGE:-local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst}"
VMID="${1:-$(ssh -o BatchMode=yes "$PVE" pvesh get /cluster/nextid)}"
echo "Lege Container $VMID (homelab-orchestrator) auf $PVE an …"
ssh -o BatchMode=yes "$PVE" "pct create $VMID $VORLAGE \
--hostname homelab-orchestrator --description 'Homelab Orchestrator – Homelab-Teil (MC2, Rolle homelab)' \
--cores 1 --memory 1024 --swap 512 --rootfs local-lvm:4 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp --unprivileged 1 --features nesting=1 \
--onboot 1 --tags mc2 --timezone Europe/Berlin --start 1"
# Auf die IP warten (DHCP), dann den SSH-Schlüssel des PCs und der Box eintragen (wie bei allen Gästen).
for _ in $(seq 1 30); do
IP="$(ssh -o BatchMode=yes "$PVE" "pct exec $VMID -- hostname -I 2>/dev/null | awk '{print \$1}'" || true)"
[ -n "$IP" ] && break
sleep 2
done
[ -n "${IP:-}" ] || { echo "✗ Container $VMID hat keine IP bekommen."; exit 1; }
ssh -o BatchMode=yes "$PVE" "pct exec $VMID -- bash -c 'apt-get update -q && apt-get install -y -q openssh-server && \
mkdir -p /root/.ssh && chmod 700 /root/.ssh' && cat /root/.ssh/authorized_keys | pct exec $VMID -- bash -c \
'cat >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && systemctl enable --now ssh'"
echo "✅ Container $VMID läuft unter $IP. Weiter mit: bash deploy/homelab/ausrollen.sh $IP"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# einrichten.sh — läuft IM Container des Homelab-Teils als root (ausrollen.sh ruft es auf).
#
# Idempotent: Pakete, Dienst-Nutzer mc2, Ordner, Python-Umgebung, systemd-Units. Die Konfiguration
# /etc/mc2/homelab.env (MC_PARTNER_URL …) bleibt, wenn es sie schon gibt.
set -euo pipefail
ZIEL=/opt/mc2
PARTNER="${1:-http://192.168.178.151:9001}"
apt-get update -q
DEBIAN_FRONTEND=noninteractive apt-get install -y -q python3 python3-venv curl ca-certificates
id mc2 >/dev/null 2>&1 || useradd --system --home-dir /var/lib/mc2 --shell /usr/sbin/nologin mc2
install -d -o mc2 -g mc2 -m 750 /var/lib/mc2
install -d -o root -g mc2 -m 750 /etc/mc2
[ -f /etc/mc2/homelab.env ] || printf 'MC_PARTNER_URL=%s\nMC_PARTNER_NAME=Box-Wart\n' "$PARTNER" > /etc/mc2/homelab.env
chown root:mc2 /etc/mc2/homelab.env && chmod 640 /etc/mc2/homelab.env
python3 -m venv "$ZIEL/backend/.venv"
"$ZIEL/backend/.venv/bin/pip" install -q --upgrade pip
"$ZIEL/backend/.venv/bin/pip" install -q -r "$ZIEL/backend/requirements.txt"
chown -R root:root "$ZIEL"
install -m 644 "$ZIEL"/deploy/homelab/mc2-homelab*.service "$ZIEL"/deploy/homelab/mc2-homelab*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now mc2-homelab.service mc2-homelab-steward.service mc2-homelab-morgenmeldung.timer
systemctl restart mc2-homelab.service mc2-homelab-steward.service
for _ in $(seq 1 20); do
curl -sf -m 3 http://127.0.0.1:9001/api/health >/dev/null && break
sleep 1
done
curl -sf -m 3 http://127.0.0.1:9001/api/health && echo
# Das gemeinsame Geheimnis für den Ausführer entsteht beim ersten Abruf (0600, Nutzer mc2).
curl -sf -m 5 http://127.0.0.1:9001/api/homelab/ausfuehrer >/dev/null || true
echo "✅ Homelab-Teil läuft."
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Homelab Orchestrator – Ausführer auf dem Proxmox-Host (holt Aufträge beim Homelab-Teil ab)
After=network-online.target pve-cluster.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/mc2/ausfuehrer.py
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,13 @@
[Unit]
Description=Homelab Orchestrator – Morgenmeldung des Homelab-Teils (Nachtmeldungen als eine Nachricht)
[Service]
Type=oneshot
User=mc2
Group=mc2
Environment=MC_NOTIFY_HERMES=/bin/false
Environment=MC_TELEGRAM_ENV=/etc/mc2/telegram.env
Environment=MC_NOTIFY_LOG=/var/lib/mc2/notify.log
Environment=MC_NIGHT_QUEUE=/var/lib/mc2/night-queue.txt
Environment="MC_MORGENMELDUNG_BETREFF=[Morgenmeldung Homelab]"
ExecStart=/bin/bash /opt/mc2/deploy/morgenmeldung.sh
@@ -0,0 +1,9 @@
[Unit]
Description=Homelab Orchestrator – Morgenmeldung des Homelab-Teils um 07:00
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=false
[Install]
WantedBy=timers.target
@@ -0,0 +1,24 @@
[Unit]
Description=Homelab Orchestrator – Wächter des Homelab-Teils (Ausführer, Gäste, Partner, Platte)
After=mc2-homelab.service
[Service]
Type=simple
User=mc2
Group=mc2
WorkingDirectory=/opt/mc2/backend
EnvironmentFile=/etc/mc2/homelab.env
Environment=MC_ROLLE=homelab
Environment=MC_DATEN_DIR=/var/lib/mc2
Environment=MC_NOTIFY_HERMES=/bin/false
Environment=MC_NOTIFY_NO_ANNOUNCE=1
Environment=MC_TELEGRAM_ENV=/etc/mc2/telegram.env
Environment=MC_NOTIFY_LOG=/var/lib/mc2/notify.log
Environment=MC_NIGHT_QUEUE=/var/lib/mc2/night-queue.txt
Environment=MC_SENTRY_MC2_URL=http://127.0.0.1:9001
ExecStart=/opt/mc2/backend/.venv/bin/python steward.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Homelab Orchestrator – Homelab-Teil (Oberfläche und Schnittstellen, Rolle homelab)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mc2
Group=mc2
WorkingDirectory=/opt/mc2/backend
EnvironmentFile=/etc/mc2/homelab.env
Environment=MC_ROLLE=homelab
Environment=MC_DATEN_DIR=/var/lib/mc2
# Kein Hermes im Container: Meldungen gehen über den Zweitweg direkt an die Telegram-Bot-API.
Environment=MC_NOTIFY_HERMES=/bin/false
Environment=MC_NOTIFY_NO_ANNOUNCE=1
Environment=MC_TELEGRAM_ENV=/etc/mc2/telegram.env
Environment=MC_NOTIFY_LOG=/var/lib/mc2/notify.log
Environment=MC_NIGHT_QUEUE=/var/lib/mc2/night-queue.txt
Environment=MC_UPDATE_VERLAUF=/var/lib/mc2/mc2-update-verlauf.jsonl
ExecStart=/opt/mc2/backend/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 9001 --timeout-graceful-shutdown 3
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+3 -1
View File
@@ -13,6 +13,8 @@ SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
MAX_ZEICHEN="${MC_MORGENMELDUNG_MAX:-3500}" # Telegram erlaubt 4096 Zeichen je Nachricht MAX_ZEICHEN="${MC_MORGENMELDUNG_MAX:-3500}" # Telegram erlaubt 4096 Zeichen je Nachricht
VERSUCHE="${MC_MORGENMELDUNG_VERSUCHE:-3}" VERSUCHE="${MC_MORGENMELDUNG_VERSUCHE:-3}"
PAUSE="${MC_MORGENMELDUNG_PAUSE:-60}" PAUSE="${MC_MORGENMELDUNG_PAUSE:-60}"
# Der Homelab-Teil schickt seine eigene Morgenmeldung (mc2-homelab-morgenmeldung.service).
BETREFF="${MC_MORGENMELDUNG_BETREFF:-[Morgenmeldung]}"
# Melde-Log begrenzen (wuchs seit Juli ungebremst): über 3 MB bleiben die letzten 2 MB. # Melde-Log begrenzen (wuchs seit Juli ungebremst): über 3 MB bleiben die letzten 2 MB.
# services/update_verlauf.py liest ohnehin nur das Ende, das reicht für Monate Update-Verlauf. # services/update_verlauf.py liest ohnehin nur das Ende, das reicht für Monate Update-Verlauf.
@@ -44,7 +46,7 @@ for ((i = 1; i <= VERSUCHE; i++)); do
# Als 07:00 senden (außerhalb der Nachtruhe); NO_ANNOUNCE, weil jede Meldung schon einzeln im # Als 07:00 senden (außerhalb der Nachtruhe); NO_ANNOUNCE, weil jede Meldung schon einzeln im
# Briefkasten liegt. # Briefkasten liegt.
if MC_NOTIFY_STRICT=1 MC_NOTIFY_NO_ANNOUNCE=1 MC_NOTIFY_STUNDE=07 \ if MC_NOTIFY_STRICT=1 MC_NOTIFY_NO_ANNOUNCE=1 MC_NOTIFY_STUNDE=07 \
bash "$SRC_DIR/notify.sh" -s "[Morgenmeldung]" "$NACHRICHT"; then bash "$SRC_DIR/notify.sh" -s "$BETREFF" "$NACHRICHT"; then
mv -f "$ARBEIT" "$QUEUE.zuletzt-gesendet" mv -f "$ARBEIT" "$QUEUE.zuletzt-gesendet"
exit 0 exit 0
fi fi
+1 -1
View File
@@ -20,7 +20,7 @@ fehler=0
schritt() { echo "── $1"; } schritt() { echo "── $1"; }
schritt "Syntax der Shell-Skripte" schritt "Syntax der Shell-Skripte"
for f in deploy/*.sh deploy/jobs/*.sh; do for f in deploy/*.sh deploy/jobs/*.sh deploy/homelab/*.sh; do
bash -n "$f" || { echo " ✗ $f"; fehler=1; } bash -n "$f" || { echo " ✗ $f"; fehler=1; }
done done
+41 -8
View File
@@ -207,12 +207,45 @@ Routen.
`Origin` (Skripte, `curl`), `Origin: null` bzw. `file://` (Lucy-Desktop), dieselbe Adresse wie die Oberfläche und die `Origin` (Skripte, `curl`), `Origin: null` bzw. `file://` (Lucy-Desktop), dieselbe Adresse wie die Oberfläche und die
Entwicklungs-Ports 5173, 5180, 5181. `/v1` ist nicht betroffen. Entwicklungs-Ports 5173, 5180, 5181. `/v1` ist nicht betroffen.
## Ausblick: der Homelab-Teil (Phasen 3 und 4) ## Der Homelab-Teil (Phasen 3 und 4, Code seit 24.09.; Einrichtung wartet auf das User-OK)
Stand der Recherche vom 24.09.2026: Community-Script-Container aktualisiert man im Container mit `update`, ohne Derselbe Code in der Rolle `homelab`, in einem eigenen Container auf dem Proxmox-PC (`deploy/homelab/`).
Rückfragen mit `PHS_SILENT=1`; die installierte Version steht in `/root/.<app>`. Die Proxmox-API kann keine Befehle Er braucht **keinen Proxmox-Schlüssel**: Alles, was vom Host kommt, liefert der Ausführer.
in Containern ausführen und gibt die Liste der Host-Updates nur mit Schreibrecht heraus. Deshalb: ein Lese-Schlüssel,
ein Proxmox-Webhook für Host-Pakete und ein kleiner Ausführer auf dem Host mit festen Aktionen. Container mit ```mermaid
Bind-Mount (z. B. PBS) lassen sich nicht snapshotten, dort gilt „Backup statt Snapshot". Arcane meldet Image-Updates flowchart LR
über eine eigene API mit Schlüssel. Ein Exit-Code 0 beweist kein gelungenes Update; geprüft wird unabhängig AF["Ausführer<br/>Proxmox-Host, root"] -->|holt Aufträge, Bericht alle 10 min| HL["Homelab-Teil<br/>Container, :9001"]
(HTTP-Probe, Dienststatus, Version). Fahrplan: [wissen/OFFENE-FAEDEN.md](wissen/OFFENE-FAEDEN.md). HL -->|Ziele, Läufe| UI["Oberfläche<br/>Seite Homelab"]
BOX["KI-Box :9001"] <-->|prüfen sich, /api/partner| HL
HL -->|Webprüfung| G["Gäste<br/>AdGuard, Gitea …"]
```
- **Ausführer** `deploy/homelab/ausfuehrer.py` (root auf dem Host, nur Standardbibliothek, `mc2-ausfuehrer.service`):
holt sich Arbeit beim Homelab-Teil ab („Pull“, kein offener Port auf dem Host) und weist sich mit einem
gemeinsamen Geheimnis aus (Kopfzeile `X-MC2-Ausfuehrer`; erzeugt der Homelab-Teil in
`/var/lib/mc2/ausfuehrer.token`, liegt auf dem Host in `/etc/mc2-ausfuehrer.json`, beides 0600). Er führt nur eine
feste Liste von Aktionen aus (`bericht`, `snapshot`, `update`, `os_update`, `suchen`, `zurueck`,
`snapshot_loeschen`, `host_update`, `host_neustart`) und prüft selbst, ob ein Gast das Etikett
`community-script` oder `watcher` trägt und nicht `watcher-aus` — dem Server vertraut er dabei nicht.
- **Bericht** (nur lesend, auch von Hand: `python3 ausfuehrer.py --bericht`): Host-Version und Paket-Updates, je
Gast Status, IP, Etiketten, Snapshots, ob ein Snapshot geht (Bind-Mounts wie beim PBS verhindern ihn), die
Community-Script-Kennung (aus `/usr/bin/update` im Gast), die App-Version (je App eigener Weg: `/root/.<app>`,
`AdGuardHome --version`, `netbird version`, `dpkg-query`, Docker-Image-Datum) und die Paket-Updates im Gast samt
Alter der Paketlisten.
- **Homelab-Teil** `backend/services/homelab/`: `kanal.py` (Geheimnis, Auftragsliste, Bericht), `apps.py` (App-
Katalog: Name, GitHub-Quelle, Weboberfläche), `inventar.py` (Bericht + neueste Versionen von GitHub +
eigene Webprüfung → Ziele im gemeinsamen Modell; Paketlisten älter als 14 Tage = „unklar“ mit Knopf „Nach Updates
suchen“), `updates.py` („Jetzt updaten“). Schnittstellen `routers/homelab.py` unter `/api/homelab/…`.
- **„Jetzt updaten“** (nur per Knopf): Snapshot (wo möglich) → `update` des Community-Scripts (`PHS_SILENT=1`) bzw.
Pakete → 20 s warten → frischer Bericht → Prüfung (Gast läuft, Weboberfläche antwortet, App-Version neu). Rot
und Snapshot da → automatisch zurück + dringende Meldung; grün → ältere `mc2-`-Snapshots weg + Meldung. Läufe in
`/var/lib/mc2/homelab-laeufe.json` und im strukturierten Update-Verlauf. Host: Pakete per Knopf mit Warnung,
Neustart als eigener Knopf.
- **Wächter** in der Rolle `homelab`: Platte, Partner (die KI-Box), Ausführer (kein Bericht seit 30 min = rot) und
jede Weboberfläche der freigegebenen Gäste.
- **Oberfläche**: Die Seite „Homelab“ zeigt alle Geräte als Karten — die KI-Box (`/api/ziele`) und alles aus
`/api/homelab/ziele` — mit Stand je Baustein, Rückweg und Knopf samt Rückfrage.
- **Meldungen** ohne Hermes: `notify.sh` im Container nimmt den Zweitweg direkt an die Bot-API
(`/etc/mc2/telegram.env`), nachts sammelt eine eigene Morgenmeldung („[Morgenmeldung Homelab]“).
- **Einrichtung** (Reihenfolge, jeder Schritt braucht das User-OK): `container-anlegen.sh` → `ausrollen.sh <ip>` →
`ausfuehrer-einrichten.sh <ip>` → `box-partner.sh <ip>`; Details in [BETRIEB.md](BETRIEB.md).
+19
View File
@@ -147,6 +147,25 @@ Neustarts von llama-swap und Hermes.
als Abzug nach `/tmp` auf der Box und lässt dort das Prüftor mit dem Python des Box-Checkouts laufen. Ändert auf als Abzug nach `/tmp` auf der Box und lässt dort das Prüftor mit dem Python des Box-Checkouts laufen. Ändert auf
der Box sonst nichts. Grund: Unter Linux zeigen sich Wettläufe, die am Windows-PC durchgehen (24.09.). der Box sonst nichts. Grund: Unter Linux zeigen sich Wettläufe, die am Windows-PC durchgehen (24.09.).
## Homelab-Teil einrichten (Phase 3/4)
Jeder Schritt braucht das OK des Users (Container anlegen, Ausführer als root auf dem Host, Telegram-Zugang in
den Container). Alles läuft am PC in Git-Bash, im Repo; SSH-Zugang zu `pve` (Schlüssel `id_lucy_infra`).
1. `bash deploy/homelab/container-anlegen.sh` — unprivilegierter Debian-13-Container (nächste freie ID, 1 Kern,
1 GB RAM, 4 GB, DHCP, Autostart, Etikett `mc2`), SSH-Schlüssel wie bei allen Gästen. Gibt die IP aus.
2. `bash deploy/homelab/ausrollen.sh <ip>` — Code von HEAD in `/opt/mc2`, `einrichten.sh` (Nutzer `mc2`, Python-
Umgebung, Units `mc2-homelab`, `mc2-homelab-steward`, `mc2-homelab-morgenmeldung.timer`). Auch jedes spätere
Update des Homelab-Teils geht so (erst Prüftor und Probelauf auf der Box).
3. Telegram-Zugang (nur mit OK): `TELEGRAM_BOT_TOKEN` und `TELEGRAM_HOME_CHANNEL` in `/etc/mc2/telegram.env`
(Eigentümer `root:mc2`, 0640).
4. `bash deploy/homelab/ausfuehrer-einrichten.sh <ip>` — Ausführer, Unit und Konfiguration (0600) auf den Host.
5. `bash deploy/homelab/box-partner.sh <ip>` — Drop-ins `partner.conf` für `mission-control-2` und `mc2-steward`
auf der Box; ab dann prüfen sich beide Instanzen gegenseitig.
Prüfen: `curl http://<ip>:9001/api/homelab/ziele` (nach etwa einer Minute stehen die Geräte darin),
`systemctl status mc2-ausfuehrer` auf dem Host, Seite „Homelab“ der Oberfläche.
## Sicherung ## Sicherung
- **Wann:** `mc2-backup.timer` täglich 03:30 (+≤5 min); außerdem vor jedem Hermes-Update, vor jedem Zurückspielen und - **Wann:** `mc2-backup.timer` täglich 03:30 (+≤5 min); außerdem vor jedem Hermes-Update, vor jedem Zurückspielen und
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{i as e,n as t,o as n,r,t as i}from"./dist-BaE5-hhJ.js";import{$ as a,H as o,M as s,R as c,_ as l,o as u,z as d}from"./index-DSxwfDVg.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-iGGgLZLg.js";var _=t(),v=n(e(),1),y=r();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=l(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:t}){let n=a(),r=u(),[l,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await c(`/api/maintenance/restart`,{service:e.unit});t.ok?o(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):o(`fehler`,`${e.name} ließ sich nicht starten: ${s(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:i(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(d,{variant:l===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":l===e.unit,onClick:()=>_(l===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),l===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&t(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
+1
View File
@@ -0,0 +1 @@
import{c as e,i as t,p as n,s as r,t as i,u as a}from"./button-BheHBEml.js";import{H as o,I as s,Q as c,U as l,b as u,s as d}from"./index-SRwQwEnh.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-CKp82pOA.js";var _=r(),v=n(a(),1),y=e();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:n}){let r=c(),a=d(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await o(`/api/maintenance/restart`,{service:e.unit});t.ok?l(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):l(`fehler`,`${e.name} ließ sich nicht starten: ${s(t)}`),r.invalidateQueries({queryKey:[`dienste`]})}catch(e){l(`fehler`,e.message)}finally{w(null)}}}let E;return E=a.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):a.isError&&!a.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,a.error.message]}):a.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:a.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:t(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(i,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(i,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(i,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(i,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&n(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
-1
View File
@@ -1 +0,0 @@
import{i as e,o as t,r as n}from"./dist-BaE5-hhJ.js";import{$ as r,H as i,L as a,Q as o,R as s,q as c,z as l}from"./index-DSxwfDVg.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-iGGgLZLg.js";var h=t(e(),1),g=n();function _({offen:e,onSchliessen:t}){let n=r(),_=o({queryKey:[`geheimnisse`],queryFn:()=>a(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``),[b,x]=(0,h.useState)(!1),[S,C]=(0,h.useState)(!1);async function w(e){if(!b){x(!0),C(!1);try{await s(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),i(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),n.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){i(`fehler`,e.message)}finally{x(!1)}}}let T=_.data,E;return E=_.isPending?`Wird gelesen …`:T?T.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`:`Lässt sich gerade nicht lesen${_.error?`: ${_.error.message}`:`.`}`,(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,"aria-live":`polite`,children:[(0,g.jsx)(`span`,{className:!T&&!_.isPending?`text-rot-text`:void 0,children:E}),` Nötig für gesperrte Modelle und schnellere Downloads.`,T?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),T?.schreibbar===!1&&(0,g.jsx)(`p`,{className:`text-sm text-bernstein`,children:`Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.`}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&w(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(l,{type:`submit`,disabled:!v.trim()||T?.schreibbar===!1||b,children:b?`Speichert …`:`Speichern`})]}),T?.hf_token_gesetzt&&!T.hf_token_aus_env&&(S?(0,g.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,g.jsx)(l,{variant:`gefahr`,size:`sm`,disabled:b,onClick:()=>w(null),children:`Wirklich löschen?`}),(0,g.jsx)(l,{variant:`ghost`,size:`sm`,onClick:()=>C(!1),children:`Nein`})]}):(0,g.jsx)(l,{variant:`ghost`,size:`sm`,className:`self-start`,disabled:b,onClick:()=>C(!0),children:`Zugang löschen`}))]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(c,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
+1
View File
@@ -0,0 +1 @@
import{c as e,p as t,t as n,u as r}from"./button-BheHBEml.js";import{H as i,Q as a,U as o,V as s,Z as c,q as l}from"./index-SRwQwEnh.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-CKp82pOA.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=a(),_=c({queryKey:[`geheimnisse`],queryFn:()=>s(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``),[b,x]=(0,h.useState)(!1),[S,C]=(0,h.useState)(!1);async function w(e){if(!b){x(!0),C(!1);try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),o(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){o(`fehler`,e.message)}finally{x(!1)}}}let T=_.data,E;return E=_.isPending?`Wird gelesen …`:T?T.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`:`Lässt sich gerade nicht lesen${_.error?`: ${_.error.message}`:`.`}`,(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,"aria-live":`polite`,children:[(0,g.jsx)(`span`,{className:!T&&!_.isPending?`text-rot-text`:void 0,children:E}),` Nötig für gesperrte Modelle und schnellere Downloads.`,T?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),T?.schreibbar===!1&&(0,g.jsx)(`p`,{className:`text-sm text-bernstein`,children:`Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.`}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&w(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||T?.schreibbar===!1||b,children:b?`Speichert …`:`Speichern`})]}),T?.hf_token_gesetzt&&!T.hf_token_aus_env&&(S?(0,g.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,g.jsx)(n,{variant:`gefahr`,size:`sm`,disabled:b,onClick:()=>w(null),children:`Wirklich löschen?`}),(0,g.jsx)(n,{variant:`ghost`,size:`sm`,onClick:()=>C(!1),children:`Nein`})]}):(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,disabled:b,onClick:()=>C(!0),children:`Zugang löschen`}))]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(l,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
-1
View File
@@ -1 +0,0 @@
import{n as e,r as t}from"./dist-BaE5-hhJ.js";import{g as n,l as r,n as i,r as a,t as o}from"./index-DSxwfDVg.js";var s=e(),c=t(),l=[`Proxmox-Host`,`AdGuard`,`NPMplus`,`NetBird`,`PVE Scripts Local`,`Gitea`,`PBS`,`Arcane mit Docker`];function u(e){let t=(0,s.c)(11),{partner:n}=e;if(n.erreichbar){let e=n.instanz??n.name,r=n.version?` (Version ${n.version})`:``,i;t[0]!==e||t[1]!==r?(i=(0,c.jsxs)(`p`,{className:`text-[15px] text-text-2`,children:[e,` antwortet`,r,`. Die Übersicht der Geräte mit ihren Updates folgt als Nächstes.`]}),t[0]=e,t[1]=r,t[2]=i):i=t[2];let a;t[3]===n.url?a=t[4]:(a=(0,c.jsx)(`p`,{className:`ziffern text-sm text-text-3`,children:n.url}),t[3]=n.url,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,c.jsxs)(c.Fragment,{children:[i,a]}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}let r=n.fehler??`nicht erreichbar`,i;return t[8]!==n.url||t[9]!==r?(i=(0,c.jsxs)(`p`,{role:`alert`,className:`text-[15px] text-rot-text`,children:[`Der Homelab-Teil (`,n.url,`) ist `,r,`. Hält das an, meldet der Wächter es nach fünf Minuten auf Telegram.`]}),t[8]=n.url,t[9]=r,t[10]=i):i=t[10],i}function d(){let e=(0,s.c)(4),t,n;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsx)(`p`,{className:`text-[15px] text-text-2`,children:`Der Homelab-Teil läuft als eigene Instanz in einem kleinen Container auf dem Proxmox-PC. Von dort prüft er auch die KI-Box von außen.`}),n=(0,c.jsx)(`p`,{className:`text-[15px] text-text-2`,children:`Steht er, zeigt diese Seite alle Geräte mit ihren offenen Updates. Ein Update startest du dann hier mit „Jetzt updaten“: Vorher wird gesichert, danach die Erreichbarkeit geprüft, und ist sie rot, geht es von selbst zurück.`}),e[0]=t,e[1]=n):(t=e[0],n=e[1]);let r;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,c.jsx)(`h3`,{className:`schild text-sm tracking-[0.18em] text-text-3`,children:`Dazu kommen`}),e[2]=r):r=e[2];let i;return e[3]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,c.jsxs)(c.Fragment,{children:[t,n,(0,c.jsxs)(`div`,{className:`flex flex-col gap-2`,children:[r,(0,c.jsx)(`ul`,{className:`flex flex-wrap gap-2`,children:l.map(f)})]}),(0,c.jsx)(`p`,{className:`text-sm text-text-3`,children:`Der Container wird erst nach deiner Freigabe angelegt.`})]}),e[3]=i):i=e[3],i}function f(e){return(0,c.jsx)(`li`,{children:(0,c.jsx)(i,{children:e})},e)}function p(){let e=(0,s.c)(12),t=r(),l=n();if(t.isPending||l.isPending){let t;return e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsx)(a,{text:`Wird gelesen …`}),e[0]=t):t=e[0],t}if(!l.data){let t=`Der Stand des Homelab-Teils lässt sich nicht lesen: ${l.error?.message??`keine Daten`}`,n;return e[1]===t?n=e[2]:(n=(0,c.jsx)(a,{fehler:!0,text:t}),e[1]=t,e[2]=n),n}if(t.data?.rolle===`homelab`){let t;return e[3]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsx)(o,{titel:`Homelab`,rechts:(0,c.jsx)(i,{art:`gruen`,children:`Diese Instanz`}),children:(0,c.jsx)(`p`,{className:`text-[15px] text-text-2`,children:`Diese Seite kommt vom Homelab-Teil auf dem Proxmox-PC.`})}),e[3]=t):t=e[3],t}let f=l.data,p;e[4]!==f.eingerichtet||e[5]!==f.erreichbar?(p=f.eingerichtet?f.erreichbar?(0,c.jsx)(i,{art:`gruen`,children:`Verbunden`}):(0,c.jsx)(i,{art:`rot`,children:`Antwortet nicht`}):(0,c.jsx)(i,{children:`Noch nicht eingerichtet`}),e[4]=f.eingerichtet,e[5]=f.erreichbar,e[6]=p):p=e[6];let m=p,h;e[7]===f?h=e[8]:(h=f.eingerichtet?(0,c.jsx)(u,{partner:f}):(0,c.jsx)(d,{}),e[7]=f,e[8]=h);let g;return e[9]!==m||e[10]!==h?(g=(0,c.jsx)(o,{titel:`Homelab`,rechts:m,children:h}),e[9]=m,e[10]=h,e[11]=g):g=e[11],g}export{p as HomelabSeite};
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{n as e,r as t}from"./dist-BaE5-hhJ.js";import{G as n,K as r,q as i}from"./index-DSxwfDVg.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-iGGgLZLg.js";var u=e(),d=t();function f(e){let t=(0,u.c)(16),{onDienste:f,onEinstellungen:p,onSchliessen:m}=e,h;t[0]===m?h=t[1]:(h=e=>!e&&m(),t[0]=m,t[1]=h);let g;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=g):g=t[2];let _;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=_):_=t[3];let v;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[4]=v):v=t[4];let y;t[5]===f?y=t[6]:(y=(0,d.jsxs)(`button`,{type:`button`,onClick:f,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[v,` Dienste und Protokolle`]}),t[5]=f,t[6]=y);let b;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),t[7]=b):b=t[7];let x;t[8]===p?x=t[9]:(x=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[b,` Einstellungen`]}),t[8]=p,t[9]=x);let S;t[10]!==y||t[11]!==x?(S=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[g,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[_,y,x]})]}),t[10]=y,t[11]=x,t[12]=S):S=t[12];let C;return t[13]!==h||t[14]!==S?(C=(0,d.jsx)(l,{open:!0,onOpenChange:h,children:S}),t[13]=h,t[14]=S,t[15]=C):C=t[15],C}export{f as MehrMenue};
+1
View File
@@ -0,0 +1 @@
import{c as e,s as t}from"./button-BheHBEml.js";import{G as n,K as r,q as i}from"./index-SRwQwEnh.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-CKp82pOA.js";var u=t(),d=e();function f(e){let t=(0,u.c)(16),{onDienste:f,onEinstellungen:p,onSchliessen:m}=e,h;t[0]===m?h=t[1]:(h=e=>!e&&m(),t[0]=m,t[1]=h);let g;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=g):g=t[2];let _;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=_):_=t[3];let v;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[4]=v):v=t[4];let y;t[5]===f?y=t[6]:(y=(0,d.jsxs)(`button`,{type:`button`,onClick:f,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[v,` Dienste und Protokolle`]}),t[5]=f,t[6]=y);let b;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),t[7]=b):b=t[7];let x;t[8]===p?x=t[9]:(x=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[b,` Einstellungen`]}),t[8]=p,t[9]=x);let S;t[10]!==y||t[11]!==x?(S=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[g,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[_,y,x]})]}),t[10]=y,t[11]=x,t[12]=S):S=t[12];let C;return t[13]!==h||t[14]!==S?(C=(0,d.jsx)(l,{open:!0,onOpenChange:h,children:S}),t[13]=h,t[14]=S,t[15]=C):C=t[15],C}export{f as MehrMenue};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{n as e,r as t}from"./dist-BaE5-hhJ.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-iGGgLZLg.js";var s=e(),c=t();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
+1
View File
@@ -0,0 +1 @@
import{c as e,s as t}from"./button-BheHBEml.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-CKp82pOA.js";var s=t(),c=e();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{a as e,c as t,i as n,s as r,t as i,u as a}from"./button-BheHBEml.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-CtXmxyGa.js";var p=r();a();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,r),t[3]=r,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(t){let r=(0,p.c)(17),a,o,s,l,d;r[0]===t?(a=r[1],o=r[2],s=r[3],l=r[4],d=r[5]):({className:o,children:a,side:l,showCloseButton:d,...s}=t,r[0]=t,r[1]=a,r[2]=o,r[3]=s,r[4]=l,r[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;r[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),r[6]=v):v=r[6];let y;r[7]===o?y=r[8]:(y=n(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),r[7]=o,r[8]=y);let b;r[9]===h?b=r[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(i,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(e,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),r[9]=h,r[10]=b);let x;return r[11]!==a||r[12]!==s||r[13]!==f||r[14]!==y||r[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[a,b]})]}),r[11]=a,r[12]=s,r[13]=f,r[14]=y,r[15]=b,r[16]=x):x=r[16],x}function y(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`flex flex-col gap-0.5 p-4`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`font-heading text-base font-medium text-foreground`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),r,i;t[0]===e?(r=t[1],i=t[2]):({className:r,...i}=e,t[0]=e,t[1]=r,t[2]=i);let a;t[3]===r?a=t[4]:(a=n(`text-sm text-muted-foreground`,r),t[3]=r,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
-1
View File
@@ -1 +0,0 @@
import{i as e,n as t,r as n,t as r}from"./dist-BaE5-hhJ.js";import{a as i,i as a,n as o,o as s,r as c,s as l,t as u}from"./dist-ndv4TK4_.js";import{W as d,z as f}from"./index-DSxwfDVg.js";var p=t();e();var m=n();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(u,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(s,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,a;t[0]===e?(n=t[1],a=t[2]):({className:n,...a}=e,t[0]=e,t[1]=n,t[2]=a);let o;t[3]===n?o=t[4]:(o=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=o);let s;return t[5]!==a||t[6]!==o?(s=(0,m.jsx)(i,{"data-slot":`sheet-overlay`,className:o,...a}),t[5]=a,t[6]=o,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,i,a,s,l;t[0]===e?(n=t[1],i=t[2],a=t[3],s=t[4],l=t[5]):({className:i,children:n,side:s,showCloseButton:l,...a}=e,t[0]=e,t[1]=n,t[2]=i,t[3]=a,t[4]=s,t[5]=l);let u=s===void 0?`right`:s,h=l===void 0||l,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===i?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,i),t[7]=i,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(o,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(f,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(d,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==a||t[13]!==u||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(c,{"data-slot":`sheet-content`,"data-side":u,className:y,...a,children:[n,b]})]}),t[11]=n,t[12]=a,t[13]=u,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(l,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let o;t[3]===n?o=t[4]:(o=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=o);let s;return t[5]!==i||t[6]!==o?(s=(0,m.jsx)(a,{"data-slot":`sheet-description`,className:o,...i}),t[5]=i,t[6]=o,t[7]=s):s=t[7],s}export{b as a,y as i,v as n,x as r,h as t};
+17 -17
View File
@@ -1,17 +1,17 @@
<!doctype html> <!doctype html>
<html lang="de" class="dark"> <html lang="de" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0e1012" /> <meta name="theme-color" content="#0e1012" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Homelab Orchestrator</title> <title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-DSxwfDVg.js"></script> <script type="module" crossorigin src="/assets/index-SRwQwEnh.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-BaE5-hhJ.js"> <link rel="modulepreload" crossorigin href="/assets/button-BheHBEml.js">
<link rel="stylesheet" crossorigin href="/assets/index-CcgHCQlF.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bpe3m53F.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>
@@ -0,0 +1,45 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { ZielStand } from "@/lib/typen"
import { ZielKarte } from "./ZielKarte"
const gitea: ZielStand = {
id: "ct-104", name: "Gitea", art: "container", instanz: "homelab", erreichbar: true, geprueft: 1_790_000_000,
rueckweg: "Snapshot vor jedem Update", zaehlung: { neu: 1, aktuell: 1 }, offen: 1,
bausteine: [
{
id: "app", name: "Gitea", zustand: "neu", installiert: "1.27.2", verfuegbar: "1.27.3", kurz: "1.27.2 → 1.27.3",
grund: null,
aktion: { methode: "POST", pfad: "/api/homelab/ziele/ct-104/update", label: "Jetzt updaten",
frage: "Gitea jetzt aktualisieren? Vorher wird ein Snapshot angelegt." },
},
{ id: "os", name: "Pakete", zustand: "aktuell", installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null },
],
}
describe("ZielKarte", () => {
it("zeigt Stand, Container-Nummer und Rückweg", () => {
render(<ZielKarte ziel={gitea} laeuft={null} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText("Container 104")).toBeInTheDocument()
expect(screen.getByText("1.27.2 → 1.27.3")).toBeInTheDocument()
expect(screen.getByText("Update bereit")).toBeInTheDocument()
expect(screen.getByText("Rückweg: Snapshot vor jedem Update")).toBeInTheDocument()
expect(screen.getByText("erreichbar")).toBeInTheDocument()
})
it("fragt mit dem Text des Geräts und startet erst nach dem Ja", () => {
const aktion = vi.fn()
render(<ZielKarte ziel={gitea} laeuft={null} gesperrt={false} onAktion={aktion} />)
fireEvent.click(screen.getByRole("button", { name: "Jetzt updaten" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Vorher wird ein Snapshot angelegt/)).toBeInTheDocument()
expect(aktion).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt updaten" }))
expect(aktion).toHaveBeenCalledWith(gitea.bausteine[0].aktion)
})
it("sperrt die Knöpfe, solange ein Update läuft", () => {
render(<ZielKarte ziel={gitea} laeuft="update …" gesperrt={false} onAktion={() => {}} />)
expect(screen.getByRole("status")).toHaveTextContent("Update läuft: update …")
expect(screen.getByRole("button", { name: "Jetzt updaten" })).toBeDisabled()
})
})
@@ -0,0 +1,117 @@
import { useState } from "react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import type { BausteinStand, BausteinZustand, ZielAktion, ZielStand } from "@/lib/typen"
const ZUSTAND: Record<BausteinZustand, { art: ChipArt; text: string }> = {
neu: { art: "cyan", text: "Update bereit" },
aktuell: { art: "gruen", text: "aktuell" },
unbekannt: { art: "bernstein", text: "unklar" },
festgehalten: { art: "bernstein", text: "festgehalten" },
"wird-geprueft": { art: "grau", text: "wird geprüft" },
}
const ART: Record<string, string> = {
"ki-box": "KI-Box",
"proxmox-host": "Proxmox-Host",
container: "Container",
vm: "VM",
}
/** „Container 104“ aus Art und ID („ct-104“). */
function artText(ziel: ZielStand): string {
const nummer = ziel.id.match(/-(\d+)$/)?.[1]
const art = ART[ziel.art] ?? ziel.art
return nummer ? `${art} ${nummer}` : art
}
function Stand({ b }: { b: BausteinStand }) {
if (b.kurz) return <span className="ziffern">{b.kurz}</span>
if (b.installiert && b.verfuegbar) return <span className="ziffern">{b.installiert} → {b.verfuegbar}</span>
if (b.installiert) return <span className="ziffern">{b.installiert}</span>
return null
}
function Zeile({ b, gesperrt, onAktion }: { b: BausteinStand; gesperrt: boolean; onAktion: (a: ZielAktion) => void }) {
const z = ZUSTAND[b.zustand] ?? ZUSTAND.unbekannt
return (
<li className="flex flex-col gap-2 border-t border-linie py-3 first:border-t-0 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="text-[15px] font-medium">{b.name}</span>
<span className="text-sm text-text-2">
<Stand b={b} />
</span>
</div>
<Chip art={z.art}>{z.text}</Chip>
</div>
{b.grund && <p className="text-sm text-text-2">{b.grund}</p>}
{b.aktion && (
<div>
<Button size="sm" variant={b.zustand === "neu" ? "default" : "outline"} disabled={gesperrt}
onClick={() => b.aktion && onAktion(b.aktion)}>
{b.aktion.label}
</Button>
</div>
)}
</li>
)
}
/**
* Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen — dieselbe Karte für beide
* Instanzen, weil beide dasselbe Ziel-Modell liefern (backend/kern/ziele.py). Jeder Knopf fragt vorher
* mit dem Text, den das Gerät selbst mitliefert (Rückweg, Neustart, was wegfällt).
*/
export function ZielKarte({ ziel, laeuft, gesperrt, onAktion }: {
ziel: ZielStand
/** Letzter Schritt eines laufenden Updates für dieses Gerät, sonst null. */
laeuft: string | null
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<ZielAktion | null>(null)
const erreichbar = ziel.erreichbar
return (
<section aria-label={ziel.name} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-linie bg-panel p-5">
<header className="flex flex-wrap items-start justify-between gap-2">
<div className="flex min-w-0 flex-col">
<h3 className="schild text-base font-bold tracking-[0.18em] break-words">{ziel.name}</h3>
<span className="text-sm text-text-3">{artText(ziel)}</span>
</div>
{erreichbar !== null && (
<span className={cn("schild flex items-center gap-1.5 text-xs", erreichbar ? "text-text-3" : "text-rot-text")}>
<span aria-hidden className={cn("size-2 rounded-full", erreichbar ? "bg-gruen" : "bg-rot")} />
{erreichbar ? "erreichbar" : "antwortet nicht"}
</span>
)}
</header>
{laeuft && (
<p role="status" className="rounded-lg border border-cyan-rand bg-cyan-grund px-3 py-2 text-sm text-cyan-text">
Update läuft: {laeuft}
</p>
)}
<ul className="flex flex-col">
{ziel.bausteine.map((b) => (
<Zeile key={b.id} b={b} gesperrt={gesperrt || !!laeuft} onAktion={setFrage} />
))}
</ul>
{ziel.rueckweg && <p className="text-xs text-text-3">Rückweg: {ziel.rueckweg}</p>}
<Bestaetigen
offen={!!frage}
titel={frage ? `${ziel.name}: ${frage.label}` : ""}
text={frage?.frage ?? ""}
knopf={frage?.label ?? ""}
gefahr={!!frage?.pfad.endsWith("/neustart")}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</section>
)
}
+31 -2
View File
@@ -9,8 +9,8 @@ import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen" import { melden } from "./meldungen"
import { stromVerbunden } from "./strom" import { stromVerbunden } from "./strom"
import type { import type {
AktionsErgebnis, AufraeumKandidat, Instanz, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start, AktionsErgebnis, AufraeumKandidat, HomelabLauf, HomelabStand, Instanz, Job, Modell, Nutzung, PartnerStatus, Radar,
UpdateDetails, Sicherung, Start, UpdateDetails, ZielAktion, ZielStand,
} from "./typen" } from "./typen"
import { gbText } from "./zeit" import { gbText } from "./zeit"
@@ -262,3 +262,32 @@ export const useInstanz = () =>
/** Lebenszeichen der anderen Instanz — die Box prüft den Homelab-Teil und umgekehrt. */ /** Lebenszeichen der anderen Instanz — die Box prüft den Homelab-Teil und umgekehrt. */
export const usePartner = () => export const usePartner = () =>
useQuery({ queryKey: ["partner"], queryFn: () => api<PartnerStatus>("/api/partner"), refetchInterval: 30_000 }) useQuery({ queryKey: ["partner"], queryFn: () => api<PartnerStatus>("/api/partner"), refetchInterval: 30_000 })
/** Die KI-Box im gemeinsamen Ziel-Modell (Box-Adapter, GET /api/ziele). */
export const useBoxZiele = () =>
useQuery({ queryKey: ["ziele-box"], queryFn: () => api<{ ziele: ZielStand[] }>("/api/ziele"), refetchInterval: takt(30_000) })
/** Proxmox-Host und Gäste — nur, wenn es den Homelab-Teil gibt (sonst gäbe es nur 404). */
export const useHomelab = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab"],
queryFn: () => api<HomelabStand>("/api/homelab/ziele"),
enabled: aktiv,
refetchInterval: 30_000,
})
export const useHomelabLaeufe = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-laeufe"],
queryFn: () => api<{ laeufe: HomelabLauf[] }>("/api/homelab/laeufe"),
enabled: aktiv,
// Läuft ein Update, die Schritte zeitnah zeigen.
refetchInterval: (q) => (q.state.data?.laeufe.some((l) => l.status === "laeuft") ? 3000 : 30_000),
})
/** Knopf eines Bausteins: die Aktion steht im Baustein selbst (Methode, Pfad, Rückfrage). */
export const useZielAktion = () =>
useAktion((a: ZielAktion) => api<AktionsErgebnis>(a.pfad, { method: a.methode }), {
erfolg: (a) => `${a.label}: gestartet. Das Ergebnis kommt als Meldung.`,
neuLaden: ["homelab", "homelab-laeufe", "ziele-box", "jobs", "start"],
})
+59
View File
@@ -243,3 +243,62 @@ export interface PartnerStatus {
/** Unix-Sekunden */ /** Unix-Sekunden */
geprueft?: number geprueft?: number
} }
// --- Gemeinsames Ziel-Modell (backend/kern/ziele.py, seit Phase 2) ---------------------------------
export type BausteinZustand = "neu" | "aktuell" | "unbekannt" | "festgehalten" | "wird-geprueft"
/** Wie ein Update angestoßen wird: eine Schnittstelle der Instanz, die das Gerät pflegt. */
export interface ZielAktion {
methode: string
pfad: string
frage: string
label: string
}
export interface BausteinStand {
id: string
name: string
zustand: BausteinZustand
installiert: string | null
verfuegbar: string | null
kurz: string
grund: string | null
aktion: ZielAktion | null
}
/** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */
export interface ZielStand {
id: string
name: string
art: "ki-box" | "proxmox-host" | "container" | "vm" | string
instanz: "box" | "homelab" | string
erreichbar: boolean | null
bausteine: BausteinStand[]
/** Unix-Sekunden */
geprueft: number | null
rueckweg: string | null
zaehlung: Partial<Record<BausteinZustand, number>>
offen: number
}
/** GET /api/homelab/ziele */
export interface HomelabStand {
ziele: ZielStand[]
bericht: { empfangen: number; veraltet: boolean } | null
ausfuehrer: { verbunden: boolean; zuletzt: number | null }
}
/** Ein „Jetzt updaten“-Lauf im Homelab (GET /api/homelab/laeufe). */
export interface HomelabLauf {
id: string
ziel: string
baustein: string
name: string
status: "laeuft" | "fertig" | "unterbrochen"
start: number
ende: number | null
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | null
text: string | null
schritte: string[]
}
+110 -49
View File
@@ -1,29 +1,18 @@
import { Chip, 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 { useInstanz, usePartner } from "@/lib/abfragen" import { ZielKarte } from "@/components/cockpit/ZielKarte"
import type { PartnerStatus } from "@/lib/typen" import { useBoxZiele, useHomelab, useHomelabLaeufe, useInstanz, usePartner, useZielAktion } from "@/lib/abfragen"
import type { HomelabLauf, PartnerStatus, ZielStand } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026; ab Phase 3 kommt die Liste live). */ /** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"] const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
function Verbindung({ partner }: { partner: PartnerStatus }) { const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
if (partner.erreichbar) { eingespielt: { art: "gruen", text: "eingespielt" },
return ( zurueckgerollt: { art: "bernstein", text: "zurückgerollt" },
<> fehler: { art: "rot", text: "Fehler" },
<p className="text-[15px] text-text-2"> neustart: { art: "cyan", text: "Neustart" },
{partner.instanz ?? partner.name} antwortet{partner.version ? ` (Version ${partner.version})` : ""}. Die
Übersicht der Geräte mit ihren Updates folgt als Nächstes.
</p>
<p className="ziffern text-sm text-text-3">{partner.url}</p>
</>
)
}
return (
<p role="alert" className="text-[15px] text-rot-text">
Der Homelab-Teil ({partner.url}) ist {partner.fehler ?? "nicht erreichbar"}. Hält das an, meldet der Wächter es
nach fünf Minuten auf Telegram.
</p>
)
} }
function NochNichtEingerichtet() { function NochNichtEingerichtet() {
@@ -34,8 +23,8 @@ function NochNichtEingerichtet() {
auch die KI-Box von außen. auch die KI-Box von außen.
</p> </p>
<p className="text-[15px] text-text-2"> <p className="text-[15px] text-text-2">
Steht er, zeigt diese Seite alle Geräte mit ihren offenen Updates. Ein Update startest du dann hier mit Steht er, erscheinen hier alle Geräte mit ihren offenen Updates. Ein Update startest du mit „Jetzt
„Jetzt updaten“: Vorher wird gesichert, danach die Erreichbarkeit geprüft, und ist sie rot, geht es von selbst updaten“: Vorher wird gesichert, danach die Erreichbarkeit geprüft, und ist sie rot, geht es von selbst
zurück. zurück.
</p> </p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -53,34 +42,106 @@ function NochNichtEingerichtet() {
) )
} }
export function HomelabSeite() { function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
const instanz = useInstanz() const chip = !partner.eingerichtet
const partner = usePartner()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!partner.data) {
return <Zustandsfeld fehler text={`Der Stand des Homelab-Teils lässt sich nicht lesen: ${partner.error?.message ?? "keine Daten"}`} />
}
// Liefert der Homelab-Teil die Seite selbst aus, ist die Box sein Partner (ab Phase 3).
if (instanz.data?.rolle === "homelab") {
return (
<Panel titel="Homelab" rechts={<Chip art="gruen">Diese Instanz</Chip>}>
<p className="text-[15px] text-text-2">Diese Seite kommt vom Homelab-Teil auf dem Proxmox-PC.</p>
</Panel>
)
}
const p = partner.data
const chip = !p.eingerichtet
? <Chip>Noch nicht eingerichtet</Chip> ? <Chip>Noch nicht eingerichtet</Chip>
: p.erreichbar : <Chip art="rot">Antwortet nicht</Chip>
? <Chip art="gruen">Verbunden</Chip>
: <Chip art="rot">Antwortet nicht</Chip>
return ( return (
<Panel titel="Homelab" rechts={chip}> <Panel titel="Homelab" rechts={chip}>
{p.eingerichtet ? <Verbindung partner={p} /> : <NochNichtEingerichtet />} {partner.eingerichtet ? (
<p role="alert" className="text-[15px] text-rot-text">
Der Homelab-Teil ({partner.url}) ist {partner.fehler ?? "nicht erreichbar"}. Hält das an, meldet der
Wächter es nach fünf Minuten auf Telegram.
</p>
) : (
<NochNichtEingerichtet />
)}
</Panel> </Panel>
) )
} }
function LetzteLaeufe({ laeufe }: { laeufe: HomelabLauf[] }) {
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 6)
if (fertig.length === 0) return null
return (
<Panel titel="Letzte Updates im Homelab">
<ul className="flex flex-col gap-3">
{fertig.map((l) => {
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
return (
<li key={l.id} className="flex flex-col gap-1 border-t border-linie pt-3 first:border-t-0 first:pt-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[15px] font-medium">{l.name}</span>
<span className="flex items-center gap-2">
<span className="ziffern text-sm text-text-3">{flugplanZeit(new Date(l.start * 1000).toISOString())}</span>
<Chip art={e.art}>{e.text}</Chip>
</span>
</div>
{l.text && <p className="text-sm text-text-2">{l.text}</p>}
</li>
)
})}
</ul>
</Panel>
)
}
export function HomelabSeite() {
const instanz = useInstanz()
const partner = usePartner()
const box = useBoxZiele()
const homelabHier = instanz.data?.rolle === "homelab"
const aktiv = homelabHier || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const homelab = useHomelab(aktiv)
const laeufe = useHomelabLaeufe(aktiv)
const aktion = useZielAktion()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
// Laufende Updates je Gerät: der letzte Schritt steht auf der Karte.
const laufend = new Map<string, string>()
for (const l of laeufe.data?.laeufe ?? []) {
if (l.status === "laeuft") laufend.set(l.ziel, l.schritte.at(-1) ?? "startet …")
}
const ziele: ZielStand[] = [...(box.data?.ziele ?? []), ...(homelab.data?.ziele ?? [])]
const offen = ziele.reduce((n, z) => n + z.offen, 0)
const bericht = homelab.data?.bericht
return (
<>
<Panel
titel="Alle Geräte"
rechts={offen > 0 ? <Chip art="cyan">{offen} {offen === 1 ? "Update" : "Updates"} bereit</Chip> : <Chip art="gruen">aktuell</Chip>}
>
<p className="text-[15px] text-text-2">
Die KI-Box und das Homelab an einem Ort. Updates startest du nur hier per Knopf; jedes fragt vorher und sagt,
welcher Rückweg bereitsteht.
</p>
{aktiv && homelab.data && !homelab.data.ausfuehrer.verbunden && (
<p role="alert" className="text-[15px] text-bernstein">
Der Ausführer auf dem Proxmox-Host meldet sich nicht
{homelab.data.ausfuehrer.zuletzt ? ` (zuletzt ${flugplanZeit(new Date(homelab.data.ausfuehrer.zuletzt * 1000).toISOString())})` : ""}.
Die Homelab-Geräte zeigen den letzten bekannten Stand.
</p>
)}
{aktiv && homelab.data && !bericht && (
<p className="text-[15px] text-text-2">Der Ausführer auf dem Proxmox-Host hat noch keinen Bericht geschickt.</p>
)}
{box.isError && <p role="alert" className="text-sm text-rot-text">Die KI-Box antwortet gerade nicht: {box.error.message}</p>}
{homelab.isError && <p role="alert" className="text-sm text-rot-text">Der Homelab-Teil antwortet gerade nicht: {homelab.error.message}</p>}
</Panel>
{ziele.length > 0 && (
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
{ziele.map((z) => (
<ZielKarte key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={aktion.isPending}
onAktion={(a) => aktion.mutate(a)} />
))}
</div>
)}
{!aktiv && partner.data && <HomelabFehlt partner={partner.data} />}
{aktiv && <LetzteLaeufe laeufe={laeufe.data?.laeufe ?? []} />}
</>
)
}