diff --git a/app/src/components/AddDeployment.tsx b/app/src/components/AddDeployment.tsx deleted file mode 100644 index 31c6aa2..0000000 --- a/app/src/components/AddDeployment.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useState } from "react" -import { Plus, X } from "lucide-react" - -const TEMPLATES: Record> = { - service: { - run: { - runner: "python", - program: "", - cwd: "", - env: {}, - }, - expose: { - http: { - internal: { port: 9001 }, - health_path: "/health", - }, - }, - proxy: { - caddy: { path_prefix: "/" }, - }, - manage: { - systemd: {}, - }, - }, - tool: { - behavior: "tool", - }, - job: { - run: { - runner: "command", - argv: [""], - cwd: "", - }, - schedule: "0 2 * * *", - }, - empty: {}, -} - -interface AddDeploymentProps { - onAdd: (name: string, config: Record) => Promise - existingNames: string[] -} - -export function AddDeployment({ onAdd, existingNames }: AddDeploymentProps) { - const [open, setOpen] = useState(false) - const [name, setName] = useState("") - const [description, setDescription] = useState("") - const [template, setTemplate] = useState("service") - const [port, setPort] = useState("") - const [saving, setSaving] = useState(false) - const [error, setError] = useState("") - - const nameError = - name && !/^[a-z0-9][a-z0-9-]*$/.test(name) - ? "lowercase letters, numbers, and hyphens" - : existingNames.includes(name) - ? "already exists" - : "" - - const handleSubmit = async () => { - if (!name || nameError) return - setSaving(true) - setError("") - try { - const config: Record = JSON.parse( - JSON.stringify(TEMPLATES[template] ?? {}) - ) - if (description) config.description = description - - // Fill in template-specific fields - if (template === "service") { - const run = config.run as Record - run.program = name - run.cwd = name - const proxy = (config.proxy as Record>).caddy - proxy.path_prefix = `/${name}` - if (port) { - const expose = (config.expose as Record>>) - expose.http.internal.port = parseInt(port, 10) - } - } else if (template === "tool") { - // tool template has behavior preset, no extra config needed - } - - await onAdd(name, config) - setName("") - setDescription("") - setPort("") - setOpen(false) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)) - } finally { - setSaving(false) - } - } - - if (!open) { - return ( - - ) - } - - return ( -
-
-

New entry

- -
- - {error && ( -
- {error} -
- )} - -
- - setName(e.target.value.toLowerCase())} - placeholder="my-service" - autoFocus - className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]" - /> - {nameError &&

{nameError}

} -
- - - - -
- - - setDescription(e.target.value)} - placeholder="What does this entry do?" - className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]" - /> - - - {template === "service" && ( - - setPort(e.target.value)} - placeholder="9001" - className="w-32 bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]" - /> - - )} - -
- -
-
- ) -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
- ) -} diff --git a/app/src/components/DeploymentEditor.tsx b/app/src/components/DeploymentEditor.tsx deleted file mode 100644 index 8aef4f4..0000000 --- a/app/src/components/DeploymentEditor.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useState } from "react" -import { ChevronDown, ChevronRight } from "lucide-react" -import type { DeploymentDetail } from "@/types" -import { DeploymentFields } from "./DeploymentFields" -import { BehaviorBadge } from "./BehaviorBadge" -import { StackBadge } from "./StackBadge" - -interface DeploymentEditorProps { - deployment: DeploymentDetail - onSave: (name: string, config: Record) => Promise - onDelete: (name: string) => Promise -} - -export function DeploymentEditor({ deployment, onSave, onDelete }: DeploymentEditorProps) { - const [expanded, setExpanded] = useState(false) - - return ( -
- - - {expanded && ( -
- -
- )} -
- ) -} diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 410b2ef..67042b9 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -9,11 +9,14 @@ export interface ServiceSummary { description: string | null stack: string | null runner: string | null + run_target: string | null port: number | null health_path: string | null proxy_path: string | null + proxy_host: string | null managed: boolean systemd: SystemdInfo | null + program: string | null source: string | null node: string | null } @@ -27,9 +30,11 @@ export interface JobSummary { description: string | null stack: string | null runner: string | null + run_target: string | null schedule: string | null managed: boolean systemd: SystemdInfo | null + program: string | null source: string | null node: string | null } @@ -53,6 +58,8 @@ export interface ProgramSummary { installed: boolean | null active: boolean | null actions: string[] + services: string[] + jobs: string[] node: string | null } diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 22d3e89..c0801da 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -50,11 +50,14 @@ class ServiceSummary(BaseModel): description: str | None = None stack: str | None = None runner: str | None = None + run_target: str | None = None # what it runs: program name, argv, image, … port: int | None = None health_path: str | None = None proxy_path: str | None = None + proxy_host: str | None = None managed: bool = False systemd: SystemdInfo | None = None + program: str | None = None # the program this deployment references, if any source: str | None = None node: str | None = None @@ -72,9 +75,11 @@ class JobSummary(BaseModel): description: str | None = None stack: str | None = None runner: str | None = None + run_target: str | None = None # what it runs: program name, argv, … schedule: str | None = None managed: bool = False systemd: SystemdInfo | None = None + program: str | None = None # the program this deployment references, if any source: str | None = None node: str | None = None @@ -102,6 +107,8 @@ class ProgramSummary(BaseModel): installed: bool | None = None active: bool | None = None # uniform lifecycle state (on PATH / running / served) actions: list[str] = [] + services: list[str] = [] # services that deploy this program + jobs: list[str] = [] # jobs that deploy this program node: str | None = None diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 0222e61..6b124e7 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -230,17 +230,34 @@ def _backfill_source(name: str, config: object) -> str | None: return None +def _run_target(run: object) -> str | None: + """A human label for what a run spec executes (program / argv / image / …).""" + if run is None: + return None + for attr in ("program", "image", "script", "base_url"): + val = getattr(run, attr, None) + if val: + return str(val) + argv = getattr(run, "argv", None) + if argv: + return " ".join(argv) + return None + + def _service_from_deployed(name: str, deployed: object) -> ServiceSummary: """Build a ServiceSummary from a Deployment.""" systemd_info = _make_systemd_info(name) if deployed.managed else None + run_target = " ".join(deployed.run_cmd) if deployed.run_cmd else None return ServiceSummary( id=name, description=deployed.description, stack=deployed.stack, runner=deployed.runner, + run_target=run_target, port=deployed.port, health_path=deployed.health_path, proxy_path=deployed.proxy_path, + proxy_host=deployed.proxy_host, managed=deployed.managed, systemd=systemd_info, ) @@ -270,16 +287,20 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu source = comp.source stack = comp.stack + proxy_host = svc.proxy.caddy.host if (svc.proxy and svc.proxy.caddy) else None return ServiceSummary( id=name, description=description, stack=stack, runner=svc.run.runner, + run_target=_run_target(svc.run), port=port, health_path=health_path, proxy_path=proxy_path, + proxy_host=proxy_host, managed=managed, systemd=systemd_info, + program=svc.program, source=source, ) @@ -287,11 +308,13 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu def _job_from_deployed(name: str, deployed: object) -> JobSummary: """Build a JobSummary from a Deployment.""" systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None + run_target = " ".join(deployed.run_cmd) if deployed.run_cmd else None return JobSummary( id=name, description=deployed.description, stack=deployed.stack, runner=deployed.runner, + run_target=run_target, schedule=deployed.schedule, managed=deployed.managed, systemd=systemd_info, @@ -318,9 +341,11 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary: description=description, stack=stack, runner=job.run.runner, + run_target=_run_target(job.run), schedule=job.schedule, managed=managed, systemd=systemd_info, + program=job.program, source=source, ) @@ -344,10 +369,15 @@ def _program_from_spec( # Uniform lifecycle state (on PATH / running / served) — needs full config. active: bool | None = None + services: list[str] = [] + jobs: list[str] = [] if config is not None: from castle_core.lifecycle import is_active active = is_active(name, config) + # Deployments that reference this program (a program → 0-N services/jobs). + services = [s for s, spec in config.services.items() if spec.program == name] + jobs = [j for j, spec in config.jobs.items() if spec.program == name] return ProgramSummary( id=name, @@ -364,6 +394,8 @@ def _program_from_spec( installed=installed, active=active, actions=available_actions(comp), + services=services, + jobs=jobs, ) @@ -434,22 +466,36 @@ def list_services(include_remote: bool = False) -> list[ServiceSummary]: tags=["services-data"], ) def get_service(name: str) -> ServiceDetail: - """Get detailed info for a single service.""" - registry = get_registry() + """Get detailed info for a single service. + The `manifest` is the editable castle.yaml ServiceSpec whenever the service + is declared there — that's the source of truth the config editor reads and + writes. Only services that exist *only* in the runtime registry (e.g. infra + not in castle.yaml) fall back to the flat deployed shape (display-only). + """ + root = get_castle_root() + config = None + if root: + try: + from castle_core.config import load_config + + config = load_config(root) + except FileNotFoundError: + pass + + if config is not None and name in config.services: + svc = config.services[name] + summary = _service_from_spec(name, svc, config) + manifest = svc.model_dump(mode="json", exclude_none=True) + return ServiceDetail(**summary.model_dump(), manifest=manifest) + + registry = get_registry() if name in registry.deployed and not registry.deployed[name].schedule: deployed = registry.deployed[name] summary = _service_from_deployed(name, deployed) - root = get_castle_root() - if root and summary.source is None: - try: - from castle_core.config import load_config - - config = load_config(root) - summary.source = _backfill_source(name, config) - except FileNotFoundError: - pass - raw = { + if config is not None and summary.source is None: + summary.source = _backfill_source(name, config) + manifest = { "runner": deployed.runner, "run_cmd": deployed.run_cmd, "env": deployed.env, @@ -460,18 +506,7 @@ def get_service(name: str) -> ServiceDetail: "behavior": deployed.behavior, "stack": deployed.stack, } - return ServiceDetail(**summary.model_dump(), manifest=raw) - - root = get_castle_root() - if root: - from castle_core.config import load_config - - config = load_config(root) - if name in config.services: - svc = config.services[name] - summary = _service_from_spec(name, svc, config) - raw = svc.model_dump(mode="json", exclude_none=True) - return ServiceDetail(**summary.model_dump(), manifest=raw) + return ServiceDetail(**summary.model_dump(), manifest=manifest) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -536,22 +571,31 @@ def list_jobs(include_remote: bool = False) -> list[JobSummary]: @router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"]) def get_job(name: str) -> JobDetail: - """Get detailed info for a single job.""" - registry = get_registry() + """Get detailed info for a single job. `manifest` is the editable castle.yaml + JobSpec when declared there; falls back to the runtime registry otherwise.""" + root = get_castle_root() + config = None + if root: + try: + from castle_core.config import load_config + config = load_config(root) + except FileNotFoundError: + pass + + if config is not None and name in config.jobs: + job = config.jobs[name] + summary = _job_from_spec(name, job, config) + manifest = job.model_dump(mode="json", exclude_none=True) + return JobDetail(**summary.model_dump(), manifest=manifest) + + registry = get_registry() if name in registry.deployed and registry.deployed[name].schedule: deployed = registry.deployed[name] summary = _job_from_deployed(name, deployed) - root = get_castle_root() - if root and summary.source is None: - try: - from castle_core.config import load_config - - config = load_config(root) - summary.source = _backfill_source(name, config) - except FileNotFoundError: - pass - raw = { + if config is not None and summary.source is None: + summary.source = _backfill_source(name, config) + manifest = { "runner": deployed.runner, "run_cmd": deployed.run_cmd, "env": deployed.env, @@ -560,18 +604,7 @@ def get_job(name: str) -> JobDetail: "behavior": deployed.behavior, "stack": deployed.stack, } - return JobDetail(**summary.model_dump(), manifest=raw) - - root = get_castle_root() - if root: - from castle_core.config import load_config - - config = load_config(root) - if name in config.jobs: - job = config.jobs[name] - summary = _job_from_spec(name, job, config) - raw = job.model_dump(mode="json", exclude_none=True) - return JobDetail(**summary.model_dump(), manifest=raw) + return JobDetail(**summary.model_dump(), manifest=manifest) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index df1ce61..3037b34 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -124,7 +124,9 @@ class TestServiceDetail: data = response.json() assert data["id"] == "test-svc" assert "manifest" in data - assert data["manifest"]["runner"] == "python" + # manifest is the editable castle.yaml ServiceSpec (nested run spec) + assert data["manifest"]["run"]["runner"] == "python" + assert data["run_target"] == "test-svc" def test_not_found(self, client: TestClient) -> None: """Returns 404 for unknown service."""