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

@@ -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."