refactor: Remove dedicated /tools endpoints, use /programs?behavior=tool instead
Eliminates inconsistency where tools had their own filtered view but daemons and frontends didn't. Changes: - API: Removed tools router and ToolSummary/ToolDetail models. Added optional behavior query parameter to GET /programs for filtering by program type (tool, daemon, frontend). - Frontend: Updated hooks to pass behavior param to usePrograms instead of separate useTools. Updated components to use ProgramSummary type. Removed useToolDetail hook. - Docs: Updated API documentation to reflect program behavior filtering.
This commit is contained in:
11
README.md
11
README.md
@@ -179,10 +179,11 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
|
||||
|----------|-------------|
|
||||
| `GET /health` | Health check |
|
||||
| `GET /stream` | SSE stream (health, service-action, mesh events) |
|
||||
| **Programs & Components** | |
|
||||
| `GET /programs` | List all programs |
|
||||
| **Programs** | |
|
||||
| `GET /programs` | List all programs (`?behavior=tool\|daemon\|frontend` to filter) |
|
||||
| `GET /programs/{name}` | Program detail |
|
||||
| `GET /components` | Unified view of all components (`?include_remote=true` for cross-node) |
|
||||
| `POST /programs/{name}/{action}` | Run a lifecycle action (build, test, lint, install, etc.) |
|
||||
| `GET /components` | Unified view across nodes (`?include_remote=true` for cross-node) |
|
||||
| `GET /components/{name}` | Component detail |
|
||||
| **Services** | |
|
||||
| `GET /services` | List all services with status |
|
||||
@@ -206,10 +207,6 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
|
||||
| `PUT /config/services/{name}` | Update a service entry |
|
||||
| `PUT /config/jobs/{name}` | Update a job entry |
|
||||
| `POST /config/apply` | Apply config changes (deploy + reload) |
|
||||
| **Tools** | |
|
||||
| `GET /tools` | List all tools |
|
||||
| `GET /tools/{name}` | Tool detail |
|
||||
| `POST /programs/{name}/{action}` | Run a program action (build, test, lint, install) |
|
||||
| **Secrets** | |
|
||||
| `GET /secrets` | List secrets |
|
||||
| `GET /secrets/{name}` | Read a secret |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { Terminal } from "lucide-react"
|
||||
import type { ToolSummary } from "@/types"
|
||||
import type { ProgramSummary } from "@/types"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: ToolSummary
|
||||
tool: ProgramSummary
|
||||
}
|
||||
|
||||
export function ToolCard({ tool }: ToolCardProps) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
|
||||
import { useProgram, useEventStream } from "@/services/api/hooks"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||
import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
||||
@@ -11,7 +11,6 @@ export function ComponentDetailPage() {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
|
||||
const isTool = component?.behavior === "tool"
|
||||
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
|
||||
|
||||
if (isLoading) {
|
||||
@@ -52,7 +51,7 @@ export function ComponentDetailPage() {
|
||||
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{component.description}</p>
|
||||
)}
|
||||
|
||||
{toolDetail && (
|
||||
{isTool && (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
|
||||
Tool Info
|
||||
@@ -61,32 +60,32 @@ export function ComponentDetailPage() {
|
||||
How this tool is packaged and what it depends on.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
|
||||
{toolDetail.source && (
|
||||
{component.source && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Source</span>
|
||||
<span className="font-mono">{toolDetail.source}</span>
|
||||
<span className="font-mono">{component.source}</span>
|
||||
</>
|
||||
)}
|
||||
{toolDetail.version && (
|
||||
{component.version && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Version</span>
|
||||
<span>{toolDetail.version}</span>
|
||||
<span>{component.version}</span>
|
||||
</>
|
||||
)}
|
||||
{toolDetail.runner && (
|
||||
{component.runner && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Runner</span>
|
||||
<span>{runnerLabel(toolDetail.runner)}</span>
|
||||
<span>{runnerLabel(component.runner)}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-[var(--muted)]">Installed</span>
|
||||
<span>{component.installed ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
{toolDetail.system_dependencies.length > 0 && (
|
||||
{component.system_dependencies.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{toolDetail.system_dependencies.map((dep) => (
|
||||
{component.system_dependencies.map((dep) => (
|
||||
<span
|
||||
key={dep}
|
||||
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
|
||||
@@ -97,14 +96,6 @@ export function ComponentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toolDetail.docs && (
|
||||
<div>
|
||||
<span className="text-sm text-[var(--muted)] block mb-1">Documentation</span>
|
||||
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)]">
|
||||
{toolDetail.docs}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { useTools } from "@/services/api/hooks"
|
||||
import { usePrograms } from "@/services/api/hooks"
|
||||
import { ToolCard } from "@/components/ToolCard"
|
||||
|
||||
export function ToolsPage() {
|
||||
const { data: tools, isLoading } = useTools()
|
||||
const { data: tools, isLoading } = usePrograms("tool")
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
|
||||
@@ -16,8 +16,6 @@ import type {
|
||||
MeshStatus,
|
||||
NodeSummary,
|
||||
NodeDetail,
|
||||
ToolSummary,
|
||||
ToolDetail,
|
||||
} from "@/types"
|
||||
|
||||
// Legacy compat hook — used by ConfigEditorPage and ComponentRedirect
|
||||
@@ -59,10 +57,11 @@ export function useJob(name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function usePrograms() {
|
||||
export function usePrograms(behavior?: string) {
|
||||
const params = behavior ? `?behavior=${behavior}` : ""
|
||||
return useQuery({
|
||||
queryKey: ["programs"],
|
||||
queryFn: () => apiClient.get<ProgramSummary[]>("/programs"),
|
||||
queryKey: ["programs", behavior ?? "all"],
|
||||
queryFn: () => apiClient.get<ProgramSummary[]>(`/programs${params}`),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,21 +186,6 @@ export function useMeshStatus() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useTools() {
|
||||
return useQuery({
|
||||
queryKey: ["tools"],
|
||||
queryFn: () => apiClient.get<ToolSummary[]>("/tools"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useToolDetail(name: string) {
|
||||
return useQuery({
|
||||
queryKey: ["tools", name],
|
||||
queryFn: () => apiClient.get<ToolDetail>(`/tools/${name}`),
|
||||
enabled: !!name,
|
||||
})
|
||||
}
|
||||
|
||||
export function useEventStream() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
|
||||
@@ -152,16 +152,4 @@ export interface MeshStatus {
|
||||
peers: string[]
|
||||
}
|
||||
|
||||
export interface ToolSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
source: string | null
|
||||
version: string | null
|
||||
runner: string | null
|
||||
system_dependencies: string[]
|
||||
installed: boolean
|
||||
}
|
||||
|
||||
export interface ToolDetail extends ToolSummary {
|
||||
docs: string | null
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from castle_api.stream import (
|
||||
unsubscribe,
|
||||
)
|
||||
from castle_api.nodes import router as nodes_router
|
||||
from castle_api.tools import programs_router, router as tools_router
|
||||
from castle_api.tools import programs_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,7 +120,6 @@ app.include_router(logs_router)
|
||||
app.include_router(nodes_router)
|
||||
app.include_router(secrets_router)
|
||||
app.include_router(services_router)
|
||||
app.include_router(tools_router)
|
||||
app.include_router(programs_router)
|
||||
|
||||
|
||||
|
||||
@@ -174,21 +174,3 @@ class ServiceActionResponse(BaseModel):
|
||||
component: str
|
||||
action: str
|
||||
status: str
|
||||
|
||||
|
||||
class ToolSummary(BaseModel):
|
||||
"""Summary of a single tool."""
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
source: str | None = None
|
||||
version: str | None = None
|
||||
runner: str | None = None
|
||||
system_dependencies: list[str] = []
|
||||
installed: bool = False
|
||||
|
||||
|
||||
class ToolDetail(ToolSummary):
|
||||
"""Full detail for a single tool, including documentation."""
|
||||
|
||||
docs: str | None = None
|
||||
|
||||
@@ -73,7 +73,9 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary:
|
||||
def _summary_from_service(
|
||||
name: str, svc: ServiceSpec, config: object
|
||||
) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
|
||||
port = None
|
||||
health_path = None
|
||||
@@ -90,7 +92,9 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
|
||||
if managed:
|
||||
unit_name = f"castle-{name}.service"
|
||||
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False)
|
||||
systemd_info = SystemdInfo(
|
||||
unit_name=unit_name, unit_path=unit_path, timer=False
|
||||
)
|
||||
|
||||
description = svc.description
|
||||
source = None
|
||||
@@ -226,9 +230,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||
)
|
||||
|
||||
|
||||
def _service_from_spec(
|
||||
name: str, svc: ServiceSpec, config: object
|
||||
) -> ServiceSummary:
|
||||
def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSummary:
|
||||
"""Build a ServiceSummary from a ServiceSpec."""
|
||||
port = None
|
||||
health_path = None
|
||||
@@ -268,9 +270,7 @@ def _service_from_spec(
|
||||
|
||||
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||
"""Build a JobSummary from a DeployedComponent."""
|
||||
systemd_info = (
|
||||
_make_systemd_info(name, timer=True) if deployed.managed else None
|
||||
)
|
||||
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
|
||||
return JobSummary(
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
@@ -553,8 +553,11 @@ def get_job(name: str) -> JobDetail:
|
||||
|
||||
|
||||
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
|
||||
def list_programs() -> list[ProgramSummary]:
|
||||
"""List all programs from the software catalog (castle.yaml programs section)."""
|
||||
def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
|
||||
"""List all programs from the software catalog (castle.yaml programs section).
|
||||
|
||||
Optionally filter by behavior: daemon, tool, or frontend.
|
||||
"""
|
||||
root = get_castle_root()
|
||||
if not root:
|
||||
return []
|
||||
@@ -573,6 +576,8 @@ def list_programs() -> list[ProgramSummary]:
|
||||
summary = _program_from_spec(name, comp, root, config)
|
||||
if summary.behavior is None:
|
||||
continue
|
||||
if behavior and summary.behavior != behavior:
|
||||
continue
|
||||
summary.node = hostname
|
||||
summaries.append(summary)
|
||||
|
||||
@@ -840,11 +845,16 @@ async def reload_gateway() -> dict[str, str]:
|
||||
# Include remote registries for cross-node routing
|
||||
remote_regs = {h: n.registry for h, n in mesh_state.all_nodes().items()}
|
||||
caddyfile_path.write_text(
|
||||
generate_caddyfile_from_registry(registry, remote_registries=remote_regs or None)
|
||||
generate_caddyfile_from_registry(
|
||||
registry, remote_registries=remote_regs or None
|
||||
)
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"systemctl", "--user", "reload", "castle-castle-gateway.service",
|
||||
"systemctl",
|
||||
"--user",
|
||||
"reload",
|
||||
"castle-castle-gateway.service",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
@@ -1,88 +1,15 @@
|
||||
"""Tools router and program actions."""
|
||||
"""Program action endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core.manifest import ProgramSpec
|
||||
from castle_core.stacks import available_actions, get_handler
|
||||
|
||||
from castle_api.config import get_config
|
||||
from castle_api.models import ToolDetail, ToolSummary
|
||||
|
||||
router = APIRouter(tags=["tools"])
|
||||
programs_router = APIRouter(tags=["programs"])
|
||||
|
||||
|
||||
def _is_tool(comp: ProgramSpec) -> bool:
|
||||
"""Check if a component is a tool."""
|
||||
return comp.behavior == "tool"
|
||||
|
||||
|
||||
def _tool_summary(
|
||||
name: str, comp: ProgramSpec, root: Path | None = None
|
||||
) -> ToolSummary:
|
||||
"""Build a ToolSummary from a ProgramSpec that is a tool."""
|
||||
installed = shutil.which(name) is not None
|
||||
|
||||
# Infer runner from source directory
|
||||
runner = None
|
||||
source = comp.source
|
||||
if source:
|
||||
source_dir = Path(source)
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
return ToolSummary(
|
||||
id=name,
|
||||
description=comp.description,
|
||||
source=source,
|
||||
version=comp.version,
|
||||
runner=runner,
|
||||
system_dependencies=comp.system_dependencies,
|
||||
installed=installed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tools", response_model=list[ToolSummary])
|
||||
def list_tools() -> list[ToolSummary]:
|
||||
"""List all registered tools (requires repo access)."""
|
||||
config = get_config()
|
||||
tools = {k: v for k, v in config.programs.items() if _is_tool(v)}
|
||||
|
||||
return sorted(
|
||||
[_tool_summary(name, comp, config.root) for name, comp in tools.items()],
|
||||
key=lambda t: t.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tools/{name}", response_model=ToolDetail)
|
||||
def get_tool(name: str) -> ToolDetail:
|
||||
"""Get detailed info for a single tool."""
|
||||
config = get_config()
|
||||
|
||||
if name not in config.programs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Component '{name}' not found",
|
||||
)
|
||||
|
||||
comp = config.programs[name]
|
||||
if not _is_tool(comp):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"'{name}' is not a tool",
|
||||
)
|
||||
|
||||
summary = _tool_summary(name, comp, config.root)
|
||||
return ToolDetail(**summary.model_dump())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified program action endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user