fix(app): drive program activate toggle off active, not installed

The Programs table's install/uninstall icon keyed off `installed`
(shutil.which(name)) — a PATH lookup that only makes sense for python CLI
tools. Static frontends have no PATH binary so they were always
`installed=false` → a green 'Install' icon even while actively served;
container daemons were `installed=null` → same. And for tools the value
reflected the castle-api service's PATH, which isn't guaranteed to include
~/.local/bin across reboots, so they could all flip to 'not installed'.

Switch ProgramActions/ProgramTable/ProgramDetail to the uniform `active`
state (Phase 2's lifecycle.is_active: PATH-independent ~/.local/bin check for
tools, systemctl for services/jobs, served-assets check for static
frontends). Now every program reflects real state: served frontends show
Uninstall, container daemons (no install verb) show no icon.

App builds clean.
This commit is contained in:
2026-06-14 12:26:29 -07:00
parent 432bf2c57f
commit 203f5212e6
3 changed files with 15 additions and 13 deletions

View File

@@ -41,32 +41,34 @@ export interface ActionOutput {
interface ProgramActionsProps { interface ProgramActionsProps {
name: string name: string
actions: string[] actions: string[]
installed?: boolean | null active?: boolean | null
compact?: boolean compact?: boolean
onOutput?: (output: ActionOutput) => void onOutput?: (output: ActionOutput) => void
} }
function visibleActions( function visibleActions(
actions: string[], actions: string[],
installed: boolean | null | undefined, active: boolean | null | undefined,
compact: boolean, compact: boolean,
): string[] { ): 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) { if (compact) {
// Table: only install/uninstall based on state // Table: only the activate/deactivate toggle based on state
if (installed === true) return actions.includes("uninstall") ? ["uninstall"] : [] if (active === true) return actions.includes("uninstall") ? ["uninstall"] : []
if (installed === false) return actions.includes("install") ? ["install"] : [] if (active === false) return actions.includes("install") ? ["install"] : []
// null — show install if available (frontends, etc.) // null — show install if available
return actions.includes("install") ? ["install"] : [] return actions.includes("install") ? ["install"] : []
} }
// Detail page: dev actions always, install/uninstall based on state // Detail page: dev actions always, activate/deactivate based on state
const visible: string[] = [] const visible: string[] = []
for (const a of DEV_ACTIONS) { for (const a of DEV_ACTIONS) {
if (actions.includes(a)) visible.push(a) if (actions.includes(a)) visible.push(a)
} }
if (installed === true) { if (active === true) {
if (actions.includes("uninstall")) visible.push("uninstall") if (actions.includes("uninstall")) visible.push("uninstall")
} else if (installed === false) { } else if (active === false) {
if (actions.includes("install")) visible.push("install") if (actions.includes("install")) visible.push("install")
} else { } else {
// null — show both if available // null — show both if available
@@ -76,11 +78,11 @@ function visibleActions(
return visible return visible
} }
export function ProgramActions({ name, actions, installed, compact, onOutput }: ProgramActionsProps) { export function ProgramActions({ name, actions, active, 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, installed, !!compact) const visible = visibleActions(actions, active, !!compact)
const handleAction = (action: string) => { const handleAction = (action: string) => {
setRunningAction(action) setRunningAction(action)

View File

@@ -143,7 +143,7 @@ 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} installed={program.installed} compact /> <ProgramActions name={program.id} actions={program.actions} active={program.active} compact />
</td> </td>
</tr> </tr>
) )

View File

@@ -37,7 +37,7 @@ export function ProgramDetailPage() {
stack={deployment.stack} stack={deployment.stack}
source={deployment.source} source={deployment.source}
> >
<ProgramActions name={deployment.id} actions={deployment.actions} installed={deployment.installed} onOutput={setActionOutput} /> <ProgramActions name={deployment.id} actions={deployment.actions} active={deployment.active} onOutput={setActionOutput} />
</DetailHeader> </DetailHeader>
{actionOutput && actionOutput.action && ( {actionOutput && actionOutput.action && (