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 { useQueryClient } from "@tanstack/react-query"
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
import type { AnyDetail } from "@/types"
|
import type { AnyDetail, ProgramDetail, ServiceDetail, JobDetail } from "@/types"
|
||||||
import { DeploymentFields } from "@/components/DeploymentFields"
|
import { ProgramFields } from "./ProgramFields"
|
||||||
|
import { ServiceFields } from "./ServiceFields"
|
||||||
|
import { JobFields } from "./JobFields"
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
deployment: AnyDetail
|
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">
|
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-4">
|
||||||
Configuration
|
Configuration
|
||||||
</h2>
|
</h2>
|
||||||
<DeploymentFields
|
{configSection === "programs" ? (
|
||||||
deployment={deployment}
|
<ProgramFields
|
||||||
onSave={handleSave}
|
program={deployment as ProgramDetail}
|
||||||
onDelete={handleDelete}
|
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>
|
</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>
|
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
|
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
|
||||||
|
|
||||||
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
|
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
|
||||||
|
|||||||
Reference in New Issue
Block a user