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
|
||||
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<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) => {
|
||||
setRunningAction(action)
|
||||
|
||||
@@ -143,7 +143,14 @@ function ProgramRow({ program }: { program: ProgramSummary }) {
|
||||
<BehaviorBadge behavior={program.behavior} />
|
||||
</td>
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user