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:
125
app/src/components/detail/ServiceFields.tsx
Normal file
125
app/src/components/detail/ServiceFields.tsx
Normal 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)} · </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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user