Manager-first deployment model: split runner, merge service/job, frontend→static

Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.

- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
  normalizes old runner shapes; CastleConfig.deployments with derived
  services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
  create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
  derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
  per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.

Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
This commit is contained in:
2026-07-01 10:23:03 -07:00
parent 00e8d58c6a
commit 317232ca6a
73 changed files with 1525 additions and 1442 deletions

View File

@@ -1,24 +0,0 @@
import { cn } from "@/lib/utils"
import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels"
const behaviorColors: Record<string, string> = {
daemon: "bg-green-700 text-white",
tool: "bg-blue-700 text-white",
frontend: "bg-yellow-600 text-black",
}
export function BehaviorBadge({ behavior }: { behavior: string | null }) {
if (!behavior) return null
return (
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
behaviorColors[behavior] ?? "bg-gray-600 text-gray-200",
)}
title={BEHAVIOR_DESCRIPTIONS[behavior]}
>
{behaviorLabel(behavior)}
</span>
)
}

View File

@@ -2,7 +2,7 @@ import { Clock, Play, RefreshCw, Square, Terminal } from "lucide-react"
import { Link } from "react-router-dom"
import type { JobSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { launcherLabel } from "@/lib/labels"
import { StackBadge } from "./StackBadge"
interface JobCardProps {
@@ -50,10 +50,10 @@ export function JobCard({ job, health }: JobCardProps) {
{job.schedule}
</span>
)}
{job.runner && (
{job.launcher && (
<span className="flex items-center gap-1">
<Terminal size={12} />
{runnerLabel(job.runner)}
{launcherLabel(job.launcher)}
</span>
)}
</div>

View File

@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils"
import { KIND_DESCRIPTIONS, kindLabel } from "@/lib/labels"
// Derived deployment kind → badge color.
const kindColors: Record<string, string> = {
service: "bg-green-700 text-white",
job: "bg-purple-700 text-white",
tool: "bg-blue-700 text-white",
static: "bg-cyan-700 text-white",
reference: "bg-gray-600 text-gray-200",
}
export function KindBadge({ kind }: { kind: string | null }) {
if (!kind) return null
return (
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
kindColors[kind] ?? "bg-gray-600 text-gray-200",
)}
title={KIND_DESCRIPTIONS[kind]}
>
{kindLabel(kind)}
</span>
)
}

View File

@@ -42,20 +42,16 @@ interface ProgramActionsProps {
name: string
actions: string[]
active?: boolean | null
behavior?: string | null
/** Names of services/jobs deploying this program — daemons activate via these. */
deployedAs?: string[]
kind?: string | null
compact?: boolean
onOutput?: (output: ActionOutput) => void
}
/** install/uninstall (activate) is meaningful only for tools (PATH) and static
* frontends (served). A daemon activates through a service/job, so it never
* shows install/uninstall here — its run controls live on the deployment page. */
function showsActivation(behavior: string | null | undefined, deployedAs: string[]): boolean {
if (behavior === "daemon") return false
if (behavior === "frontend") return deployedAs.length === 0 // self-serving frontend → its service
return true // tools (and unspecified)
/** install/uninstall (activate) is meaningful only for a tool (a PATH deployment).
* Services, jobs, and static (caddy) deployments are managed through their
* deployment — never install/uninstall here. */
function showsActivation(kind: string | null | undefined): boolean {
return kind === "tool"
}
function visibleActions(
@@ -94,16 +90,15 @@ export function ProgramActions({
name,
actions,
active,
behavior,
deployedAs = [],
kind,
compact,
onOutput,
}: ProgramActionsProps) {
const { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null)
// Drop install/uninstall for behaviors that activate via a deployment.
const allowed = showsActivation(behavior, deployedAs)
// Drop install/uninstall for kinds that activate via a deployment.
const allowed = showsActivation(kind)
? actions
: actions.filter((a) => a !== "install" && a !== "uninstall")
const visible = visibleActions(allowed, active, !!compact)

View File

@@ -1,6 +1,6 @@
import { Link } from "react-router-dom"
import type { ProgramSummary } from "@/types"
import { BehaviorBadge } from "./BehaviorBadge"
import { KindBadge } from "./KindBadge"
import { StackBadge } from "./StackBadge"
import { ProgramActions } from "./ProgramActions"
@@ -21,7 +21,7 @@ export function ProgramCard({ program }: ProgramCardProps) {
</div>
<div className="flex flex-wrap gap-1.5 mb-2">
<BehaviorBadge behavior={program.behavior} />
<KindBadge kind={program.kind} />
<StackBadge stack={program.stack} />
</div>
@@ -34,8 +34,7 @@ export function ProgramCard({ program }: ProgramCardProps) {
name={program.id}
actions={program.actions}
active={program.active}
behavior={program.behavior}
deployedAs={[...program.services, ...program.jobs]}
kind={program.kind}
compact
/>
</div>

View File

@@ -2,7 +2,7 @@ import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-
import { Link } from "react-router-dom"
import type { ServiceSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { runnerLabel, subdomainUrl } from "@/lib/labels"
import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge"
@@ -52,10 +52,10 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
<Server size={12} />:{service.port}
</span>
)}
{service.runner && (
{service.launcher && (
<span className="flex items-center gap-1">
<Terminal size={12} />
{runnerLabel(service.runner)}
{launcherLabel(service.launcher)}
</span>
)}
{service.subdomain && (

View File

@@ -23,11 +23,14 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
const [pendingApply, setPendingApply] = useState(false)
const [applying, setApplying] = useState(false)
const isDeployment = configSection !== "programs"
// Programs are their own catalog; every deployment kind (service/job/tool/
// static) lives in the single deployments/ collection.
const writeSection = isDeployment ? "deployments" : "programs"
const handleSave = async (compName: string, config: Record<string, unknown>) => {
setMessage(null)
try {
await apiClient.put(`/config/${configSection}/${compName}`, { config })
await apiClient.put(`/config/${writeSection}/${compName}`, { config })
setMessage({
type: "ok",
text: isDeployment
@@ -71,7 +74,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
const handleDelete = async (compName: string) => {
try {
await apiClient.delete(`/config/${configSection}/${compName}`)
await apiClient.delete(`/config/${writeSection}/${compName}`)
qc.invalidateQueries({ queryKey: [configSection] })
navigate("/")
} catch (e: unknown) {

View File

@@ -8,23 +8,32 @@ import { Field, TextField } from "./fields"
const SELECT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
export type DeploymentKind = "service" | "job" | "tool" | "static"
export interface CreatePrefill {
name?: string
program?: string
runTarget?: string
runner?: string
launcher?: string
}
/** Create a service or job in castle.yaml, then deploy (and start, for a
* service). The UI twin of `castle expose`. Reachable standalone or prefilled
* from a program page. */
const KIND_INFO: Record<DeploymentKind, { label: string; hint: string }> = {
service: { label: "Service", hint: "Long-running process (systemd)" },
job: { label: "Job", hint: "Scheduled task (systemd timer)" },
tool: { label: "Tool", hint: "CLI installed on PATH" },
static: { label: "Static", hint: "Static site served by the gateway" },
}
/** Create a deployment in castle.yaml, then deploy (and start, for a service).
* A pick-a-kind wizard: the chosen kind sets the manager and shows only its
* relevant fields. Reachable standalone or prefilled from a program page. */
export function CreateDeploymentForm({
kind,
kind: initialKind,
prefill,
existingNames,
onCancel,
}: {
kind: "service" | "job"
kind?: DeploymentKind
prefill?: CreatePrefill
existingNames: string[]
onCancel: () => void
@@ -32,18 +41,23 @@ export function CreateDeploymentForm({
const qc = useQueryClient()
const navigate = useNavigate()
const [kind, setKind] = useState<DeploymentKind>(initialKind ?? "service")
const [name, setName] = useState(prefill?.name ?? "")
const [program] = useState(prefill?.program ?? "")
const [description, setDescription] = useState("")
const [runner, setRunner] = useState(prefill?.runner ?? "python")
const [launcher, setLauncher] = useState(prefill?.launcher ?? "python")
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
const [root, setRoot] = useState("dist")
const [port, setPort] = useState("")
const [health, setHealth] = useState("/health")
const [expose, setExpose] = useState(true)
const [proxy, setProxy] = useState(true)
const [isPublic, setIsPublic] = useState(false)
const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState("")
const isSystemd = kind === "service" || kind === "job"
const nameError =
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
? "lowercase letters, numbers, hyphens"
@@ -51,31 +65,41 @@ export function CreateDeploymentForm({
? "already exists"
: ""
const buildRun = () =>
launcher === "command"
? { launcher: "command", argv: runTarget.split(" ").filter(Boolean) }
: { launcher: "python", program: runTarget || name }
const buildConfig = (): Record<string, unknown> => {
const run =
runner === "command"
? { runner: "command", argv: runTarget.split(" ").filter(Boolean) }
: { runner: "python", program: runTarget || name }
const base: Record<string, unknown> = {
...(program ? { program } : {}),
...(description ? { description } : {}),
run,
}
if (kind === "tool") return { ...base, manager: "path" }
if (kind === "static") return { ...base, manager: "caddy", root }
// systemd (service or job)
const cfg: Record<string, unknown> = {
...base,
manager: "systemd",
run: buildRun(),
manage: { systemd: {} },
}
if (kind === "job") {
base.schedule = schedule
return base
cfg.schedule = schedule
return cfg
}
if (port) {
base.expose = {
cfg.expose = {
http: {
internal: { port: parseInt(port, 10) },
...(health ? { health_path: health } : {}),
},
}
}
if (port && expose) base.proxy = true
return base
if (proxy) cfg.proxy = true
if (proxy && isPublic) cfg.public = true
return cfg
}
const submit = async () => {
@@ -83,17 +107,20 @@ export function CreateDeploymentForm({
setError("")
try {
setBusy("Saving…")
await apiClient.put(`/config/${kind}s/${name}`, { config: buildConfig() })
await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() })
setBusy("Deploying…")
await apiClient.post(`/deploy`, { name })
if (kind === "service") {
setBusy("Starting…")
await apiClient.post(`/services/${name}/start`, {})
}
qc.invalidateQueries({ queryKey: [`${kind}s`] })
qc.invalidateQueries({ queryKey: ["services"] })
qc.invalidateQueries({ queryKey: ["jobs"] })
qc.invalidateQueries({ queryKey: ["programs"] })
qc.invalidateQueries({ queryKey: ["status"] })
navigate(kind === "service" ? `/services/${name}` : `/jobs/${name}`)
if (kind === "service") navigate(`/services/${name}`)
else if (kind === "job") navigate(`/jobs/${name}`)
else navigate(`/programs/${program || name}`)
} catch (e: unknown) {
let msg = e instanceof Error ? e.message : String(e)
try {
@@ -109,7 +136,7 @@ export function CreateDeploymentForm({
return (
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4 mt-2">
<div className="flex items-center justify-between">
<h3 className="font-semibold">New {kind}{program ? ` for ${program}` : ""}</h3>
<h3 className="font-semibold">New deployment{program ? ` for ${program}` : ""}</h3>
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
<X size={16} />
</button>
@@ -121,11 +148,31 @@ export function CreateDeploymentForm({
</div>
)}
{/* Kind picker */}
<Field label="Kind" hint={KIND_INFO[kind].hint}>
<div className="flex flex-wrap gap-1.5">
{(Object.keys(KIND_INFO) as DeploymentKind[]).map((k) => (
<button
key={k}
type="button"
onClick={() => setKind(k)}
className={`px-3 py-1 text-sm rounded border transition-colors ${
kind === k
? "bg-[var(--primary)] text-white border-[var(--primary)]"
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{KIND_INFO[k].label}
</button>
))}
</div>
</Field>
<Field label="Name">
<input
value={name}
onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder={kind === "service" ? "my-service" : "my-job"}
placeholder="my-deployment"
autoFocus
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
/>
@@ -134,37 +181,69 @@ export function CreateDeploymentForm({
<TextField label="Description" value={description} onChange={setDescription} />
<Field label="Runner">
<select value={runner} onChange={(e) => setRunner(e.target.value)} className={`w-40 ${SELECT}`}>
<option value="python">python</option>
<option value="command">command</option>
</select>
</Field>
<TextField
label="Runs"
value={runTarget}
onChange={setRunTarget}
mono
placeholder={runner === "command" ? "my-cmd --flag" : "console-script"}
/>
{/* Program ref — informational; tool/static require it, systemd optional. */}
{program && (
<Field label="Program">
<span className="text-sm font-mono text-[var(--muted)]">{program}</span>
</Field>
)}
{kind === "service" ? (
{isSystemd && (
<>
<Field label="Launcher">
<select value={launcher} onChange={(e) => setLauncher(e.target.value)} className={`w-40 ${SELECT}`}>
<option value="python">python</option>
<option value="command">command</option>
</select>
</Field>
<TextField
label="Runs"
value={runTarget}
onChange={setRunTarget}
mono
placeholder={launcher === "command" ? "my-cmd --flag" : "console-script"}
/>
</>
)}
{kind === "service" && (
<>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono />
<Field label="Expose" hint="Route through the gateway at <name>.<gateway.domain>. Off: reachable only at host:port.">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={expose} onChange={(e) => setExpose(e.target.checked)} />
<input type="checkbox" checked={proxy} onChange={(e) => setProxy(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">
{expose ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"}
{proxy ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"}
</span>
</label>
</Field>
{proxy && (
<Field label="Public" hint="Also publish to the internet via the Cloudflare tunnel at <name>.<gateway.public_domain>.">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
</label>
</Field>
)}
</>
) : (
)}
{kind === "job" && (
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />
)}
{kind === "static" && (
<TextField
label="Root"
value={root}
onChange={setRoot}
width="w-48"
mono
placeholder="dist"
/>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]">
Cancel
@@ -174,7 +253,7 @@ export function CreateDeploymentForm({
disabled={!name || !!nameError || !!busy}
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
>
{busy ?? `Create ${kind}`}
{busy ?? `Create ${KIND_INFO[kind].label.toLowerCase()}`}
</button>
</div>
</div>

View File

@@ -5,27 +5,26 @@ import type { ProgramDetail } from "@/types"
import { useServices, useJobs } from "@/services/api/hooks"
import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm"
/** The services and jobs that deploy a program. A program → 0-N services and
* 0-N jobs; these are convenience links, not ownership (a deployment can run
* anything, program-backed or not). The Create buttons just prefill the
* standalone create form with sensible values. */
/** The services and jobs that deploy a program. A program → 0-N deployments;
* these are convenience links, not ownership (a deployment can run anything,
* program-backed or not). The Create button prefills the kind-aware wizard. */
export function DeploymentsSection({ program }: { program: ProgramDetail }) {
const { services, jobs, behavior } = program
const { services, jobs, kind } = program
const none = services.length === 0 && jobs.length === 0
const [creating, setCreating] = useState<"service" | "job" | null>(null)
const [creating, setCreating] = useState(false)
const { data: allServices } = useServices()
const { data: allJobs } = useJobs()
const existing =
creating === "service"
? (allServices ?? []).map((s) => s.id)
: (allJobs ?? []).map((j) => j.id)
const existing = [
...(allServices ?? []).map((s) => s.id),
...(allJobs ?? []).map((j) => j.id),
]
const prefill: CreatePrefill = {
name: program.id,
program: program.id,
runTarget: program.id,
runner: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
launcher: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
}
return (
@@ -34,20 +33,12 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
Deployments
</h2>
<div className="flex gap-2">
<button
onClick={() => setCreating(creating === "service" ? null : "service")}
className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline"
>
<Plus size={12} /> Create service
</button>
<button
onClick={() => setCreating(creating === "job" ? null : "job")}
className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline"
>
<Plus size={12} /> Create job
</button>
</div>
<button
onClick={() => setCreating((c) => !c)}
className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline"
>
<Plus size={12} /> Add deployment
</button>
</div>
<p className="text-xs text-[var(--muted)] mb-4">
Services and jobs that run this program.
@@ -55,19 +46,18 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
{creating && (
<CreateDeploymentForm
kind={creating}
prefill={prefill}
existingNames={existing}
onCancel={() => setCreating(null)}
onCancel={() => setCreating(false)}
/>
)}
{none && !creating ? (
<p className="text-sm text-[var(--muted)]">
{behavior === "daemon"
? "No service yet — this daemon isn't deployed."
: behavior === "tool"
? "Not scheduled — add a job to run it on a timer."
{kind === "service"
? "No service yet — this program isn't deployed."
: kind === "tool"
? "Installed on PATH. Add a job to also run it on a timer."
: "None."}
</p>
) : (

View File

@@ -1,19 +1,19 @@
import { Link } from "react-router-dom"
import { ArrowLeft } from "lucide-react"
import { BehaviorBadge } from "@/components/BehaviorBadge"
import { KindBadge } from "@/components/KindBadge"
import { StackBadge } from "@/components/StackBadge"
interface DetailHeaderProps {
backTo: string
backLabel: string
name: string
behavior?: string | null
kind?: string | null
stack?: string | null
source?: string | null
children?: React.ReactNode
}
export function DetailHeader({ backTo, backLabel, name, behavior, stack, source, children }: DetailHeaderProps) {
export function DetailHeader({ backTo, backLabel, name, kind, stack, source, children }: DetailHeaderProps) {
return (
<>
<Link to={backTo} className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6">
@@ -26,7 +26,7 @@ export function DetailHeader({ backTo, backLabel, name, behavior, stack, source,
</div>
<div className="flex items-center gap-3 flex-wrap mb-6">
<BehaviorBadge behavior={behavior ?? null} />
<KindBadge kind={kind ?? null} />
<StackBadge stack={stack ?? null} />
{source && (
<span className="text-sm text-[var(--muted)] font-mono break-all min-w-0">{source}</span>

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import type { JobDetail } from "@/types"
import { runnerLabel } from "@/lib/labels"
import { launcherLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
@@ -27,7 +27,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const runner = (run.runner as string) ?? "?"
const launcher = (run.launcher as string) ?? "?"
const handleSave = async () => {
setSaving(true)
@@ -39,8 +39,8 @@ export function JobFields({ job, onSave, onDelete }: Props) {
config.schedule = schedule || undefined
const runOut = obj(config.run)
if (runner === "command") runOut.argv = runTarget.split(" ").filter(Boolean)
else if (runner === "python") runOut.program = runTarget
if (launcher === "command") runOut.argv = runTarget.split(" ").filter(Boolean)
else if (launcher === "python") runOut.program = runTarget
config.run = runOut
const env = merged()
@@ -68,7 +68,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
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)]">{runnerLabel(runner)} &middot; </span>
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} &middot; </span>
<input
value={runTarget}
onChange={(e) => setRunTarget(e.target.value)}

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { runnerLabel } from "@/lib/labels"
import { launcherLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
@@ -33,7 +33,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const runner = (run.runner as string) ?? "?"
const launcher = (run.launcher as string) ?? "?"
const handleSave = async () => {
setSaving(true)
@@ -43,11 +43,11 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
delete config.id
config.description = description || undefined
// Only python/command run specs are edited here; other runners
// (container/node/remote) keep their original run block untouched.
// Only python/command launchers are edited here; other launchers
// (container/node/compose) keep their original run block untouched.
const runOut = obj(config.run)
if (runner === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
else if (runner === "python") runOut.program = runProgram
if (launcher === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
else if (launcher === "python") runOut.program = runProgram
config.run = runOut
if (port) {
@@ -83,7 +83,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
label="Runs"
hint="The console script (python runner) or command this service executes."
>
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} &middot; </span>
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} &middot; </span>
<input
value={runProgram}
onChange={(e) => setRunProgram(e.target.value)}

View File

@@ -1,21 +1,26 @@
export const RUNNER_LABELS: Record<string, string> = {
export const LAUNCHER_LABELS: Record<string, string> = {
python: "Python",
command: "Command",
container: "Container",
compose: "Compose",
node: "Node.js",
remote: "Remote",
}
export const BEHAVIOR_LABELS: Record<string, string> = {
daemon: "Daemon",
// Derived deployment kinds (service | job | tool | static | reference).
export const KIND_LABELS: Record<string, string> = {
service: "Service",
job: "Job",
tool: "Tool",
frontend: "Frontend",
static: "Static",
reference: "Reference",
}
export const BEHAVIOR_DESCRIPTIONS: Record<string, string> = {
daemon: "Long-running process that exposes ports",
tool: "CLI utility or scheduled task",
frontend: "Built web application",
export const KIND_DESCRIPTIONS: Record<string, string> = {
service: "Long-running process (systemd)",
job: "Scheduled task (timer)",
tool: "CLI installed on PATH",
static: "Static site served by the gateway",
reference: "External service on another node",
}
export const STACK_LABELS: Record<string, string> = {
@@ -31,12 +36,12 @@ export const STACK_LABELS: Record<string, string> = {
remote: "Remote",
}
export function runnerLabel(runner: string): string {
return RUNNER_LABELS[runner] ?? runner
export function launcherLabel(launcher: string): string {
return LAUNCHER_LABELS[launcher] ?? launcher
}
export function behaviorLabel(behavior: string): string {
return BEHAVIOR_LABELS[behavior] ?? behavior
export function kindLabel(kind: string): string {
return KIND_LABELS[kind] ?? kind
}
export function stackLabel(stack: string): string {

View File

@@ -1,7 +1,7 @@
import { useParams, Link } from "react-router-dom"
import { ArrowLeft, Server } from "lucide-react"
import { useNode } from "@/services/api/hooks"
import { BehaviorBadge } from "@/components/BehaviorBadge"
import { KindBadge } from "@/components/KindBadge"
import { StackBadge } from "@/components/StackBadge"
import { cn } from "@/lib/utils"
@@ -63,9 +63,9 @@ export function NodeDetailPage() {
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<th className="px-3 py-2 font-medium text-[var(--muted)]">Deployment</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Behavior</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Kind</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Manager</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
</tr>
</thead>
@@ -87,13 +87,13 @@ export function NodeDetailPage() {
)}
</td>
<td className="px-3 py-2.5">
<BehaviorBadge behavior={comp.behavior} />
<KindBadge kind={comp.kind} />
</td>
<td className="px-3 py-2.5">
<StackBadge stack={comp.stack} />
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{comp.runner ?? "—"}
{comp.manager ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{comp.port ?? "—"}

View File

@@ -1,7 +1,7 @@
import { useState } from "react"
import { useParams } from "react-router-dom"
import { useProgram } from "@/services/api/hooks"
import { runnerLabel, subdomainUrl } from "@/lib/labels"
import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { DeploymentsSection } from "@/components/detail/DeploymentsSection"
@@ -27,11 +27,11 @@ export function ProgramDetailPage() {
)
}
// A static frontend (frontend behavior, build outputs, no service) is served by
// the gateway in place at its own subdomain — show where.
// A static (caddy) deployment with build outputs is served by the gateway in
// place at its own subdomain — show where.
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
const servedAt =
deployment.behavior === "frontend" && deployment.services.length === 0 && buildOutputs?.length
deployment.kind === "static" && buildOutputs?.length
? (subdomainUrl(deployment.id) ?? `${deployment.id}.<gateway.domain>`)
: null
@@ -41,7 +41,7 @@ export function ProgramDetailPage() {
backTo="/programs"
backLabel="Back to Programs"
name={deployment.id}
behavior={deployment.behavior}
kind={deployment.kind}
stack={deployment.stack}
source={deployment.source}
>
@@ -49,8 +49,7 @@ export function ProgramDetailPage() {
name={deployment.id}
actions={deployment.actions}
active={deployment.active}
behavior={deployment.behavior}
deployedAs={[...deployment.services, ...deployment.jobs]}
kind={deployment.kind}
onOutput={setActionOutput}
/>
</DetailHeader>
@@ -96,8 +95,8 @@ export function ProgramDetailPage() {
)}
{deployment.runner && (
<>
<span className="text-[var(--muted)]">Runner</span>
<span>{runnerLabel(deployment.runner)}</span>
<span className="text-[var(--muted)]">Launcher</span>
<span>{launcherLabel(deployment.runner)}</span>
</>
)}
{deployment.active !== null && (

View File

@@ -34,6 +34,7 @@ export function ScheduledDetailPage() {
backTo="/scheduled"
backLabel="Back to Jobs"
name={deployment.id}
kind="job"
stack={deployment.stack}
source={deployment.source}
>

View File

@@ -1,7 +1,7 @@
import { useParams, Link } from "react-router-dom"
import { Server, ExternalLink, Terminal } from "lucide-react"
import { useService, useStatus, useCaddyfile } from "@/services/api/hooks"
import { runnerLabel, subdomainUrl } from "@/lib/labels"
import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "@/components/HealthBadge"
import { LogViewer } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader"
@@ -38,7 +38,7 @@ export function ServiceDetailPage() {
backTo="/services"
backLabel="Back to Services"
name={deployment.id}
behavior="daemon"
kind="service"
stack={deployment.stack}
source={deployment.source}
>
@@ -78,12 +78,12 @@ export function ServiceDetailPage() {
</a>
</>
)}
{deployment.runner && (
{deployment.launcher && (
<>
<span className="text-[var(--muted)]">Runs</span>
<span className="flex items-center gap-1 min-w-0">
<Terminal size={12} className="shrink-0" />
{runnerLabel(deployment.runner)}
{launcherLabel(deployment.launcher)}
{deployment.run_target && <> &middot; <span className="font-mono break-all">{deployment.run_target}</span></>}
</span>
</>

View File

@@ -68,10 +68,10 @@ export function useJob(name: string) {
})
}
export function usePrograms(behavior?: string) {
const params = behavior ? `?behavior=${behavior}` : ""
export function usePrograms(kind?: string) {
const params = kind ? `?kind=${kind}` : ""
return useQuery({
queryKey: ["programs", behavior ?? "all"],
queryKey: ["programs", kind ?? "all"],
queryFn: () => apiClient.get<ProgramSummary[]>(`/programs${params}`),
})
}

View File

@@ -8,7 +8,8 @@ export interface ServiceSummary {
id: string
description: string | null
stack: string | null
runner: string | null
manager?: string | null // systemd for a service
launcher: string | null // python | command | container | compose | node
run_target: string | null
port: number | null
health_path: string | null
@@ -28,7 +29,7 @@ export interface JobSummary {
id: string
description: string | null
stack: string | null
runner: string | null
launcher: string | null // python | command | container | compose | node
run_target: string | null
schedule: string | null
managed: boolean
@@ -45,9 +46,9 @@ export interface JobDetail extends JobSummary {
export interface ProgramSummary {
id: string
description: string | null
behavior: string | null
kind: string | null // derived: service | job | tool | static | reference
stack: string | null
runner: string | null
runner: string | null // inferred launch hint (python | command)
version: string | null
source: string | null
repo: string | null
@@ -74,9 +75,10 @@ export interface DeploymentSummary {
id: string
category: "program" | "service" | "job" | null
description: string | null
behavior: string | null
kind: string | null // derived: service | job | tool | static | reference
stack: string | null
runner: string | null
manager: string | null // systemd | caddy | path | none
launcher: string | null // python | command | container | compose | node (systemd only)
port: number | null
health_path: string | null
subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null