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

@@ -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>
)
}