"""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/{id}/docker Docker über Arcane (zuerst Probelauf) 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/hinweise Hinweise des Wächters dieser Instanz 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} ┘ GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt) Dünn: die Logik liegt in services/homelab/. """ import asyncio import json import time from fastapi import APIRouter, Depends, Header, HTTPException, Request from fastapi.responses import StreamingResponse from kern.einstellungen import einstellungen from pydantic import BaseModel from services.homelab import inventar, kanal, updates router = APIRouter(prefix="/api/homelab", tags=["homelab"]) strom_router = APIRouter(prefix="/api", 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/{ziel_id}/docker") def docker(ziel_id: str) -> dict: """Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt).""" return _antwort(updates.starten(ziel_id, "docker")) @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("/hinweise") def hinweise() -> dict: """Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …).""" from services import waechter return waechter.lese_stand() @router.get("/laeufe") def laeufe() -> dict: return {"laeufe": updates.laeufe()} # --- „Alle aktualisieren“, Protokoll, Einstellungen (24.09.2026) ------------------------------------------------ # GET /api/homelab/alle/plan was „Alle aktualisieren“ täte, in dieser Reihenfolge # POST /api/homelab/alle „Alle aktualisieren“ starten # GET /api/homelab/sammellauf der laufende oder der letzte Sammellauf # GET /api/homelab/protokoll?grenze=200&berichte=0 was wann geschah, neueste zuerst # GET /api/homelab/einstellungen Arcane (Schlüssel, echt), Wartezeit, Ausführer # POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern # DELETE /api/homelab/einstellungen/arcane-schluessel # POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf # Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche # liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise(). @router.get("/alle/plan") def alle_plan() -> dict: from services.homelab import sammellauf return sammellauf.plan() @router.post("/alle") def alle_starten() -> dict: from services.homelab import sammellauf return sammellauf.starten() @router.get("/sammellauf") def sammellauf_stand() -> dict: from services.homelab import sammellauf return {"sammellauf": sammellauf.aktueller()} @router.get("/protokoll") def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict: from services.homelab import protokoll return {"eintraege": protokoll.eintraege(grenze, berichte)} @router.get("/einstellungen") def einstellungen_stand() -> dict: from services.homelab import einstellungen as homelab_einstellungen return homelab_einstellungen.stand() @router.post("/einstellungen/arcane-schluessel") async def arcane_schluessel_setzen(request: Request) -> dict: """Den Body liest der Dienst selbst: Ein Pydantic-Modell würde bei falscher Form mit 422 antworten und die Eingabe, also den Schlüssel, dabei wiederholen.""" from services.homelab import einstellungen as homelab_einstellungen wert = homelab_einstellungen.schluessel_aus_body(await request.body()) return await asyncio.to_thread(homelab_einstellungen.arcane_schluessel_setzen, wert) @router.delete("/einstellungen/arcane-schluessel") def arcane_schluessel_loeschen() -> dict: from services.homelab import einstellungen as homelab_einstellungen return homelab_einstellungen.arcane_schluessel_loeschen() class ArcaneEcht(BaseModel): an: bool @router.post("/einstellungen/arcane-echt") def arcane_echt(schalter: ArcaneEcht) -> dict: from services.homelab import einstellungen as homelab_einstellungen return homelab_einstellungen.arcane_echt_setzen(schalter.an) @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)} # --- Live-Strom der Homelab-Instanz --------------------------------------------------------------- # Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine # Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern, # und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot). _DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"), ("homelab-hinweise", "mc2-waechter.json")) def _abdruecke() -> dict[str, float]: ordner = einstellungen().daten_dir abdruck = {} for schluessel, name in _DATEIEN: try: abdruck[schluessel] = (ordner / name).stat().st_mtime except OSError: abdruck[schluessel] = 0.0 return abdruck async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5): alt = _abdruecke() yield ": verbunden\n\n" takte = 0 while not await request.is_disconnected(): await asyncio.sleep(takt_s) takte += 1 neu = _abdruecke() schluessel = [k for k, v in neu.items() if v != alt.get(k)] alt = neu if schluessel or takte % lebenszeichen_takte == 0: yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n" @strom_router.get("/stream") async def stream(request: Request) -> StreamingResponse: return StreamingResponse(_strom(request), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) # --- Messwerte mit Verlauf (Monitoring, seit 24.09.2026) ------------------------------------------------------ # POST /api/homelab/ausfuehrer/messwerte jede Minute vom Ausführer (Kopfzeile X-MC2-Ausfuehrer) # GET /api/homelab/messwerte?zeitraum=1h|24h|7d Proxmox-Host und Gäste: Reihen und letzter Punkt # Die Logik liegt in services/homelab/messwerte.py, die Ablage in kern/messreihen.py. @router.post("/ausfuehrer/messwerte", dependencies=[Depends(_nur_ausfuehrer)]) def messwerte_annehmen(daten: dict) -> dict: from services.homelab import messwerte as homelab_messwerte return {"ok": True, "geraete": homelab_messwerte.annehmen(daten)} @router.get("/messwerte") def messwerte_lesen(zeitraum: str = "1h") -> dict: from services.homelab import messwerte as homelab_messwerte if zeitraum not in homelab_messwerte.ZEITRAEUME: raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.") return homelab_messwerte.abfrage(zeitraum)