Stage 1: API — correct editable shape + program/deployment links

The config editor read service/job detail 'manifest' as the castle.yaml spec
but the endpoint returned the flat *runtime registry* shape for deployed
entries — so service fields (port/health/proxy) never prefilled and a save
would 422/corrupt. Detail 'manifest' is now always the editable castle.yaml
ServiceSpec/JobSpec when the entry is declared there; the flat registry shape
is a display-only fallback for entries that exist only at runtime.

Also:
- ServiceSummary/JobSummary gain run_target (what it runs) + program ref;
  ServiceSummary gains proxy_host.
- ProgramSummary gains services[]/jobs[] — the deployments referencing a
  program (a program → 0-N services/jobs), for linking + create prefill.
- App types updated to match; deleted orphaned DeploymentEditor/AddDeployment
  (leftovers from the removed ConfigEditor).

Verified live: service manifest nested (expose.http.internal.port, proxy.caddy
.host prefill), run_target=lakehoused, program lakehouse → services:[lakehouse].
api 52 green; ruff + app build clean.
This commit is contained in:
2026-06-14 15:30:32 -07:00
parent 4840cbe72a
commit 1c37380cec
6 changed files with 98 additions and 283 deletions

View File

@@ -1,189 +0,0 @@
import { useState } from "react"
import { Plus, X } from "lucide-react"
const TEMPLATES: Record<string, Record<string, unknown>> = {
service: {
run: {
runner: "python",
program: "",
cwd: "",
env: {},
},
expose: {
http: {
internal: { port: 9001 },
health_path: "/health",
},
},
proxy: {
caddy: { path_prefix: "/" },
},
manage: {
systemd: {},
},
},
tool: {
behavior: "tool",
},
job: {
run: {
runner: "command",
argv: [""],
cwd: "",
},
schedule: "0 2 * * *",
},
empty: {},
}
interface AddDeploymentProps {
onAdd: (name: string, config: Record<string, unknown>) => Promise<void>
existingNames: string[]
}
export function AddDeployment({ onAdd, existingNames }: AddDeploymentProps) {
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [template, setTemplate] = useState("service")
const [port, setPort] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState("")
const nameError =
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
? "lowercase letters, numbers, and hyphens"
: existingNames.includes(name)
? "already exists"
: ""
const handleSubmit = async () => {
if (!name || nameError) return
setSaving(true)
setError("")
try {
const config: Record<string, unknown> = JSON.parse(
JSON.stringify(TEMPLATES[template] ?? {})
)
if (description) config.description = description
// Fill in template-specific fields
if (template === "service") {
const run = config.run as Record<string, unknown>
run.program = name
run.cwd = name
const proxy = (config.proxy as Record<string, Record<string, string>>).caddy
proxy.path_prefix = `/${name}`
if (port) {
const expose = (config.expose as Record<string, Record<string, Record<string, number>>>)
expose.http.internal.port = parseInt(port, 10)
}
} else if (template === "tool") {
// tool template has behavior preset, no extra config needed
}
await onAdd(name, config)
setName("")
setDescription("")
setPort("")
setOpen(false)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="w-full flex items-center justify-center gap-2 p-4 border border-dashed border-[var(--border)] rounded-lg text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
>
<Plus size={16} /> Add entry
</button>
)
}
return (
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">New entry</h3>
<button onClick={() => setOpen(false)} 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>
)}
<div className="grid grid-cols-2 gap-4">
<Field label="Name">
<input
value={name}
onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder="my-service"
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>
<Field label="Template">
<select
value={template}
onChange={(e) => setTemplate(e.target.value)}
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
>
<option value="service">Service (FastAPI + systemd + Caddy)</option>
<option value="tool">Tool (PATH install)</option>
<option value="job">Job (scheduled task)</option>
<option value="empty">Empty</option>
</select>
</Field>
</div>
<Field label="Description">
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this entry do?"
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
/>
</Field>
{template === "service" && (
<Field label="Port">
<input
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="9001"
className="w-32 bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
/>
</Field>
)}
<div className="flex justify-end">
<button
onClick={handleSubmit}
disabled={!name || !!nameError || saving}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
>
<Plus size={14} /> Add
</button>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-sm font-medium mb-1">{label}</label>
{children}
</div>
)
}

View File

@@ -1,45 +0,0 @@
import { useState } from "react"
import { ChevronDown, ChevronRight } from "lucide-react"
import type { DeploymentDetail } from "@/types"
import { DeploymentFields } from "./DeploymentFields"
import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
interface DeploymentEditorProps {
deployment: DeploymentDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete: (name: string) => Promise<void>
}
export function DeploymentEditor({ deployment, onSave, onDelete }: DeploymentEditorProps) {
const [expanded, setExpanded] = useState(false)
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between p-4 hover:bg-white/5 transition-colors text-left"
>
<div className="flex items-center gap-3">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<span className="font-semibold">{deployment.id}</span>
<span className="text-sm text-[var(--muted)]">{deployment.description}</span>
</div>
<div className="flex items-center gap-1.5">
<BehaviorBadge behavior={deployment.behavior} />
<StackBadge stack={deployment.stack} />
</div>
</button>
{expanded && (
<div className="border-t border-[var(--border)] p-4">
<DeploymentFields
deployment={deployment}
onSave={onSave}
onDelete={onDelete}
/>
</div>
)}
</div>
)
}

View File

@@ -9,11 +9,14 @@ export interface ServiceSummary {
description: string | null
stack: string | null
runner: string | null
run_target: string | null
port: number | null
health_path: string | null
proxy_path: string | null
proxy_host: string | null
managed: boolean
systemd: SystemdInfo | null
program: string | null
source: string | null
node: string | null
}
@@ -27,9 +30,11 @@ export interface JobSummary {
description: string | null
stack: string | null
runner: string | null
run_target: string | null
schedule: string | null
managed: boolean
systemd: SystemdInfo | null
program: string | null
source: string | null
node: string | null
}
@@ -53,6 +58,8 @@ export interface ProgramSummary {
installed: boolean | null
active: boolean | null
actions: string[]
services: string[]
jobs: string[]
node: string | null
}