feat: add ComponentGrid and ComponentTable components for displaying components in grid and table formats
feat: implement HealthBadge and RoleBadge components for displaying health status and roles feat: create LogViewer component for streaming logs of services feat: develop SecretsEditor for managing secrets with CRUD operations feat: introduce ToolCard component for displaying tool information style: add global CSS variables and styles for consistent theming feat: set up API client for handling requests to the backend feat: implement hooks for fetching components, statuses, and tools feat: create routes for dashboard, component details, and tools feat: add service management endpoints for starting, stopping, and restarting services feat: implement event bus for handling real-time updates via SSE feat: create health check and logs endpoints for monitoring services feat: add tests for health and tools endpoints to ensure functionality chore: update project configuration files and dependencies for better development experience
This commit is contained in:
71
castle-api/src/castle_api/stream.py
Normal file
71
castle-api/src/castle_api/stream.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""SSE stream — pushes health updates and service action events to connected clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.health import check_all_health
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All connected SSE clients receive events through this queue-based broadcast.
|
||||
_subscribers: list[asyncio.Queue[str]] = []
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue[str]:
|
||||
"""Register a new SSE client. Returns a queue to read events from."""
|
||||
q: asyncio.Queue[str] = asyncio.Queue(maxsize=64)
|
||||
_subscribers.append(q)
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: asyncio.Queue[str]) -> None:
|
||||
"""Remove a disconnected SSE client."""
|
||||
try:
|
||||
_subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def close_all_subscribers() -> None:
|
||||
"""Unblock all SSE generators so they exit during shutdown."""
|
||||
for q in list(_subscribers):
|
||||
try:
|
||||
q.put_nowait("")
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
_subscribers.clear()
|
||||
|
||||
|
||||
async def broadcast(event_type: str, data: dict) -> None:
|
||||
"""Send an event to all connected SSE clients."""
|
||||
payload = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
||||
dead: list[asyncio.Queue[str]] = []
|
||||
for q in _subscribers:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
unsubscribe(q)
|
||||
|
||||
|
||||
async def health_poll_loop(interval: float = 10.0) -> None:
|
||||
"""Background task that polls health and broadcasts updates."""
|
||||
while True:
|
||||
try:
|
||||
config = load_config(settings.castle_root)
|
||||
statuses = await check_all_health(config)
|
||||
await broadcast("health", {
|
||||
"statuses": [s.model_dump() for s in statuses],
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("Health poll failed")
|
||||
await asyncio.sleep(interval)
|
||||
Reference in New Issue
Block a user