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 (
-
+
setSearch(e.target.value)} placeholder="Filter programs..." className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56" /> -
- setKind(null)} - /> - {kindsPresent.map((k) => ( - setKind(kind === k ? null : k)} - /> - ))} -
{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 ( - - ) -} 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)]" - /> + +
+ + · + 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)]" - /> +
+ + · + 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 }