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:
@@ -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 && (
|
||||
<> · {(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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
80
app/src/components/detail/JobFields.tsx
Normal file
80
app/src/components/detail/JobFields.tsx
Normal 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)} · </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>
|
||||
)
|
||||
}
|
||||
101
app/src/components/detail/ProgramFields.tsx
Normal file
101
app/src/components/detail/ProgramFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
153
app/src/components/detail/fields.tsx
Normal file
153
app/src/components/detail/fields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export interface ProgramDetail extends ProgramSummary {
|
||||
manifest: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Union for shared detail components (ConfigPanel, DeploymentFields)
|
||||
// Union for the shared ConfigPanel (ProgramFields / ServiceFields / JobFields)
|
||||
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
|
||||
|
||||
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
|
||||
|
||||
Reference in New Issue
Block a user