Stage 2: App — split the one editor into typed program/service/job forms

The single service-shaped DeploymentFields was reused for all three sections,
so the program page bled service fields (port/proxy/health) and couldn't edit
program config, and the job page couldn't edit its schedule. Replaced it with:

- ProgramFields  — description, source, stack, behavior, version, repo/ref,
  system deps (+ read-only commands). Edits the program catalog entry.
- ServiceFields  — run target, port, port_env, health, proxy path + host,
  env/secrets. Prefills correctly now (Stage 1) and round-trips through PUT.
- JobFields      — schedule, run target, env/secrets.

ConfigPanel selects the form by section. Shared Field/TextField/useEnvSecrets/
FormFooter helpers in detail/fields.tsx. Retired DeploymentFields. run editing
only touches python/command specs (container/node/remote run blocks preserved).

Verified: GET service manifest → PUT back validates 200. App type-checks/builds.
This commit is contained in:
2026-06-14 15:34:07 -07:00
parent 1c37380cec
commit 73698fafe7
7 changed files with 483 additions and 265 deletions

View File

@@ -1,257 +0,0 @@
import { useMemo, useState } from "react"
import { Check, Loader2, Save, Trash2 } from "lucide-react"
import type { AnyDetail } from "@/types"
import { runnerLabel } from "@/lib/labels"
import { SecretsEditor } from "./SecretsEditor"
interface DeploymentFieldsProps {
deployment: AnyDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete?: (name: string) => Promise<void>
}
const SECRET_RE = /^\$\{secret:([^}]+)\}$/
export function DeploymentFields({ deployment, onSave, onDelete }: DeploymentFieldsProps) {
const m = deployment.manifest
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const allEnv: Record<string, string> =
((m.run as Record<string, unknown>)?.env as Record<string, string>) ?? {}
// Split into plain env vars and secret references
const { initialEnv, initialSecrets } = useMemo(() => {
const env: Record<string, string> = {}
const secrets: Record<string, string> = {}
for (const [key, val] of Object.entries(allEnv)) {
const match = SECRET_RE.exec(val)
if (match) {
secrets[key] = match[1]
} else {
env[key] = val
}
}
return { initialEnv: env, initialSecrets: secrets }
}, [])
const [runEnv, setRunEnv] = useState<Record<string, string>>(initialEnv)
const [secrets, setSecrets] = useState<Record<string, string>>(initialSecrets)
const [description, setDescription] = useState(m.description as string ?? "")
const [port, setPort] = useState(
String(
((m.expose as Record<string, unknown>)?.http as Record<string, unknown>)
?.internal as Record<string, unknown>
? (((m.expose as Record<string, unknown>)?.http as Record<string, unknown>)
?.internal as Record<string, number>)?.port ?? ""
: ""
)
)
const [proxyPath, setProxyPath] = useState(
((m.proxy as Record<string, unknown>)?.caddy as Record<string, string>)?.path_prefix ?? ""
)
const [healthPath, setHealthPath] = useState(
((m.expose as Record<string, unknown>)?.http as Record<string, string>)?.health_path ?? ""
)
const runner = (m.run as Record<string, unknown>)?.runner as string | undefined
const handleSave = async () => {
setSaving(true)
setSaved(false)
try {
const config: Record<string, unknown> = { ...m }
delete config.id
delete config.behavior
config.description = description || undefined
// Merge plain env + secret references back together
if (config.run && typeof config.run === "object") {
const mergedEnv: Record<string, string> = { ...runEnv }
for (const [envKey, secretName] of Object.entries(secrets)) {
mergedEnv[envKey] = `\${secret:${secretName}}`
}
config.run = { ...config.run as Record<string, unknown>, env: mergedEnv }
}
if (port) {
const portNum = parseInt(port, 10)
if (!isNaN(portNum)) {
config.expose = {
http: {
internal: { port: portNum },
...(healthPath ? { health_path: healthPath } : {}),
},
}
}
}
if (proxyPath) {
config.proxy = { caddy: { path_prefix: proxyPath } }
} else {
delete config.proxy
}
await onSave(deployment.id, config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
<Field label="Description">
<input
value={description}
onChange={(e) => setDescription(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)]"
/>
</Field>
{runner && (
<Field label="Runner">
<span className="text-sm font-mono text-[var(--muted)]">
{runnerLabel(runner)}
{(m.run as Record<string, string>)?.program && (
<> &middot; {(m.run as Record<string, string>).tool}</>
)}
</span>
</Field>
)}
{("managed" in deployment && deployment.managed || port) && (
<Field label="Port">
<input
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="e.g. 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>
)}
{("managed" in deployment && deployment.managed || healthPath) && (
<Field label="Health path">
<input
value={healthPath}
onChange={(e) => setHealthPath(e.target.value)}
placeholder="/health"
className="w-48 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>
)}
<Field label="Proxy path">
<input
value={proxyPath}
onChange={(e) => setProxyPath(e.target.value)}
placeholder="/my-service"
className="w-48 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>
{runner && (
<Field label="Environment">
<div className="space-y-2">
{Object.entries(runEnv).map(([key, val]) => (
<div key={key} className="flex items-center gap-2">
<input
value={key}
readOnly
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono text-[var(--muted)]"
/>
<span className="text-[var(--muted)]">=</span>
<input
value={val}
onChange={(e) =>
setRunEnv((prev) => ({ ...prev, [key]: e.target.value }))
}
className="flex-1 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
<button
onClick={() =>
setRunEnv((prev) => {
const next = { ...prev }
delete next[key]
return next
})
}
className="text-red-400 hover:text-red-300 p-0.5"
title="Remove"
>
<Trash2 size={12} />
</button>
</div>
))}
<button
onClick={() => {
const key = prompt("Variable name:")
if (key) setRunEnv((prev) => ({ ...prev, [key]: "" }))
}}
className="text-xs text-[var(--primary)] hover:underline"
>
+ Add variable
</button>
</div>
</Field>
)}
{runner && (
<Field label="Secrets">
<SecretsEditor secrets={secrets} onSecretsChange={setSecrets} />
</Field>
)}
{"managed" in deployment && (
<Field label="Systemd">
<span className="text-sm text-[var(--muted)]">
{deployment.managed ? "Yes" : "No"}
</span>
</Field>
)}
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
{onDelete ? (
<button
onClick={() => {
if (confirm(`Delete deployment "${deployment.id}" from castle.yaml?`)) {
onDelete(deployment.id)
}
}}
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
>
<Trash2 size={12} /> Remove deployment
</button>
) : (
<div />
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40"
>
{saving ? (
<Loader2 size={14} className="animate-spin" />
) : saved ? (
<Check size={14} />
) : (
<Save size={14} />
)}
{saved ? "Saved" : "Save"}
</button>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-4">
<label className="w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label>
<div className="flex-1">{children}</div>
</div>
)
}

View File

@@ -3,8 +3,10 @@ import { useNavigate } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { Check } from "lucide-react"
import { apiClient } from "@/services/api/client"
import type { AnyDetail } from "@/types"
import { DeploymentFields } from "@/components/DeploymentFields"
import type { AnyDetail, ProgramDetail, ServiceDetail, JobDetail } from "@/types"
import { ProgramFields } from "./ProgramFields"
import { ServiceFields } from "./ServiceFields"
import { JobFields } from "./JobFields"
interface ConfigPanelProps {
deployment: AnyDetail
@@ -61,11 +63,25 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-4">
Configuration
</h2>
<DeploymentFields
deployment={deployment}
onSave={handleSave}
onDelete={handleDelete}
/>
{configSection === "programs" ? (
<ProgramFields
program={deployment as ProgramDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
) : configSection === "services" ? (
<ServiceFields
service={deployment as ServiceDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
) : (
<JobFields
job={deployment as JobDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
)}
</div>
</>
)

View File

@@ -0,0 +1,80 @@
import { useState } from "react"
import type { JobDetail } from "@/types"
import { runnerLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
job: JobDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete?: (name: string) => Promise<void>
}
type Obj = Record<string, unknown>
const obj = (v: unknown): Obj => (v as Obj) ?? {}
/** Edit a job's deployment config (schedule / run / env). */
export function JobFields({ job, onSave, onDelete }: Props) {
const m = job.manifest
const run = obj(m.run)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [description, setDescription] = useState((m.description as string) ?? "")
const [schedule, setSchedule] = useState((m.schedule as string) ?? "")
const [runTarget, setRunTarget] = useState(
(run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const runner = (run.runner as string) ?? "?"
const handleSave = async () => {
setSaving(true)
setSaved(false)
try {
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
delete config.id
config.description = description || undefined
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
config.run = runOut
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).env
await onSave(job.id, config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
<TextField label="Description" value={description} onChange={setDescription} />
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />
<Field label="Runs">
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} &middot; </span>
<input
value={runTarget}
onChange={(e) => setRunTarget(e.target.value)}
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
</Field>
{envEditor}
<FormFooter
saving={saving}
saved={saved}
onSave={handleSave}
onDelete={onDelete ? () => onDelete(job.id) : undefined}
deleteLabel="Remove job"
/>
</div>
)
}

View File

@@ -0,0 +1,101 @@
import { useState } from "react"
import type { ProgramDetail } from "@/types"
import { Field, TextField, FormFooter } 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)]"
interface Props {
program: ProgramDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete?: (name: string) => Promise<void>
}
/** Edit a program's catalog config (source identity), not how it's deployed. */
export function ProgramFields({ program, onSave, onDelete }: Props) {
const m = program.manifest
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [description, setDescription] = useState((m.description as string) ?? "")
const [source, setSource] = useState((m.source as string) ?? "")
const [behavior, setBehavior] = useState((m.behavior as string) ?? "")
const [stack, setStack] = useState((m.stack as string) ?? "")
const [version, setVersion] = useState((m.version as string) ?? "")
const [repo, setRepo] = useState((m.repo as string) ?? "")
const [ref, setRef] = useState((m.ref as string) ?? "")
const [deps, setDeps] = useState(((m.system_dependencies as string[]) ?? []).join(", "))
const handleSave = async () => {
setSaving(true)
setSaved(false)
try {
const config: Record<string, unknown> = { ...m }
delete config.id
config.description = description || undefined
config.source = source || undefined
config.behavior = behavior || undefined
config.stack = stack || undefined
config.version = version || undefined
config.repo = repo || undefined
config.ref = ref || undefined
config.system_dependencies = deps
.split(",")
.map((d) => d.trim())
.filter(Boolean)
await onSave(program.id, config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
<TextField label="Description" value={description} onChange={setDescription} />
<TextField label="Source" value={source} onChange={setSource} mono placeholder="/data/repos/my-prog" />
<Field label="Behavior">
<select value={behavior} onChange={(e) => setBehavior(e.target.value)} className={`w-48 ${SELECT}`}>
<option value="">(none)</option>
<option value="tool">tool</option>
<option value="daemon">daemon</option>
<option value="frontend">frontend</option>
</select>
</Field>
<Field label="Stack">
<select value={stack} onChange={(e) => setStack(e.target.value)} className={`w-48 ${SELECT}`}>
<option value="">(none)</option>
<option value="python-cli">python-cli</option>
<option value="python-fastapi">python-fastapi</option>
<option value="react-vite">react-vite</option>
</select>
</Field>
<TextField label="Version" value={version} onChange={setVersion} width="w-32" />
<TextField label="Repo" value={repo} onChange={setRepo} mono placeholder="https://github.com/me/x.git" />
<TextField label="Ref" value={ref} onChange={setRef} width="w-48" placeholder="branch / tag / commit" />
<TextField label="System deps" value={deps} onChange={setDeps} placeholder="pandoc, poppler-utils" />
{program.commands && Object.keys(program.commands).length > 0 && (
<Field label="Commands">
<div className="space-y-1 pt-1.5">
{Object.entries(program.commands).map(([verb, cmds]) => (
<div key={verb} className="flex gap-2 text-xs">
<span className="text-[var(--muted)] w-20 shrink-0">{verb}</span>
<span className="font-mono break-all">{cmds.map((a) => a.join(" ")).join(" && ")}</span>
</div>
))}
</div>
</Field>
)}
<FormFooter
saving={saving}
saved={saved}
onSave={handleSave}
onDelete={onDelete ? () => onDelete(program.id) : undefined}
deleteLabel="Remove program"
/>
</div>
)
}

View File

@@ -0,0 +1,125 @@
import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { runnerLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
service: ServiceDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete?: (name: string) => Promise<void>
}
type Obj = Record<string, unknown>
const obj = (v: unknown): Obj => (v as Obj) ?? {}
/** Edit a service's deployment config (run / expose / proxy / env). */
export function ServiceFields({ service, onSave, onDelete }: Props) {
const m = service.manifest
const run = obj(m.run)
const internal = obj(obj(obj(m.expose).http).internal)
const httpExpose = obj(obj(m.expose).http)
const caddy = obj(obj(m.proxy).caddy)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [description, setDescription] = useState((m.description as string) ?? "")
const [runProgram, setRunProgram] = useState(
(run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
)
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
const [portEnv, setPortEnv] = useState((internal.port_env as string) ?? "")
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
const [proxyPath, setProxyPath] = useState((caddy.path_prefix as string) ?? "")
const [proxyHost, setProxyHost] = useState((caddy.host as string) ?? "")
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const runner = (run.runner as string) ?? "?"
const handleSave = async () => {
setSaving(true)
setSaved(false)
try {
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
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.
const runOut = obj(config.run)
if (runner === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
else if (runner === "python") runOut.program = runProgram
config.run = runOut
if (port) {
config.expose = {
http: {
internal: {
port: parseInt(port, 10),
...(portEnv ? { port_env: portEnv } : {}),
},
...(health ? { health_path: health } : {}),
},
}
} else {
delete config.expose
}
if (proxyPath || proxyHost) {
config.proxy = {
caddy: {
...(proxyPath ? { path_prefix: proxyPath } : {}),
...(proxyHost ? { host: proxyHost } : {}),
},
}
} else {
delete config.proxy
}
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).env
await onSave(service.id, config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
<TextField label="Description" value={description} onChange={setDescription} />
<Field label="Runs">
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} &middot; </span>
<input
value={runProgram}
onChange={(e) => setRunProgram(e.target.value)}
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/>
</Field>
<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="(only if the program reads a custom var)"
/>
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono placeholder="/health" />
<TextField label="Proxy path" value={proxyPath} onChange={setProxyPath} width="w-48" mono placeholder="/my-service" />
<TextField label="Proxy host" value={proxyHost} onChange={setProxyHost} mono placeholder="my-service.lan" />
{envEditor}
<FormFooter
saving={saving}
saved={saved}
onSave={handleSave}
onDelete={onDelete ? () => onDelete(service.id) : undefined}
deleteLabel="Remove service"
/>
</div>
)
}

View File

@@ -0,0 +1,153 @@
import { useMemo, useState } from "react"
import { Trash2 } from "lucide-react"
import { SecretsEditor } from "@/components/SecretsEditor"
const INPUT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-4">
<label className="w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label>
<div className="flex-1">{children}</div>
</div>
)
}
export function TextField({
label,
value,
onChange,
placeholder,
mono,
width,
}: {
label: string
value: string
onChange: (v: string) => void
placeholder?: string
mono?: boolean
width?: string
}) {
return (
<Field label={label}>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`${width ?? "w-full"} ${INPUT} ${mono ? "font-mono" : ""}`}
/>
</Field>
)
}
const SECRET_RE = /^\$\{secret:([^}]+)\}$/
/** Hook for editing a run env that mixes plain vars and `${secret:NAME}` refs.
* Returns the editor element plus a `merged()` that reconstitutes the env. */
export function useEnvSecrets(initial: Record<string, string>) {
const { plain, secretRefs } = useMemo(() => {
const p: Record<string, string> = {}
const s: Record<string, string> = {}
for (const [k, v] of Object.entries(initial)) {
const m = SECRET_RE.exec(v)
if (m) s[k] = m[1]
else p[k] = v
}
return { plain: p, secretRefs: s }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [env, setEnv] = useState<Record<string, string>>(plain)
const [secrets, setSecrets] = useState<Record<string, string>>(secretRefs)
const merged = (): Record<string, string> => {
const out: Record<string, string> = { ...env }
for (const [k, name] of Object.entries(secrets)) out[k] = `\${secret:${name}}`
return out
}
const element = (
<div className="space-y-4">
<Field label="Environment">
<div className="space-y-2">
{Object.entries(env).map(([key, val]) => (
<div key={key} className="flex items-center gap-2">
<input value={key} readOnly className={`w-56 ${INPUT} text-xs text-[var(--muted)]`} />
<span className="text-[var(--muted)]">=</span>
<input
value={val}
onChange={(e) => setEnv((p) => ({ ...p, [key]: e.target.value }))}
className={`flex-1 ${INPUT} text-xs font-mono`}
/>
<button
onClick={() =>
setEnv((p) => {
const n = { ...p }
delete n[key]
return n
})
}
className="text-red-400 hover:text-red-300 p-0.5"
title="Remove"
>
<Trash2 size={12} />
</button>
</div>
))}
<button
onClick={() => {
const key = prompt("Variable name:")
if (key) setEnv((p) => ({ ...p, [key]: "" }))
}}
className="text-xs text-[var(--primary)] hover:underline"
>
+ Add variable
</button>
</div>
</Field>
<Field label="Secrets">
<SecretsEditor secrets={secrets} onSecretsChange={setSecrets} />
</Field>
</div>
)
return { element, merged }
}
/** Save/Delete footer shared by the typed config forms. */
export function FormFooter({
saving,
saved,
onSave,
onDelete,
deleteLabel,
}: {
saving: boolean
saved: boolean
onSave: () => void
onDelete?: () => void
deleteLabel: string
}) {
return (
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
{onDelete ? (
<button
onClick={onDelete}
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
>
<Trash2 size={12} /> {deleteLabel}
</button>
) : (
<div />
)}
<button
onClick={onSave}
disabled={saving}
className="px-3 py-1.5 text-sm rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40"
>
{saving ? "Saving…" : saved ? "Saved" : "Save"}
</button>
</div>
)
}