From dea585b15b312d334b0b292a146b69dbfe5cc97a Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 14 Jun 2026 15:37:20 -0700 Subject: [PATCH] =?UTF-8?q?Stage=203:=20App=20=E2=80=94=20program/deployme?= =?UTF-8?q?nt=20model=20&=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProgramActions: install/uninstall (activate) shown only for tools and no-service frontends. Daemons activate via a service/job, so the program page no longer offers a misleading Install/Uninstall (which secretly enabled/disabled the service). - Program page: new Deployments section listing the services/jobs that run the program, as links (a program → 0-N services/jobs). - Service page: 'Runs' row from the run_target summary field (the old code dug into manifest.run, which was empty pre-Stage-1), a Host row for proxy_host, and a convenience link to the referenced program. - lifecycle.activate on a daemon with no service now errors toward 'castle expose' instead of silently installing its binary to PATH. core 94 green; ruff + app build clean. --- app/src/components/ProgramActions.tsx | 28 ++++++++- app/src/components/ProgramTable.tsx | 9 ++- .../components/detail/DeploymentsSection.tsx | 57 +++++++++++++++++++ app/src/pages/ProgramDetail.tsx | 12 +++- app/src/pages/ServiceDetail.tsx | 28 ++++++--- core/src/castle_core/lifecycle.py | 11 ++++ 6 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 app/src/components/detail/DeploymentsSection.tsx diff --git a/app/src/components/ProgramActions.tsx b/app/src/components/ProgramActions.tsx index 6464543..168f38c 100644 --- a/app/src/components/ProgramActions.tsx +++ b/app/src/components/ProgramActions.tsx @@ -42,10 +42,22 @@ interface ProgramActionsProps { name: string actions: string[] active?: boolean | null + behavior?: string | null + /** Names of services/jobs deploying this program — daemons activate via these. */ + deployedAs?: string[] compact?: boolean onOutput?: (output: ActionOutput) => void } +/** install/uninstall (activate) is meaningful only for tools (PATH) and static + * frontends (served). A daemon activates through a service/job, so it never + * shows install/uninstall here — its run controls live on the deployment page. */ +function showsActivation(behavior: string | null | undefined, deployedAs: string[]): boolean { + if (behavior === "daemon") return false + if (behavior === "frontend") return deployedAs.length === 0 // self-serving frontend → its service + return true // tools (and unspecified) +} + function visibleActions( actions: string[], active: boolean | null | undefined, @@ -78,11 +90,23 @@ function visibleActions( return visible } -export function ProgramActions({ name, actions, active, compact, onOutput }: ProgramActionsProps) { +export function ProgramActions({ + name, + actions, + active, + behavior, + deployedAs = [], + compact, + onOutput, +}: ProgramActionsProps) { const { mutate, isPending } = useProgramAction() const [runningAction, setRunningAction] = useState(null) - const visible = visibleActions(actions, active, !!compact) + // Drop install/uninstall for behaviors that activate via a deployment. + const allowed = showsActivation(behavior, deployedAs) + ? actions + : actions.filter((a) => a !== "install" && a !== "uninstall") + const visible = visibleActions(allowed, active, !!compact) const handleAction = (action: string) => { setRunningAction(action) diff --git a/app/src/components/ProgramTable.tsx b/app/src/components/ProgramTable.tsx index d0ae9b1..544b56e 100644 --- a/app/src/components/ProgramTable.tsx +++ b/app/src/components/ProgramTable.tsx @@ -143,7 +143,14 @@ function ProgramRow({ program }: { program: ProgramSummary }) { - + ) diff --git a/app/src/components/detail/DeploymentsSection.tsx b/app/src/components/detail/DeploymentsSection.tsx new file mode 100644 index 0000000..53f0e83 --- /dev/null +++ b/app/src/components/detail/DeploymentsSection.tsx @@ -0,0 +1,57 @@ +import { Link } from "react-router-dom" +import { Server, Clock } from "lucide-react" +import type { ProgramDetail } from "@/types" + +/** The services and jobs that deploy a program. A program → 0-N services and + * 0-N jobs; these are convenience links, not ownership (a deployment can run + * anything, program-backed or not). */ +export function DeploymentsSection({ program }: { program: ProgramDetail }) { + const { services, jobs, behavior } = program + const none = services.length === 0 && jobs.length === 0 + + return ( +
+

+ Deployments +

+

+ Services and jobs that run this program. +

+ + {none ? ( +

+ {behavior === "daemon" + ? "No service yet — this daemon isn't deployed." + : behavior === "tool" + ? "Not scheduled — add a job to run it on a timer." + : "None."} +

+ ) : ( +
+ {services.map((s) => ( + + + {s} + service + + ))} + {jobs.map((j) => ( + + + {j} + job + + ))} +
+ )} +
+ ) +} diff --git a/app/src/pages/ProgramDetail.tsx b/app/src/pages/ProgramDetail.tsx index 21b1308..b623211 100644 --- a/app/src/pages/ProgramDetail.tsx +++ b/app/src/pages/ProgramDetail.tsx @@ -4,6 +4,7 @@ import { useProgram, useEventStream } from "@/services/api/hooks" import { runnerLabel } from "@/lib/labels" import { DetailHeader } from "@/components/detail/DetailHeader" import { ConfigPanel } from "@/components/detail/ConfigPanel" +import { DeploymentsSection } from "@/components/detail/DeploymentsSection" import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions" export function ProgramDetailPage() { @@ -37,7 +38,14 @@ export function ProgramDetailPage() { stack={deployment.stack} source={deployment.source} > - + {actionOutput && actionOutput.action && ( @@ -128,6 +136,8 @@ export function ProgramDetailPage() { )} + + ) diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx index 37da64a..8a3a94f 100644 --- a/app/src/pages/ServiceDetail.tsx +++ b/app/src/pages/ServiceDetail.tsx @@ -1,5 +1,5 @@ import { useRef } from "react" -import { useParams } from "react-router-dom" +import { useParams, Link } from "react-router-dom" import { Server, ExternalLink, Terminal, Trash2 } from "lucide-react" import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks" import { runnerLabel } from "@/lib/labels" @@ -35,8 +35,6 @@ export function ServiceDetailPage() { ) } - const runner = (deployment.manifest.run as Record)?.runner as string | undefined - return (
)} - {runner && ( + {deployment.proxy_host && ( <> - Runner + Host + {deployment.proxy_host} + + )} + {deployment.runner && ( + <> + Runs - {runnerLabel(runner)} - {(deployment.manifest.run as Record)?.program && ( - <> · {(deployment.manifest.run as Record).program} - )} + {runnerLabel(deployment.runner)} + {deployment.run_target && <> · {deployment.run_target}} )} + {deployment.program && ( + <> + Program + + {deployment.program} + + + )} {deployment.port && ( <> Docs diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 4231cef..57937cd 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -155,6 +155,17 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult: if comp is not None and comp.behavior == "frontend": return await run_action("install", name, comp, root) + # A daemon with no service can't be "activated" — installing its binary to + # PATH doesn't run it. Direct the user to declare a service instead. + if comp is not None and comp.behavior == "daemon": + return ActionResult( + name, + "activate", + "error", + f"'{name}' is a daemon with no service. Run " + f"'castle expose {name} --port ' to deploy it as a service.", + ) + # Tool: install to PATH. if comp is not None: return await run_action("install", name, comp, root)