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