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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
57
app/src/components/detail/DeploymentsSection.tsx
Normal file
57
app/src/components/detail/DeploymentsSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 && <> · <span className="font-mono">{deployment.run_target}</span></>}
|
||||||
<> · {(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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user