Files
mission-control-v2/backend/routers/events.py
T
werkstatt 41e569cf99 backend: SSE-Eventstrom /api/events (UMBAU v3 P3a)
- Neuer Router routers/events.py mit EventSource-Endpunkt\n- Heartbeat alle 25s, invalidate-Event bei Status-Änderung\n- Keys: system-status, models, agent-status, ideen, token-stats
2026-07-15 15:38:32 +02:00

163 lines
4.7 KiB
Python

"""SSE-Eventstrom /api/events — Server-Sent Events für Frontend-Updates.
EIN EventSource-Endpunkt, der Changes der heute gepollten Zustände pusht:
- System-Status (SystemStatus)
- Modelle/running (ModelsResp)
- Agent-Status (AgentStatus)
- Auftragsbuch/Ideen-Zaehler (IdeenResp)
- Token-Stats (TokenStats)
Event-Schema:
type: "invalidate"
data: {"keys": ["system-status", "models", "agent-status", "ideen", "token-stats"]}
"""
import asyncio
import json
import logging
import time
from typing import Any, AsyncGenerator
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from config import HERMES_API_URL, LLAMA_SWAP_URL, GATEWAY_URL
import httpx
from services import agent, llamaswap
from services.system import system_status
from services.token_stats import get_stats
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Aktueller Snapshot für Diff-Prüfung (kein Lock nötig: Python GIL, single writer)
_last_snapshot: dict[str, dict[str, Any]] = {
"system-status": {},
"models": {},
"agent-status": {},
"ideen": {},
"token-stats": {},
}
# Heartbeat-Intervall (Sekunden) — EventSource reconnectet nach ~3s ohne Daten
HEARTBEAT_INTERVAL = 25
async def _get_system_status() -> dict:
try:
return system_status()
except Exception:
return {}
async def _get_models() -> dict:
try:
items = llamaswap.list_models()
return {"models": items, "count": len(items), "running": llamaswap.get_running_models()}
except Exception:
return {"models": [], "count": 0, "running": []}
async def _get_agent_status() -> dict:
try:
return agent.agent_status()
except Exception:
return {}
async def _get_ideen_count() -> dict:
"""Ideen-Zaehler (nur count, nicht volle Liste — zu teuer)."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(f"{GATEWAY_URL}/v1/ideen", headers={"Authorization": "Bearer placeholder"})
if r.status_code == 200:
data = r.json()
return {"count": len(data.get("items", []))}
except Exception:
pass
return {"count": 0}
async def _get_token_stats() -> dict:
try:
return get_stats()
except Exception:
return {}
def _snapshot_diff(key: str, new: dict) -> bool:
"""Gibt True zurück, wenn sich das Snapshot geändert hat."""
old = _last_snapshot.get(key, {})
changed = old != new
if changed:
_last_snapshot[key] = new
return changed
async def event_stream() -> AsyncGenerator[str, None]:
"""Generiert SSE-Events."""
# Initialer Heartbeat sofort
yield f": heartbeat\n\n"
while True:
try:
# Alle Status abrufen
tasks = {
"system-status": asyncio.create_task(_get_system_status()),
"models": asyncio.create_task(_get_models()),
"agent-status": asyncio.create_task(_get_agent_status()),
"ideen": asyncio.create_task(_get_ideen_count()),
"token-stats": asyncio.create_task(_get_token_stats()),
}
# Warten auf alle (mit Timeout)
await asyncio.wait(tasks.values(), timeout=10)
# Prüfen, ob sich etwas geändert hat
keys_to_invalidate: list[str] = []
for key, task in tasks.items():
try:
new = task.result()
if _snapshot_diff(key, new):
keys_to_invalidate.append(key)
except Exception as exc:
log.warning(f"Event {key} failed: {exc}")
if keys_to_invalidate:
event = json.dumps({"keys": keys_to_invalidate})
yield f"type: invalidate\n"
yield f"data: {event}\n\n"
log.info(f"SSE invalidate: {keys_to_invalidate}")
except asyncio.CancelledError:
log.info("SSE stream cancelled")
break
except Exception as exc:
log.error(f"SSE stream error: {exc}")
break
# Heartbeat alle HEARTBEAT_INTERVAL Sekunden
try:
for _ in range(HEARTBEAT_INTERVAL * 10): # 100ms Intervall
await asyncio.sleep(0.1)
yield f": heartbeat\n\n"
except asyncio.CancelledError:
break
@router.get("/events")
async def events_endpoint(request: Request):
"""SSE-Endpunkt für EventSource."""
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Nginx-Buffering deaktivieren
},
)