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:
186
app/src/components/detail/CreateDeploymentForm.tsx
Normal file
186
app/src/components/detail/CreateDeploymentForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
Deployments
|
||||
</h2>
|
||||
<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."
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user