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