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

@@ -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}
>
<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>
{actionOutput && actionOutput.action && (
@@ -128,6 +136,8 @@ export function ProgramDetailPage() {
)}
</div>
<DeploymentsSection program={deployment} />
<ConfigPanel deployment={deployment} configSection="programs" onRefetch={refetch} />
</div>
)

View File

@@ -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<string, unknown>)?.runner as string | undefined
return (
<div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader
@@ -83,18 +81,30 @@ export function ServiceDetailPage() {
</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">
<Terminal size={12} />
{runnerLabel(runner)}
{(deployment.manifest.run as Record<string, string>)?.program && (
<> &middot; {(deployment.manifest.run as Record<string, string>).program}</>
)}
{runnerLabel(deployment.runner)}
{deployment.run_target && <> &middot; <span className="font-mono">{deployment.run_target}</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 && (
<>
<span className="text-[var(--muted)]">Docs</span>