Stage 3: App — program/deployment model & controls

- 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.
This commit is contained in:
2026-06-14 15:37:20 -07:00
parent 73698fafe7
commit dea585b15b
6 changed files with 132 additions and 13 deletions

View File

@@ -42,10 +42,22 @@ interface ProgramActionsProps {
name: string name: string
actions: string[] actions: string[]
active?: boolean | null active?: boolean | null
behavior?: string | null
/** Names of services/jobs deploying this program — daemons activate via these. */
deployedAs?: string[]
compact?: boolean compact?: boolean
onOutput?: (output: ActionOutput) => void 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( function visibleActions(
actions: string[], actions: string[],
active: boolean | null | undefined, active: boolean | null | undefined,
@@ -78,11 +90,23 @@ function visibleActions(
return visible 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 { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null) const [runningAction, setRunningAction] = useState<string | null>(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) => { const handleAction = (action: string) => {
setRunningAction(action) setRunningAction(action)

View File

@@ -143,7 +143,14 @@ function ProgramRow({ program }: { program: ProgramSummary }) {
<BehaviorBadge behavior={program.behavior} /> <BehaviorBadge behavior={program.behavior} />
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<ProgramActions name={program.id} actions={program.actions} active={program.active} compact /> <ProgramActions
name={program.id}
actions={program.actions}
active={program.active}
behavior={program.behavior}
deployedAs={[...program.services, ...program.jobs]}
compact
/>
</td> </td>
</tr> </tr>
) )

View File

@@ -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 (
<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">
Deployments
</h2>
<p className="text-xs text-[var(--muted)] mb-4">
Services and jobs that run this program.
</p>
{none ? (
<p className="text-sm text-[var(--muted)]">
{behavior === "daemon"
? "No service yet — this daemon isn't deployed."
: behavior === "tool"
? "Not scheduled — add a job to run it on a timer."
: "None."}
</p>
) : (
<div className="space-y-1.5">
{services.map((s) => (
<Link
key={s}
to={`/services/${s}`}
className="flex items-center gap-2 text-sm hover:text-[var(--primary)] transition-colors"
>
<Server size={14} className="text-[var(--muted)]" />
<span className="font-medium">{s}</span>
<span className="text-xs text-[var(--muted)]">service</span>
</Link>
))}
{jobs.map((j) => (
<Link
key={j}
to={`/jobs/${j}`}
className="flex items-center gap-2 text-sm hover:text-[var(--primary)] transition-colors"
>
<Clock size={14} className="text-[var(--muted)]" />
<span className="font-medium">{j}</span>
<span className="text-xs text-[var(--muted)]">job</span>
</Link>
))}
</div>
)}
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useProgram, useEventStream } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels" import { runnerLabel } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader" import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel" import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { DeploymentsSection } from "@/components/detail/DeploymentsSection"
import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions" import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions"
export function ProgramDetailPage() { export function ProgramDetailPage() {
@@ -37,7 +38,14 @@ export function ProgramDetailPage() {
stack={deployment.stack} stack={deployment.stack}
source={deployment.source} source={deployment.source}
> >
<ProgramActions name={deployment.id} actions={deployment.actions} active={deployment.active} onOutput={setActionOutput} /> <ProgramActions
name={deployment.id}
actions={deployment.actions}
active={deployment.active}
behavior={deployment.behavior}
deployedAs={[...deployment.services, ...deployment.jobs]}
onOutput={setActionOutput}
/>
</DetailHeader> </DetailHeader>
{actionOutput && actionOutput.action && ( {actionOutput && actionOutput.action && (
@@ -128,6 +136,8 @@ export function ProgramDetailPage() {
)} )}
</div> </div>
<DeploymentsSection program={deployment} />
<ConfigPanel deployment={deployment} configSection="programs" onRefetch={refetch} /> <ConfigPanel deployment={deployment} configSection="programs" onRefetch={refetch} />
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
import { useRef } from "react" 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 { Server, ExternalLink, Terminal, Trash2 } from "lucide-react"
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks" import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels" import { runnerLabel } from "@/lib/labels"
@@ -35,8 +35,6 @@ export function ServiceDetailPage() {
) )
} }
const runner = (deployment.manifest.run as Record<string, unknown>)?.runner as string | undefined
return ( return (
<div className="max-w-3xl mx-auto px-6 py-8"> <div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader <DetailHeader
@@ -83,18 +81,30 @@ export function ServiceDetailPage() {
</a> </a>
</> </>
)} )}
{runner && ( {deployment.proxy_host && (
<> <>
<span className="text-[var(--muted)]">Runner</span> <span className="text-[var(--muted)]">Host</span>
<span className="font-mono">{deployment.proxy_host}</span>
</>
)}
{deployment.runner && (
<>
<span className="text-[var(--muted)]">Runs</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Terminal size={12} /> <Terminal size={12} />
{runnerLabel(runner)} {runnerLabel(deployment.runner)}
{(deployment.manifest.run as Record<string, string>)?.program && ( {deployment.run_target && <> &middot; <span className="font-mono">{deployment.run_target}</span></>}
<> &middot; {(deployment.manifest.run as Record<string, string>).program}</>
)}
</span> </span>
</> </>
)} )}
{deployment.program && (
<>
<span className="text-[var(--muted)]">Program</span>
<Link to={`/programs/${deployment.program}`} className="text-[var(--primary)] hover:underline">
{deployment.program}
</Link>
</>
)}
{deployment.port && ( {deployment.port && (
<> <>
<span className="text-[var(--muted)]">Docs</span> <span className="text-[var(--muted)]">Docs</span>

View File

@@ -155,6 +155,17 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
if comp is not None and comp.behavior == "frontend": if comp is not None and comp.behavior == "frontend":
return await run_action("install", name, comp, root) 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 <PORT>' to deploy it as a service.",
)
# Tool: install to PATH. # Tool: install to PATH.
if comp is not None: if comp is not None:
return await run_action("install", name, comp, root) return await run_action("install", name, comp, root)