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,189 @@
"""Config editor — read, validate, save, and apply castle.yaml changes."""
from __future__ import annotations
import asyncio
import shutil
import yaml
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_cli.config import load_config, save_config
from castle_cli.manifest import ComponentManifest
from castle_api.config import settings
from castle_api.stream import broadcast
router = APIRouter(prefix="/config", tags=["config"])
class ConfigResponse(BaseModel):
yaml_content: str
class ConfigSaveRequest(BaseModel):
yaml_content: str
class ConfigSaveResponse(BaseModel):
ok: bool
component_count: int
errors: list[str]
class ApplyResponse(BaseModel):
ok: bool
actions: list[str]
errors: list[str]
class ComponentConfigRequest(BaseModel):
config: dict
@router.get("", response_model=ConfigResponse)
def get_config() -> ConfigResponse:
"""Get the raw castle.yaml content."""
config_path = settings.castle_root / "castle.yaml"
return ConfigResponse(yaml_content=config_path.read_text())
@router.put("", response_model=ConfigSaveResponse)
def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
"""Validate and save castle.yaml. Does NOT apply changes."""
errors: list[str] = []
# Parse YAML
try:
data = yaml.safe_load(request.yaml_content)
except yaml.YAMLError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid YAML: {e}",
)
if not isinstance(data, dict) or "components" not in data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="YAML must have a 'components' key",
)
# Validate each component
count = 0
for name, comp_data in data.get("components", {}).items():
try:
comp_data_copy = dict(comp_data) if comp_data else {}
comp_data_copy["id"] = name
ComponentManifest.model_validate(comp_data_copy)
count += 1
except Exception as e:
errors.append(f"{name}: {e}")
if errors:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"errors": errors},
)
# Backup and save
config_path = settings.castle_root / "castle.yaml"
backup_path = config_path.with_suffix(".yaml.bak")
shutil.copy2(config_path, backup_path)
config_path.write_text(request.yaml_content)
return ConfigSaveResponse(ok=True, component_count=count, errors=[])
@router.put("/components/{name}")
def save_component(name: str, request: ComponentConfigRequest) -> dict:
"""Update a single component's config in castle.yaml."""
# Validate
try:
comp_data = dict(request.config)
comp_data["id"] = name
ComponentManifest.model_validate(comp_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid component config: {e}",
)
config = load_config(settings.castle_root)
config.components[name] = ComponentManifest.model_validate(
{**request.config, "id": name}
)
save_config(config)
return {"ok": True, "component": name}
@router.delete("/components/{name}")
def delete_component(name: str) -> dict:
"""Remove a component from castle.yaml."""
config = load_config(settings.castle_root)
if name not in config.components:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
)
del config.components[name]
save_config(config)
return {"ok": True, "component": name, "action": "deleted"}
@router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse:
"""Apply config: regenerate systemd units for managed services + reload gateway."""
config = load_config(settings.castle_root)
actions: list[str] = []
errors: list[str] = []
# Regenerate and restart managed services
for name in config.managed:
unit = f"castle-{name}.service"
ok, output = await _systemctl("restart", unit)
if ok:
actions.append(f"Restarted {name}")
else:
errors.append(f"Failed to restart {name}: {output}")
# Reload gateway
from castle_cli.commands.gateway import _generate_caddyfile
from castle_cli.config import GENERATED_DIR, ensure_dirs
ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(_generate_caddyfile(config))
actions.append("Generated Caddyfile")
if shutil.which("caddy"):
ok, output = await _run("caddy", "reload",
"--config", str(caddyfile_path),
"--adapter", "caddyfile")
if ok:
actions.append("Reloaded gateway")
else:
errors.append(f"Gateway reload failed: {output}")
await broadcast("config-changed", {"actions": actions})
return ApplyResponse(ok=len(errors) == 0, actions=actions, errors=errors)
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", action, unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode == 0, (stdout or stderr or b"").decode().strip()
async def _run(*args: str) -> tuple[bool, str]:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode == 0, (stdout or stderr or b"").decode().strip()