diff --git a/app/src/components/ProgramCard.tsx b/app/src/components/ProgramCard.tsx
index 4d0cc10..c38743a 100644
--- a/app/src/components/ProgramCard.tsx
+++ b/app/src/components/ProgramCard.tsx
@@ -9,9 +9,16 @@ interface ProgramCardProps {
// "/tools" so a tool card opens its tool detail page (a tool is 1:1 with its
// program, same name).
linkBase?: string
+ // Whether to list the program's deployments. The Programs catalog shows them;
+ // a kind-specific lens (e.g. Tools) is already looking at one deployment, so off.
+ showDeployments?: boolean
}
-export function ProgramCard({ program, linkBase = "/programs" }: ProgramCardProps) {
+export function ProgramCard({
+ program,
+ linkBase = "/programs",
+ showDeployments = true,
+}: ProgramCardProps) {
// The dot reflects the uniform lifecycle state (a tool on PATH, a service
// running, a static site served). Lifecycle controls live on the detail page's
// Deployment section, not here — a card just shows state and links through.
@@ -38,19 +45,21 @@ export function ProgramCard({ program, linkBase = "/programs" }: ProgramCardProp
no deployment
- )} + {/* A program has no kind of its own — show its deployments (name · kind). + Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */} + {showDeployments && + (program.deployments.length > 0 ? ( +no deployment
+ ))} {program.description && ({program.description}
diff --git a/app/src/components/ProgramList.tsx b/app/src/components/ProgramList.tsx index 2b2a9ec..fc04bc4 100644 --- a/app/src/components/ProgramList.tsx +++ b/app/src/components/ProgramList.tsx @@ -5,9 +5,10 @@ import { ProgramCard } from "./ProgramCard" interface ProgramListProps { programs: ProgramSummary[] linkBase?: string // where each card links (default "/programs") + showDeployments?: boolean // list each program's deployments on the card (default true) } -export function ProgramList({ programs, linkBase }: ProgramListProps) { +export function ProgramList({ programs, linkBase, showDeployments }: ProgramListProps) { const [search, setSearch] = useState("") const filtered = useMemo(() => { @@ -39,7 +40,12 @@ export function ProgramList({ programs, linkBase }: ProgramListProps) { ) : ({deployment.description}
)} - {/* A tool's PATH deployment: install/uninstall is its start/stop. */} -Loading...
) : programs && programs.length > 0 ? ( -No tools yet.
)} diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index 8dc0d7c..9fda783 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -273,18 +273,27 @@ async def delete_program(name: str, cascade: bool = False) -> dict: def _save_deployment(name: str, config_dict: dict) -> dict: """Validate a deployment (any manager) and persist it to config.deployments.""" _require_repo() + config = get_config() + config_dict = dict(config_dict) + + # On CREATE (a new deployment) with no description of its own, inherit the + # referenced program's description — a deployment reads as its program by + # default. Edits keep whatever the user set (including a cleared field). + if name not in config.deployments and not config_dict.get("description"): + prog = config_dict.get("program") + if prog and prog in config.programs and config.programs[prog].description: + config_dict["description"] = config.programs[prog].description + try: - dep_data = _normalize_deployment_dict({**config_dict, "id": name}) - _DEPLOYMENT_ADAPTER.validate_python(dep_data) + dep = _DEPLOYMENT_ADAPTER.validate_python( + _normalize_deployment_dict({**config_dict, "id": name}) + ) except Exception as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid deployment config: {e}", ) - config = get_config() - config.deployments[name] = _DEPLOYMENT_ADAPTER.validate_python( - _normalize_deployment_dict({**config_dict, "id": name}) - ) + config.deployments[name] = dep save_config(config) return {"ok": True, "deployment": name} diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 8b9995c..aaeaba9 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -77,10 +77,13 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: timer=has_timer, ) - # A PATH-managed deployment (a tool) is "installed" when it's on PATH. + # A PATH-managed deployment (a tool) is "installed" — and thus active — when + # it's on PATH. (systemd/caddy liveness comes from the health/status stream.) installed: bool | None = None + active: bool | None = None if deployed.manager == "path": installed = shutil.which(name) is not None + active = installed category = "job" if deployed.schedule else "service" @@ -99,6 +102,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: systemd=systemd_info, schedule=deployed.schedule, installed=installed, + active=active, ) diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 2e98633..83e3b69 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -115,12 +115,13 @@ def run_create(args: argparse.Namespace) -> int: if kind == "tool": # A PATH-managed deployment: installed via `uv tool install`, no unit/route. config.deployments[name] = PathDeployment( - id=name, manager="path", program=name + id=name, manager="path", program=name, description=description ) elif kind == "static": # A caddy-managed static deployment: no systemd unit, served from the build dir. config.deployments[name] = CaddyDeployment( - id=name, manager="caddy", program=name, root=static_root or "dist" + id=name, manager="caddy", program=name, root=static_root or "dist", + description=description, ) elif kind == "service": prefix = name.replace("-", "_").upper() @@ -128,6 +129,7 @@ def run_create(args: argparse.Namespace) -> int: id=name, manager="systemd", program=name, + description=description, run=RunPython(launcher="python", program=name), expose=ExposeSpec( http=HttpExposeSpec(