Dashboard: programs list deployments; Tools page; statics on Services; editable launcher

- Programs page: each card lists the program's deployments (name · kind) instead
  of a single kind badge; kind-filter chips removed (nav is the kind lens now).
  protonmail honestly shows tool + job.
- New Tools nav page (Wrench) — programs with a tool deployment (/programs?kind=tool).
- Services page now shows statics too: /services returns service + static, and a
  static renders as a caddy-served card (KindBadge, served URL, no start/stop).
- Program detail: DeploymentsSection + deleteConfirm rebuilt over program.deployments;
  header drops the (now nonexistent) program kind.
- ServiceFields / JobFields: launcher is now an editable select (python|command|
  container|compose|node), persisted into run.launcher via the normal save.

clean pnpm build + tsc --noEmit.
This commit is contained in:
2026-07-01 12:32:09 -07:00
parent 10a86d0b6f
commit 01505328ad
13 changed files with 199 additions and 148 deletions

View File

@@ -11,8 +11,7 @@ import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm
* 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
const { deployments } = program
const [creating, setCreating] = useState(false)
const { data: allServices } = useServices()
@@ -29,6 +28,11 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
launcher: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
}
// Tool/static deployments are 1:1 with the program (same name) — their
// lifecycle is shown inline; service/job deployments link to their own pages.
const inline = deployments.filter((d) => d.kind === "tool" || d.kind === "static")
const linked = deployments.filter((d) => d.kind === "service" || d.kind === "job")
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<div className="flex items-center justify-between mb-1">
@@ -47,8 +51,13 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
</p>
{/* The program's own path/caddy deployment — its lifecycle, inline. */}
{kind === "tool" && <PathLifecycle name={program.id} active={program.active} />}
{kind === "static" && <StaticStatus name={program.id} active={program.active} />}
{inline.map((d) =>
d.kind === "tool" ? (
<PathLifecycle key={d.name} name={program.id} active={program.active} />
) : (
<StaticStatus key={d.name} name={program.id} active={program.active} />
),
)}
{creating && (
<CreateDeploymentForm
@@ -59,40 +68,27 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
)}
{/* Service/job deployments — managed on their own detail pages. */}
{none ? (
(kind === "tool" || kind === "static") ? null : (
<p className="text-sm text-[var(--muted)]">
{kind === "service"
? "No service yet — this program isn't deployed."
: "No deployment yet."}
</p>
)
) : (
{deployments.length === 0 && !creating ? (
<p className="text-sm text-[var(--muted)]">No deployment yet.</p>
) : linked.length > 0 ? (
<div className="space-y-1.5 mt-1">
{services.map((s) => (
{linked.map((d) => (
<Link
key={s}
to={`/services/${s}`}
key={d.name}
to={d.kind === "job" ? `/jobs/${d.name}` : `/services/${d.name}`}
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>
{d.kind === "job" ? (
<Clock size={14} className="text-[var(--muted)]" />
) : (
<Server size={14} className="text-[var(--muted)]" />
)}
<span className="font-medium">{d.name}</span>
<span className="text-xs text-[var(--muted)]">{d.kind}</span>
</Link>
))}
</div>
)}
) : null}
</div>
)
}

View File

@@ -1,6 +1,5 @@
import { useState } from "react"
import type { JobDetail } from "@/types"
import { launcherLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
@@ -12,6 +11,28 @@ interface Props {
type Obj = Record<string, unknown>
const obj = (v: unknown): Obj => (v as Obj) ?? {}
// A job is a systemd deployment; its launcher is editable like a service's.
const LAUNCHERS = ["python", "command", "container", "compose", "node"]
function applyLauncher(run: Obj, launcher: string, target: string): Obj {
const out: Obj = { ...run, launcher }
const t = target.trim()
if (launcher === "command") {
out.argv = t.split(/\s+/).filter(Boolean)
delete out.program
} else if (launcher === "python") {
out.program = t
delete out.argv
} else if (launcher === "container") {
if (t) out.image = t
} else if (launcher === "node") {
if (t) out.script = t
} else if (launcher === "compose") {
if (t) out.file = t
}
return out
}
/** Edit a job's deployment config (schedule / run / env). */
export function JobFields({ job, onSave, onDelete }: Props) {
const m = job.manifest
@@ -22,12 +43,17 @@ export function JobFields({ job, onSave, onDelete }: Props) {
const [description, setDescription] = useState((m.description as string) ?? "")
const [schedule, setSchedule] = useState((m.schedule as string) ?? "")
const [launcher, setLauncher] = useState((run.launcher as string) ?? "command")
const [runTarget, setRunTarget] = useState(
(run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
(run.program as string) ||
((run.argv as string[]) ?? []).join(" ") ||
(run.image as string) ||
(run.script as string) ||
(run.file as string) ||
"",
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const launcher = (run.launcher as string) ?? "?"
const handleSave = async () => {
setSaving(true)
@@ -38,10 +64,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
config.description = description || undefined
config.schedule = schedule || undefined
const runOut = obj(config.run)
if (launcher === "command") runOut.argv = runTarget.split(" ").filter(Boolean)
else if (launcher === "python") runOut.program = runTarget
config.run = runOut
config.run = applyLauncher(obj(config.run), launcher, runTarget)
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
@@ -67,13 +90,26 @@ export function JobFields({ job, onSave, onDelete }: Props) {
placeholder="0 2 * * *"
hint="Cron expression — castle generates a systemd timer that runs the job on this schedule."
/>
<Field label="Runs" hint="The console script or command the job runs on each tick, then exits.">
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} &middot; </span>
<input
value={runTarget}
onChange={(e) => setRunTarget(e.target.value)}
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
<Field label="Runs" hint="How the job runs on each tick, then exits: the launcher and its target (a console script, command/argv, image, node script, or compose file).">
<div className="flex items-center gap-2">
<select
value={launcher}
onChange={(e) => setLauncher(e.target.value)}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
>
{LAUNCHERS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
<span className="text-[var(--muted)]">&middot;</span>
<input
value={runTarget}
onChange={(e) => setRunTarget(e.target.value)}
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
</div>
</Field>
{envEditor}
<FormFooter

View File

@@ -172,12 +172,13 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
* confirm enumerates exactly what will happen. Source on disk is always kept. */
function deleteConfirm(program: ProgramDetail): string {
const actions: string[] = []
// The program's own 1:1 deployment (tool/static) isn't listed in services/jobs.
if (program.kind === "tool") actions.push(`uninstall ${program.id} from your PATH`)
else if (program.kind === "static")
actions.push(`stop serving ${program.id} (drop its gateway route)`)
// Named service/job deployments that reference this program.
const named = [...program.services, ...program.jobs]
// Enumerate the cascade — one line per deployment, keyed on its kind.
const named: string[] = []
for (const d of program.deployments) {
if (d.kind === "tool") actions.push(`uninstall ${d.name} from your PATH`)
else if (d.kind === "static") actions.push(`stop serving ${d.name}`)
else named.push(d.name)
}
if (named.length) actions.push(`stop, disable, and remove: ${named.join(", ")}`)
// Always: the catalog entry itself.
actions.push(`remove "${program.id}" from the catalog`)

View File

@@ -1,6 +1,5 @@
import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { launcherLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
@@ -12,6 +11,31 @@ interface Props {
type Obj = Record<string, unknown>
const obj = (v: unknown): Obj => (v as Obj) ?? {}
// The systemd launch mechanisms (a service is manager=systemd). Editable, so a
// mis-set launcher can be corrected; the primary "Runs" target maps per launcher.
const LAUNCHERS = ["python", "command", "container", "compose", "node"]
/** Fold the "Runs" text into the run block for the chosen launcher, preserving
* any other run fields (args, ports, package_manager, …) already present. */
function applyLauncher(run: Obj, launcher: string, target: string): Obj {
const out: Obj = { ...run, launcher }
const t = target.trim()
if (launcher === "command") {
out.argv = t.split(/\s+/).filter(Boolean)
delete out.program
} else if (launcher === "python") {
out.program = t
delete out.argv
} else if (launcher === "container") {
if (t) out.image = t
} else if (launcher === "node") {
if (t) out.script = t
} else if (launcher === "compose") {
if (t) out.file = t
}
return out
}
/** Edit a service's deployment config (run / expose / proxy / env). */
export function ServiceFields({ service, onSave, onDelete }: Props) {
const m = service.manifest
@@ -23,8 +47,14 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const [saved, setSaved] = useState(false)
const [description, setDescription] = useState((m.description as string) ?? "")
const [launcher, setLauncher] = useState((run.launcher as string) ?? "python")
const [runProgram, setRunProgram] = useState(
(run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
(run.program as string) ||
((run.argv as string[]) ?? []).join(" ") ||
(run.image as string) ||
(run.script as string) ||
(run.file as string) ||
"",
)
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
@@ -33,8 +63,6 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const launcher = (run.launcher as string) ?? "?"
const handleSave = async () => {
setSaving(true)
setSaved(false)
@@ -43,12 +71,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
delete config.id
config.description = description || undefined
// Only python/command launchers are edited here; other launchers
// (container/node/compose) keep their original run block untouched.
const runOut = obj(config.run)
if (launcher === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
else if (launcher === "python") runOut.program = runProgram
config.run = runOut
// Rebuild the run block for the chosen launcher, preserving other fields.
config.run = applyLauncher(obj(config.run), launcher, runProgram)
if (port) {
config.expose = {
@@ -81,14 +105,27 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
<TextField label="Description" value={description} onChange={setDescription} />
<Field
label="Runs"
hint="The console script (python runner) or command this service executes."
hint="How this service starts: the launcher, and its target — a console script (python), a command/argv (command), an image (container), a script (node), or a compose file (compose)."
>
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} &middot; </span>
<input
value={runProgram}
onChange={(e) => setRunProgram(e.target.value)}
className="w-full sm:w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
<div className="flex items-center gap-2">
<select
value={launcher}
onChange={(e) => setLauncher(e.target.value)}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
>
{LAUNCHERS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
<span className="text-[var(--muted)]">&middot;</span>
<input
value={runProgram}
onChange={(e) => setRunProgram(e.target.value)}
className="w-full sm:w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
</div>
</Field>
<TextField
label="Port"