Stage 4: App — create service/job forms (UI twin of castle expose)

CreateDeploymentForm builds a service or job in castle.yaml (PUT /config/...),
then deploys it (POST /deploy {name} → unit + Caddyfile + reload) and, for a
service, starts it (POST /services/{name}/start), then navigates to the new
detail page.

Reachable two ways:
- Program page Deployments section: 'Create service' / 'Create job' buttons,
  prefilled from the program (name, program ref, run target, runner).
- Dashboard: standalone '+ Add service' / '+ Add job' (deployments can run
  anything, not just castle programs).

Service form covers port / port_env / health / proxy path / proxy host; job
form covers schedule. Name validated against existing services/jobs.

Verified: the configs the form builds validate (PUT 200) and round-trip clean.
App builds; api 52 / core 94 green from prior stages.
This commit is contained in:
2026-06-14 15:41:13 -07:00
parent dea585b15b
commit 2a073fc0b4
3 changed files with 268 additions and 6 deletions

View File

@@ -0,0 +1,186 @@
import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import { X } from "lucide-react"
import { apiClient } from "@/services/api/client"
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 interface CreatePrefill {
name?: string
program?: string
runTarget?: string
runner?: 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. */
export function CreateDeploymentForm({
kind,
prefill,
existingNames,
onCancel,
}: {
kind: "service" | "job"
prefill?: CreatePrefill
existingNames: string[]
onCancel: () => void
}) {
const qc = useQueryClient()
const navigate = useNavigate()
const [name, setName] = useState(prefill?.name ?? "")
const [program] = useState(prefill?.program ?? "")
const [description, setDescription] = useState("")
const [runner, setRunner] = useState(prefill?.runner ?? "python")
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
const [port, setPort] = useState("")
const [portEnv, setPortEnv] = useState("")
const [health, setHealth] = useState("/health")
const [path, setPath] = useState("")
const [host, setHost] = useState("")
const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState("")
const nameError =
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
? "lowercase letters, numbers, hyphens"
: existingNames.includes(name)
? "already exists"
: ""
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,
manage: { systemd: {} },
}
if (kind === "job") {
base.schedule = schedule
return base
}
if (port) {
base.expose = {
http: {
internal: { port: parseInt(port, 10), ...(portEnv ? { port_env: portEnv } : {}) },
...(health ? { health_path: health } : {}),
},
}
}
if (path || host) {
base.proxy = {
caddy: {
...(path ? { path_prefix: path.startsWith("/") ? path : `/${path}` } : {}),
...(host ? { host } : {}),
},
}
}
return base
}
const submit = async () => {
if (!name || nameError) return
setError("")
try {
setBusy("Saving…")
await apiClient.put(`/config/${kind}s/${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: ["programs"] })
qc.invalidateQueries({ queryKey: ["status"] })
navigate(kind === "service" ? `/services/${name}` : `/jobs/${name}`)
} catch (e: unknown) {
let msg = e instanceof Error ? e.message : String(e)
try {
msg = JSON.parse((e as Error).message).detail ?? msg
} catch {
/* keep msg */
}
setError(msg)
setBusy(null)
}
}
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>
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
<X size={16} />
</button>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
{error}
</div>
)}
<Field label="Name">
<input
value={name}
onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder={kind === "service" ? "my-service" : "my-job"}
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)]"
/>
{nameError && <p className="text-xs text-red-400 mt-1">{nameError}</p>}
</Field>
<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"}
/>
{kind === "service" ? (
<>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
<TextField label="Port env" value={portEnv} onChange={setPortEnv} width="w-64" mono placeholder="(custom port var, optional)" />
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono />
<TextField label="Proxy path" value={path} onChange={setPath} width="w-48" mono placeholder={`/${name || "name"}`} />
<TextField label="Proxy host" value={host} onChange={setHost} mono placeholder="my-service.lan (optional)" />
</>
) : (
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />
)}
<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
</button>
<button
onClick={submit}
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}`}
</button>
</div>
</div>
)
}

View File

@@ -1,24 +1,68 @@
import { useState } from "react"
import { Link } from "react-router-dom"
import { Server, Clock } from "lucide-react"
import { Server, Clock, Plus } from "lucide-react"
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). */
* anything, program-backed or not). The Create buttons just prefill the
* standalone create form with sensible values. */
export function DeploymentsSection({ program }: { program: ProgramDetail }) {
const { services, jobs, behavior } = program
const none = services.length === 0 && jobs.length === 0
const [creating, setCreating] = useState<"service" | "job" | null>(null)
const { data: allServices } = useServices()
const { data: allJobs } = useJobs()
const existing =
creating === "service"
? (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",
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
<div className="flex items-center justify-between mb-1">
<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>
</div>
<p className="text-xs text-[var(--muted)] mb-4">
Services and jobs that run this program.
</p>
{none ? (
{creating && (
<CreateDeploymentForm
kind={creating}
prefill={prefill}
existingNames={existing}
onCancel={() => setCreating(null)}
/>
)}
{none && !creating ? (
<p className="text-sm text-[var(--muted)]">
{behavior === "daemon"
? "No service yet — this daemon isn't deployed."

View File

@@ -1,3 +1,5 @@
import { useState } from "react"
import { Plus } from "lucide-react"
import { useServices, useJobs, usePrograms, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
import { GatewayPanel } from "@/components/GatewayPanel"
import { MeshPanel } from "@/components/MeshPanel"
@@ -6,6 +8,7 @@ import { ServiceSection } from "@/components/ServiceSection"
import { ScheduledSection } from "@/components/ScheduledSection"
import { ProgramTable } from "@/components/ProgramTable"
import { SectionHeader } from "@/components/SectionHeader"
import { CreateDeploymentForm } from "@/components/detail/CreateDeploymentForm"
export function Dashboard() {
@@ -17,9 +20,14 @@ export function Dashboard() {
const { data: gateway } = useGateway()
const { data: nodes } = useNodes()
const { data: mesh } = useMeshStatus()
const [creating, setCreating] = useState<"service" | "job" | null>(null)
const statuses = statusResp?.statuses ?? []
const isLoading = loadingServices || loadingJobs || loadingPrograms
const existing =
creating === "service"
? (services ?? []).map((s) => s.id)
: (jobs ?? []).map((j) => j.id)
return (
<div className="max-w-6xl mx-auto px-6 py-8">
@@ -42,6 +50,30 @@ export function Dashboard() {
</div>
)}
<div className="flex items-center gap-2 mb-6">
<button
onClick={() => setCreating(creating === "service" ? null : "service")}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
>
<Plus size={14} /> Add service
</button>
<button
onClick={() => setCreating(creating === "job" ? null : "job")}
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
>
<Plus size={14} /> Add job
</button>
</div>
{creating && (
<div className="mb-6 max-w-2xl">
<CreateDeploymentForm
kind={creating}
existingNames={existing}
onCancel={() => setCreating(null)}
/>
</div>
)}
{isLoading ? (
<p className="text-[var(--muted)]">Loading...</p>
) : (