diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx
index ebc97bd..e369642 100644
--- a/app/src/components/Layout.tsx
+++ b/app/src/components/Layout.tsx
@@ -10,6 +10,7 @@ import {
Package,
Server,
Share2,
+ Wrench,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
@@ -20,6 +21,7 @@ const NAV = [
{ to: "/gateway", label: "Gateway", icon: Globe, end: false },
{ to: "/services", label: "Services", icon: Server, end: false },
{ to: "/scheduled", label: "Scheduled", icon: Clock, end: false },
+ { to: "/tools", label: "Tools", icon: Wrench, end: false },
{ to: "/programs", label: "Programs", icon: Package, end: false },
{ to: "/mesh", label: "Mesh", icon: Share2, end: false },
]
diff --git a/app/src/components/ProgramCard.tsx b/app/src/components/ProgramCard.tsx
index 1cf25b5..edf8f15 100644
--- a/app/src/components/ProgramCard.tsx
+++ b/app/src/components/ProgramCard.tsx
@@ -31,13 +31,23 @@ export function ProgramCard({ program }: ProgramCardProps) {
>
{program.id}
-
-
-
-
+ {/* A program has no kind of its own — show its deployments (name · kind). */}
+ {program.deployments.length > 0 ? (
+
+ {program.deployments.map((d) => (
+
+ {d.name}
+
+
+ ))}
+
+ ) : (
+ no deployment
+ )}
+
{program.description && (
{program.description}
)}
diff --git a/app/src/components/ProgramList.tsx b/app/src/components/ProgramList.tsx
index 0ab2b59..3198d98 100644
--- a/app/src/components/ProgramList.tsx
+++ b/app/src/components/ProgramList.tsx
@@ -1,38 +1,16 @@
import { useMemo, useState } from "react"
import type { ProgramSummary } from "@/types"
import { ProgramCard } from "./ProgramCard"
-import { kindLabel } from "@/lib/labels"
-import { cn } from "@/lib/utils"
interface ProgramListProps {
programs: ProgramSummary[]
}
-const KIND_ORDER = ["service", "job", "tool", "static", "reference"]
-
-// Active chip color per kind — mirrors KindBadge so the filter reads as the badge.
-const KIND_ACTIVE: Record = {
- service: "bg-green-700 text-white border-green-600",
- job: "bg-purple-700 text-white border-purple-600",
- tool: "bg-blue-700 text-white border-blue-600",
- static: "bg-cyan-700 text-white border-cyan-600",
- reference: "bg-gray-600 text-gray-200 border-gray-500",
-}
-
export function ProgramList({ programs }: ProgramListProps) {
const [search, setSearch] = useState("")
- const [kind, setKind] = useState(null)
-
- const counts = useMemo(() => {
- const c: Record = {}
- for (const p of programs) if (p.kind) c[p.kind] = (c[p.kind] ?? 0) + 1
- return c
- }, [programs])
- const kindsPresent = KIND_ORDER.filter((k) => counts[k])
const filtered = useMemo(() => {
let base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
- if (kind) base = base.filter((p) => p.kind === kind)
if (search) {
const q = search.toLowerCase()
base = base.filter(
@@ -42,34 +20,17 @@ export function ProgramList({ programs }: ProgramListProps) {
)
}
return base
- }, [programs, search, kind])
+ }, [programs, search])
return (
-
+
{filtered.length === 0 ? (
@@ -84,29 +45,3 @@ export function ProgramList({ programs }: ProgramListProps) {
)
}
-
-function Chip({
- label,
- active,
- activeClass,
- onClick,
-}: {
- label: string
- active: boolean
- activeClass: string
- onClick: () => void
-}) {
- return (
-
- {label}
-
- )
-}
diff --git a/app/src/components/ServiceCard.tsx b/app/src/components/ServiceCard.tsx
index 628c451..d81605b 100644
--- a/app/src/components/ServiceCard.tsx
+++ b/app/src/components/ServiceCard.tsx
@@ -5,6 +5,7 @@ import { useServiceAction } from "@/services/api/hooks"
import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge"
+import { KindBadge } from "./KindBadge"
interface ServiceCardProps {
service: ServiceSummary
@@ -37,7 +38,9 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
) : null}
-
+
+ {/* A static (caddy-served) "service" is distinguished from a systemd one. */}
+ {service.kind === "static" && }
diff --git a/app/src/components/detail/DeploymentsSection.tsx b/app/src/components/detail/DeploymentsSection.tsx
index d8cee87..2313439 100644
--- a/app/src/components/detail/DeploymentsSection.tsx
+++ b/app/src/components/detail/DeploymentsSection.tsx
@@ -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 (
@@ -47,8 +51,13 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
{/* The program's own path/caddy deployment — its lifecycle, inline. */}
- {kind === "tool" &&
}
- {kind === "static" &&
}
+ {inline.map((d) =>
+ d.kind === "tool" ? (
+
+ ) : (
+
+ ),
+ )}
{creating && (
- {kind === "service"
- ? "No service yet — this program isn't deployed."
- : "No deployment yet."}
-
- )
- ) : (
+ {deployments.length === 0 && !creating ? (
+ No deployment yet.
+ ) : linked.length > 0 ? (
- {services.map((s) => (
+ {linked.map((d) => (
-
- {s}
- service
-
- ))}
- {jobs.map((j) => (
-
-
- {j}
- job
+ {d.kind === "job" ? (
+
+ ) : (
+
+ )}
+ {d.name}
+ {d.kind}
))}
- )}
+ ) : null}
)
}
diff --git a/app/src/components/detail/JobFields.tsx b/app/src/components/detail/JobFields.tsx
index 9f6206e..6c4da75 100644
--- a/app/src/components/detail/JobFields.tsx
+++ b/app/src/components/detail/JobFields.tsx
@@ -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
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)
- 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."
/>
-
- {launcherLabel(launcher)} ·
- 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)]"
- />
+
+
+ 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) => (
+
+ {l}
+
+ ))}
+
+ ·
+ 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)]"
+ />
+
{envEditor}
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)
- 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) {
- {launcherLabel(launcher)} ·
- 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)]"
- />
+
+ 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) => (
+
+ {l}
+
+ ))}
+
+ ·
+ 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)]"
+ />
+
d.kind === "static")
const servedAt =
- deployment.kind === "static" && buildOutputs?.length
+ isStatic && buildOutputs?.length
? (subdomainUrl(deployment.id) ?? `${deployment.id}.`)
: null
@@ -41,7 +42,6 @@ export function ProgramDetailPage() {
backTo="/programs"
backLabel="Back to Programs"
name={deployment.id}
- kind={deployment.kind}
stack={deployment.stack}
source={deployment.source}
>
diff --git a/app/src/pages/Services.tsx b/app/src/pages/Services.tsx
index 7607b86..b64bcd2 100644
--- a/app/src/pages/Services.tsx
+++ b/app/src/pages/Services.tsx
@@ -17,7 +17,7 @@ export function Services() {
setCreating((c) => !c)}
diff --git a/app/src/pages/Tools.tsx b/app/src/pages/Tools.tsx
new file mode 100644
index 0000000..171238e
--- /dev/null
+++ b/app/src/pages/Tools.tsx
@@ -0,0 +1,23 @@
+import { usePrograms } from "@/services/api/hooks"
+import { ProgramList } from "@/components/ProgramList"
+import { PageHeader } from "@/components/PageHeader"
+
+export function Tools() {
+ // Tools are program-centric — a tool is a program installed on PATH (its path
+ // deployment is 1:1 and trivial), so we list the programs that have one.
+ const { data: programs, isLoading } = usePrograms("tool")
+
+ return (
+
+
+
+ {isLoading ? (
+
Loading...
+ ) : programs && programs.length > 0 ? (
+
+ ) : (
+
No tools yet.
+ )}
+
+ )
+}
diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx
index b4580b0..a6d7870 100644
--- a/app/src/router/routes.tsx
+++ b/app/src/router/routes.tsx
@@ -3,6 +3,7 @@ import { Layout } from "@/components/Layout"
import { Overview } from "@/pages/Overview"
import { Services } from "@/pages/Services"
import { Scheduled } from "@/pages/Scheduled"
+import { Tools } from "@/pages/Tools"
import { Programs } from "@/pages/Programs"
import { GatewayPage } from "@/pages/GatewayPage"
import { MeshPage } from "@/pages/MeshPage"
@@ -20,6 +21,7 @@ export const router = createBrowserRouter([
{ index: true, element: },
{ path: "services", element: },
{ path: "scheduled", element: },
+ { path: "tools", element: },
{ path: "programs", element: },
{ path: "gateway", element: },
{ path: "mesh", element: },
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index e86e5b7..e45d62f 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -8,8 +8,9 @@ export interface ServiceSummary {
id: string
description: string | null
stack: string | null
- manager?: string | null // systemd for a service
- launcher: string | null // python | command | container | compose | node
+ kind: string | null // service | static
+ manager: string | null // systemd | caddy
+ launcher: string | null // python | command | container | compose | node (systemd only)
run_target: string | null
port: number | null
health_path: string | null
@@ -43,10 +44,16 @@ export interface JobDetail extends JobSummary {
manifest: Record
}
+// A program's deployment (name + its derived kind). A program has no kind of its
+// own — it has deployments, each with a kind (a program can be a tool AND a job).
+export interface DeploymentRef {
+ name: string
+ kind: string // service | job | tool | static | reference
+}
+
export interface ProgramSummary {
id: string
description: string | null
- kind: string | null // derived: service | job | tool | static | reference
stack: string | null
version: string | null
source: string | null
@@ -57,8 +64,7 @@ export interface ProgramSummary {
installed: boolean | null
active: boolean | null
actions: string[]
- services: string[]
- jobs: string[]
+ deployments: DeploymentRef[]
node: string | null
}