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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user