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,70 @@
"""Log streaming — tail journalctl output for systemd-managed services."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from fastapi import APIRouter, HTTPException, Query, status
from starlette.responses import StreamingResponse
from castle_cli.config import load_config
from castle_api.config import settings
router = APIRouter(prefix="/logs", tags=["logs"])
UNIT_PREFIX = "castle-"
@router.get("/{name}", response_model=None)
async def get_logs(
name: str,
n: int = Query(default=100, ge=1, le=5000, description="Number of lines"),
follow: bool = Query(default=False, description="Stream new lines via SSE"),
) -> StreamingResponse | dict:
"""Get logs for a systemd-managed service."""
config = load_config(settings.castle_root)
if name not in config.managed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
)
unit = f"{UNIT_PREFIX}{name}.service"
if follow:
return StreamingResponse(
_follow_logs(unit, n),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Static tail
proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-u", unit, "-n", str(n), "--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
lines = (stdout or b"").decode().splitlines()
return {"component": name, "lines": lines}
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
"""Stream journalctl -f output as SSE events."""
proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-u", unit, "-n", str(n), "-f", "--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
assert proc.stdout is not None
async for line in proc.stdout:
text = line.decode().rstrip()
yield f"data: {text}\n\n"
except asyncio.CancelledError:
pass
finally:
proc.kill()
await proc.wait()