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:
co-authored by
Claude Opus 5.5
parent
a47489fa85
commit
5e9227c4a3
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
"""
|
||||||
@@ -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}"
|
||||||
@@ -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)
|
||||||
@@ -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()))
|
||||||
@@ -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"]}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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))."
|
||||||
@@ -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."
|
||||||
@@ -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())
|
||||||
@@ -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'"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2
File diff suppressed because one or more lines are too long
-2
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
@@ -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
@@ -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 +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};
|
|
||||||
@@ -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
@@ -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};
|
|
||||||
+1
File diff suppressed because one or more lines are too long
-1
@@ -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
@@ -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};
|
||||||
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -1 +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};
|
|
||||||
@@ -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};
|
||||||
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+41
File diff suppressed because one or more lines are too long
-41
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+1
@@ -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
@@ -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};
|
|
||||||
Vendored
+3
-3
@@ -7,9 +7,9 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
})
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
+109
-48
@@ -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="gruen">Verbunden</Chip>
|
|
||||||
: <Chip art="rot">Antwortet nicht</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 ?? []} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user