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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
27
app/src/components/KindBadge.tsx
Normal file
27
app/src/components/KindBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} · </span>
|
||||
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} · </span>
|
||||
<input
|
||||
value={runTarget}
|
||||
onChange={(e) => setRunTarget(e.target.value)}
|
||||
|
||||
@@ -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)} · </span>
|
||||
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} · </span>
|
||||
<input
|
||||
value={runProgram}
|
||||
onChange={(e) => setRunProgram(e.target.value)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? "—"}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ScheduledDetailPage() {
|
||||
backTo="/scheduled"
|
||||
backLabel="Back to Jobs"
|
||||
name={deployment.id}
|
||||
kind="job"
|
||||
stack={deployment.stack}
|
||||
source={deployment.source}
|
||||
>
|
||||
|
||||
@@ -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 && <> · <span className="font-mono break-all">{deployment.run_target}</span></>}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -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}`),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user