From 6ee6d4c8504845322fc4d9180554d2e9e74055d3 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 11:32:25 -0700 Subject: [PATCH] Dashboard: lifecycle is a deployment concern, not a program action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install/Uninstall no longer live in the program's action cluster (they were a tool's PATH-deployment lifecycle masquerading as a program action). ProgramActions is now dev-verbs-only (build/test/lint/type-check/check). The program detail's Deployment section owns lifecycle, keyed on kind: a tool shows Installed-on-PATH with an Install/Uninstall toggle, a static shows served-by-gateway + its URL, services/jobs link to their own pages. Program list cards drop the quick toggle for a plain active dot. 'Remove program' stays as catalog deletion — no longer a dead-end now that the blocking deployment is visible in the Deployment section. --- app/src/components/ProgramActions.tsx | 60 ++--------- app/src/components/ProgramCard.tsx | 29 ++--- .../components/detail/DeploymentsSection.tsx | 102 +++++++++++++++--- app/src/pages/ProgramDetail.tsx | 2 - 4 files changed, 108 insertions(+), 85 deletions(-) diff --git a/app/src/components/ProgramActions.tsx b/app/src/components/ProgramActions.tsx index 34730b8..acab009 100644 --- a/app/src/components/ProgramActions.tsx +++ b/app/src/components/ProgramActions.tsx @@ -4,10 +4,8 @@ import { FlaskConical, Hammer, Loader2, - Plug, ShieldCheck, Sparkles, - Unplug, type LucideIcon, } from "lucide-react" import { useProgramAction } from "@/services/api/hooks" @@ -20,14 +18,15 @@ interface ActionConfig { borderColor: string } +// Dev verbs only — operations on the program's *source*. Deployment lifecycle +// (install/uninstall a tool, start/stop a service, …) belongs to the deployment, +// not the program, so it lives in the Deployment section, never here. const ACTION_CONFIG: Record = { build: { icon: Hammer, label: "Build", color: "text-blue-400", hoverBg: "hover:bg-blue-800/30", borderColor: "border-blue-800" }, test: { icon: FlaskConical, label: "Test", color: "text-purple-400", hoverBg: "hover:bg-purple-800/30", borderColor: "border-purple-800" }, lint: { icon: Sparkles, label: "Lint", color: "text-amber-400", hoverBg: "hover:bg-amber-800/30", borderColor: "border-amber-800" }, "type-check": { icon: FileCheck, label: "Type Check", color: "text-cyan-400", hoverBg: "hover:bg-cyan-800/30", borderColor: "border-cyan-800" }, check: { icon: ShieldCheck, label: "Check All", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" }, - install: { icon: Plug, label: "Install", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" }, - uninstall: { icon: Unplug, label: "Uninstall", color: "text-red-400", hoverBg: "hover:bg-red-800/30", borderColor: "border-red-800" }, } const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"] @@ -41,67 +40,20 @@ export interface ActionOutput { interface ProgramActionsProps { name: string actions: string[] - active?: boolean | null - kind?: string | null compact?: boolean onOutput?: (output: ActionOutput) => void } -/** install/uninstall (activate) is meaningful only for a tool (a PATH deployment). - * Services, jobs, and static (caddy) deployments are managed through their - * deployment — never install/uninstall here. */ -function showsActivation(kind: string | null | undefined): boolean { - return kind === "tool" -} - -function visibleActions( - actions: string[], - active: boolean | null | undefined, - compact: boolean, -): string[] { - // `active` is the uniform lifecycle state (on PATH / running / served), not a - // PATH lookup — so it's correct for tools, services, jobs, and static frontends. - if (compact) { - // Table: only the activate/deactivate toggle based on state - if (active === true) return actions.includes("uninstall") ? ["uninstall"] : [] - if (active === false) return actions.includes("install") ? ["install"] : [] - // null — show install if available - return actions.includes("install") ? ["install"] : [] - } - - // Detail page: dev actions always, activate/deactivate based on state - const visible: string[] = [] - for (const a of DEV_ACTIONS) { - if (actions.includes(a)) visible.push(a) - } - if (active === true) { - if (actions.includes("uninstall")) visible.push("uninstall") - } else if (active === false) { - if (actions.includes("install")) visible.push("install") - } else { - // null — show both if available - if (actions.includes("install")) visible.push("install") - if (actions.includes("uninstall")) visible.push("uninstall") - } - return visible -} - export function ProgramActions({ name, actions, - active, - kind, compact, onOutput, }: ProgramActionsProps) { const { mutate, isPending } = useProgramAction() const [runningAction, setRunningAction] = useState(null) - // Drop install/uninstall for kinds that activate via a deployment. - const allowed = showsActivation(kind) - ? actions - : actions.filter((a) => a !== "install" && a !== "uninstall") - const visible = visibleActions(allowed, active, !!compact) + const visible = DEV_ACTIONS.filter((a) => actions.includes(a)) const handleAction = (action: string) => { setRunningAction(action) @@ -111,13 +63,13 @@ export function ProgramActions({ { onSuccess: (data) => { setRunningAction(null) - if (!compact && DEV_ACTIONS.includes(action) && data.output) { + if (!compact && data.output) { onOutput?.({ action, text: data.output, ok: true }) } }, onError: (err) => { setRunningAction(null) - if (!compact && DEV_ACTIONS.includes(action)) { + if (!compact) { let text = String(err) try { const parsed = JSON.parse((err as Error).message) diff --git a/app/src/components/ProgramCard.tsx b/app/src/components/ProgramCard.tsx index 6cc55e1..1cf25b5 100644 --- a/app/src/components/ProgramCard.tsx +++ b/app/src/components/ProgramCard.tsx @@ -2,16 +2,29 @@ import { Link } from "react-router-dom" import type { ProgramSummary } from "@/types" import { KindBadge } from "./KindBadge" import { StackBadge } from "./StackBadge" -import { ProgramActions } from "./ProgramActions" interface ProgramCardProps { program: ProgramSummary } export function ProgramCard({ program }: ProgramCardProps) { + // The dot reflects the uniform lifecycle state (a tool on PATH, a service + // running, a static site served). Lifecycle controls live on the detail page's + // Deployment section, not here — a card just shows state and links through. + const dot = + program.active === true + ? "bg-green-500" + : program.active === false + ? "bg-[var(--muted)]" + : "bg-transparent border border-[var(--muted)]" + return (
-
+
+ {program.description && ( -

{program.description}

+

{program.description}

)} - -
- -
) } diff --git a/app/src/components/detail/DeploymentsSection.tsx b/app/src/components/detail/DeploymentsSection.tsx index 22876db..d8cee87 100644 --- a/app/src/components/detail/DeploymentsSection.tsx +++ b/app/src/components/detail/DeploymentsSection.tsx @@ -1,13 +1,15 @@ import { useState } from "react" import { Link } from "react-router-dom" -import { Server, Clock, Plus } from "lucide-react" +import { Server, Clock, Plus, Loader2, ExternalLink } from "lucide-react" import type { ProgramDetail } from "@/types" -import { useServices, useJobs } from "@/services/api/hooks" +import { useServices, useJobs, useProgramAction } from "@/services/api/hooks" +import { subdomainUrl } from "@/lib/labels" import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm" -/** The services and jobs that deploy a program. A program → 0-N deployments; - * these are convenience links, not ownership (a deployment can run anything, - * program-backed or not). The Create button prefills the kind-aware wizard. */ +/** How a program is deployed, and its lifecycle. A program → 0-N deployments. + * Its own path (tool) / caddy (static) deployment is 1:1 with the program, so its + * lifecycle is shown inline here; service/job deployments link to their own pages + * where start/stop lives. This is the single home for "how this program runs". */ export function DeploymentsSection({ program }: { program: ProgramDetail }) { const { services, jobs, kind } = program const none = services.length === 0 && jobs.length === 0 @@ -31,7 +33,7 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {

- Deployments + Deployment

- Services and jobs that run this program. + How this program is materialized into the runtime.

+ {/* The program's own path/caddy deployment — its lifecycle, inline. */} + {kind === "tool" && } + {kind === "static" && } + {creating && ( )} - {none && !creating ? ( -

- {kind === "service" - ? "No service yet — this program isn't deployed." - : kind === "tool" - ? "Installed on PATH. Add a job to also run it on a timer." - : "None."} -

+ {/* Service/job deployments — managed on their own detail pages. */} + {none ? ( + (kind === "tool" || kind === "static") ? null : ( +

+ {kind === "service" + ? "No service yet — this program isn't deployed." + : "No deployment yet."} +

+ ) ) : ( -
+
{services.map((s) => ( ) } + +function Dot({ active }: { active: boolean | null }) { + const cls = + active === true + ? "bg-green-500" + : active === false + ? "bg-[var(--muted)]" + : "bg-transparent border border-[var(--muted)]" + return +} + +/** A tool's PATH deployment: install/uninstall is its start/stop (manager=path). */ +function PathLifecycle({ name, active }: { name: string; active: boolean | null }) { + const { mutate, isPending } = useProgramAction() + const installed = active === true + return ( +
+
+ + {installed ? "Installed on PATH" : "Not installed"} + manager: path +
+ +
+ ) +} + +/** A static (caddy) deployment: served by the gateway from its built dir. */ +function StaticStatus({ name, active }: { name: string; active: boolean | null }) { + const url = subdomainUrl(name) + const served = active === true + return ( +
+
+ + {served ? "Served by the gateway" : "Not built yet"} + manager: caddy +
+ {url && served && ( + + {name} + + + )} +
+ ) +} diff --git a/app/src/pages/ProgramDetail.tsx b/app/src/pages/ProgramDetail.tsx index 547984f..a7d1577 100644 --- a/app/src/pages/ProgramDetail.tsx +++ b/app/src/pages/ProgramDetail.tsx @@ -48,8 +48,6 @@ export function ProgramDetailPage() {