homelab: eigener Live-Strom (keine Getrennt-Anzeige mehr), Hinweise des Homelab-Waechters auf der Seite, Verbindungsanzeige je Instanz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 19:10:02 +02:00
co-authored by Claude Opus 5.5
parent 83c33584e4
commit 160381d8b3
17 changed files with 148 additions and 19 deletions
+1
View File
@@ -166,6 +166,7 @@ if ROLLE == "box":
else:
from routers import homelab
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
+55 -1
View File
@@ -7,21 +7,29 @@
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
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:
@@ -70,6 +78,13 @@ def suchen(ziel_id: str) -> dict:
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()}
@@ -101,3 +116,42 @@ class Ergebnis(BaseModel):
@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"})
+37
View File
@@ -297,3 +297,40 @@ def test_eigener_container_erscheint_nicht(daten):
"etiketten": ["mc2"], "erlaubt": False, "ip": "192.168.178.31"})
kanal.bericht_speichern(bericht)
assert "ct-107" not in {z["id"] for z in inventar.ziele()["ziele"]}
def test_strom_stoesst_bei_aenderung_an_und_meldet_sich(daten):
import asyncio
from routers import homelab as homelab_router
class Anfrage:
def __init__(self):
self.runden = 0
async def is_disconnected(self):
self.runden += 1
return self.runden > 4
async def lesen() -> list[str]:
teile = []
async for teil in homelab_router._strom(Anfrage(), takt_s=0.01, lebenszeichen_takte=2):
teile.append(teil)
if len(teile) == 1:
kanal.bericht_speichern(BERICHT) # ein neuer Bericht → Anstoß „homelab“
return teile
teile = asyncio.run(lesen())
assert teile[0] == ": verbunden\n\n"
assert teile[1] == 'event: invalidate\ndata: {"keys": ["homelab"]}\n\n'
assert 'event: invalidate\ndata: {"keys": []}\n\n' in teile[2:] # Lebenszeichen
def test_hinweise_des_homelab_waechters(daten, monkeypatch):
from services import waechter
monkeypatch.setattr(waechter, "STORE_PATH", daten / "mc2-waechter.json")
(daten / "mc2-waechter.json").write_text(json.dumps({"hinweise": {"gast:ct-104": {
"id": "gast:ct-104", "stufe": "rot", "titel": "Gitea antwortet nicht", "text": "…", "seit": 1}}}),
encoding="utf-8")
antwort = _client().get("/api/homelab/hinweise").json()
assert [h["titel"] for h in antwort["hinweise"]] == ["Gitea antwortet nicht"]