Fix tool install-state, inherit program description, declutter Tools cards

- Tool detail showed installed tools as 'Not installed': /deployments/{name}
  populated 'installed' for path deployments but left 'active' null, and
  ToolDetail keyed off 'active'. Now the endpoint sets active=installed for path,
  and ToolDetail reads 'installed' directly (verified: on-PATH ⇒ True, else False).
- A deployment now inherits its program's description by default: copied at
  creation (CLI create + API _save_deployment on new deployments) + a one-time
  patchup of existing ones (29 deployments).
- Tools page cards no longer list deployments (ProgramCard showDeployments=false) —
  the lens already scopes to the tool; the deployment list is a Programs-catalog thing.
This commit is contained in:
2026-07-01 13:07:00 -07:00
parent 183c589b63
commit 0463cd91f1
7 changed files with 64 additions and 33 deletions

View File

@@ -9,9 +9,16 @@ interface ProgramCardProps {
// "/tools" so a tool card opens its tool detail page (a tool is 1:1 with its // "/tools" so a tool card opens its tool detail page (a tool is 1:1 with its
// program, same name). // program, same name).
linkBase?: string linkBase?: string
// Whether to list the program's deployments. The Programs catalog shows them;
// a kind-specific lens (e.g. Tools) is already looking at one deployment, so off.
showDeployments?: boolean
} }
export function ProgramCard({ program, linkBase = "/programs" }: ProgramCardProps) { export function ProgramCard({
program,
linkBase = "/programs",
showDeployments = true,
}: ProgramCardProps) {
// The dot reflects the uniform lifecycle state (a tool on PATH, a service // 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 // 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. // Deployment section, not here — a card just shows state and links through.
@@ -38,19 +45,21 @@ export function ProgramCard({ program, linkBase = "/programs" }: ProgramCardProp
<StackBadge stack={program.stack} /> <StackBadge stack={program.stack} />
</div> </div>
{/* A program has no kind of its own — show its deployments (name · kind). */} {/* A program has no kind of its own — show its deployments (name · kind).
{program.deployments.length > 0 ? ( Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */}
<div className="flex flex-col gap-1 mb-2"> {showDeployments &&
{program.deployments.map((d) => ( (program.deployments.length > 0 ? (
<div key={d.name} className="flex items-center gap-1.5 text-xs"> <div className="flex flex-col gap-1 mb-2">
<span className="font-mono text-[var(--muted)]">{d.name}</span> {program.deployments.map((d) => (
<KindBadge kind={d.kind} /> <div key={d.name} className="flex items-center gap-1.5 text-xs">
</div> <span className="font-mono text-[var(--muted)]">{d.name}</span>
))} <KindBadge kind={d.kind} />
</div> </div>
) : ( ))}
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p> </div>
)} ) : (
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p>
))}
{program.description && ( {program.description && (
<p className="text-sm text-[var(--muted)]">{program.description}</p> <p className="text-sm text-[var(--muted)]">{program.description}</p>

View File

@@ -5,9 +5,10 @@ import { ProgramCard } from "./ProgramCard"
interface ProgramListProps { interface ProgramListProps {
programs: ProgramSummary[] programs: ProgramSummary[]
linkBase?: string // where each card links (default "/programs") linkBase?: string // where each card links (default "/programs")
showDeployments?: boolean // list each program's deployments on the card (default true)
} }
export function ProgramList({ programs, linkBase }: ProgramListProps) { export function ProgramList({ programs, linkBase, showDeployments }: ProgramListProps) {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -39,7 +40,12 @@ export function ProgramList({ programs, linkBase }: ProgramListProps) {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((program) => ( {filtered.map((program) => (
<ProgramCard key={program.id} program={program} linkBase={linkBase} /> <ProgramCard
key={program.id}
program={program}
linkBase={linkBase}
showDeployments={showDeployments}
/>
))} ))}
</div> </div>
)} )}

View File

@@ -38,8 +38,9 @@ export function ToolDetailPage() {
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{deployment.description}</p> <p className="text-sm text-[var(--muted)] -mt-4 mb-6">{deployment.description}</p>
)} )}
{/* A tool's PATH deployment: install/uninstall is its start/stop. */} {/* A tool's PATH deployment: install/uninstall is its start/stop. Its
<PathLifecycle name={deployment.id} active={deployment.active} onDone={refetch} /> live state is `installed` (on PATH), which the endpoint sets directly. */}
<PathLifecycle name={deployment.id} installed={deployment.installed} onDone={refetch} />
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6"> <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-3"> <h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-3">
@@ -65,19 +66,19 @@ export function ToolDetailPage() {
/** Install/uninstall a tool on PATH — the path deployment's lifecycle. */ /** Install/uninstall a tool on PATH — the path deployment's lifecycle. */
function PathLifecycle({ function PathLifecycle({
name, name,
active, installed: installedState,
onDone, onDone,
}: { }: {
name: string name: string
active: boolean | null installed: boolean | null
onDone: () => void onDone: () => void
}) { }) {
const { mutate, isPending } = useProgramAction() const { mutate, isPending } = useProgramAction()
const installed = active === true const installed = installedState === true
const dot = const dot =
active === true installedState === true
? "bg-green-500" ? "bg-green-500"
: active === false : installedState === false
? "bg-[var(--muted)]" ? "bg-[var(--muted)]"
: "bg-transparent border border-[var(--muted)]" : "bg-transparent border border-[var(--muted)]"
return ( return (

View File

@@ -14,7 +14,7 @@ export function Tools() {
{isLoading ? ( {isLoading ? (
<p className="text-[var(--muted)]">Loading...</p> <p className="text-[var(--muted)]">Loading...</p>
) : programs && programs.length > 0 ? ( ) : programs && programs.length > 0 ? (
<ProgramList programs={programs} linkBase="/tools" /> <ProgramList programs={programs} linkBase="/tools" showDeployments={false} />
) : ( ) : (
<p className="text-[var(--muted)]">No tools yet.</p> <p className="text-[var(--muted)]">No tools yet.</p>
)} )}

View File

@@ -273,18 +273,27 @@ async def delete_program(name: str, cascade: bool = False) -> dict:
def _save_deployment(name: str, config_dict: dict) -> dict: def _save_deployment(name: str, config_dict: dict) -> dict:
"""Validate a deployment (any manager) and persist it to config.deployments.""" """Validate a deployment (any manager) and persist it to config.deployments."""
_require_repo() _require_repo()
config = get_config()
config_dict = dict(config_dict)
# On CREATE (a new deployment) with no description of its own, inherit the
# referenced program's description — a deployment reads as its program by
# default. Edits keep whatever the user set (including a cleared field).
if name not in config.deployments and not config_dict.get("description"):
prog = config_dict.get("program")
if prog and prog in config.programs and config.programs[prog].description:
config_dict["description"] = config.programs[prog].description
try: try:
dep_data = _normalize_deployment_dict({**config_dict, "id": name}) dep = _DEPLOYMENT_ADAPTER.validate_python(
_DEPLOYMENT_ADAPTER.validate_python(dep_data) _normalize_deployment_dict({**config_dict, "id": name})
)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid deployment config: {e}", detail=f"Invalid deployment config: {e}",
) )
config = get_config() config.deployments[name] = dep
config.deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(
_normalize_deployment_dict({**config_dict, "id": name})
)
save_config(config) save_config(config)
return {"ok": True, "deployment": name} return {"ok": True, "deployment": name}

View File

@@ -77,10 +77,13 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
timer=has_timer, timer=has_timer,
) )
# A PATH-managed deployment (a tool) is "installed" when it's on PATH. # A PATH-managed deployment (a tool) is "installed" — and thus active — when
# it's on PATH. (systemd/caddy liveness comes from the health/status stream.)
installed: bool | None = None installed: bool | None = None
active: bool | None = None
if deployed.manager == "path": if deployed.manager == "path":
installed = shutil.which(name) is not None installed = shutil.which(name) is not None
active = installed
category = "job" if deployed.schedule else "service" category = "job" if deployed.schedule else "service"
@@ -99,6 +102,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
systemd=systemd_info, systemd=systemd_info,
schedule=deployed.schedule, schedule=deployed.schedule,
installed=installed, installed=installed,
active=active,
) )

View File

@@ -115,12 +115,13 @@ def run_create(args: argparse.Namespace) -> int:
if kind == "tool": if kind == "tool":
# A PATH-managed deployment: installed via `uv tool install`, no unit/route. # A PATH-managed deployment: installed via `uv tool install`, no unit/route.
config.deployments[name] = PathDeployment( config.deployments[name] = PathDeployment(
id=name, manager="path", program=name id=name, manager="path", program=name, description=description
) )
elif kind == "static": elif kind == "static":
# A caddy-managed static deployment: no systemd unit, served from the build dir. # A caddy-managed static deployment: no systemd unit, served from the build dir.
config.deployments[name] = CaddyDeployment( config.deployments[name] = CaddyDeployment(
id=name, manager="caddy", program=name, root=static_root or "dist" id=name, manager="caddy", program=name, root=static_root or "dist",
description=description,
) )
elif kind == "service": elif kind == "service":
prefix = name.replace("-", "_").upper() prefix = name.replace("-", "_").upper()
@@ -128,6 +129,7 @@ def run_create(args: argparse.Namespace) -> int:
id=name, id=name,
manager="systemd", manager="systemd",
program=name, program=name,
description=description,
run=RunPython(launcher="python", program=name), run=RunPython(launcher="python", program=name),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(