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:
2026-02-21 01:23:37 -08:00
parent f99c2dad86
commit 930bc601b7
63 changed files with 154 additions and 157 deletions

View 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)