diff --git a/app/src/components/DeploymentFields.tsx b/app/src/components/DeploymentFields.tsx deleted file mode 100644 index 2349bc7..0000000 --- a/app/src/components/DeploymentFields.tsx +++ /dev/null @@ -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) => Promise - onDelete?: (name: string) => Promise -} - -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 = - ((m.run as Record)?.env as Record) ?? {} - - // Split into plain env vars and secret references - const { initialEnv, initialSecrets } = useMemo(() => { - const env: Record = {} - const secrets: Record = {} - 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>(initialEnv) - const [secrets, setSecrets] = useState>(initialSecrets) - - const [description, setDescription] = useState(m.description as string ?? "") - const [port, setPort] = useState( - String( - ((m.expose as Record)?.http as Record) - ?.internal as Record - ? (((m.expose as Record)?.http as Record) - ?.internal as Record)?.port ?? "" - : "" - ) - ) - const [proxyPath, setProxyPath] = useState( - ((m.proxy as Record)?.caddy as Record)?.path_prefix ?? "" - ) - const [healthPath, setHealthPath] = useState( - ((m.expose as Record)?.http as Record)?.health_path ?? "" - ) - - const runner = (m.run as Record)?.runner as string | undefined - - const handleSave = async () => { - setSaving(true) - setSaved(false) - try { - const config: Record = { ...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 = { ...runEnv } - for (const [envKey, secretName] of Object.entries(secrets)) { - mergedEnv[envKey] = `\${secret:${secretName}}` - } - config.run = { ...config.run as Record, 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 ( -
- - 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)]" - /> - - - {runner && ( - - - {runnerLabel(runner)} - {(m.run as Record)?.program && ( - <> · {(m.run as Record).tool} - )} - - - )} - - {("managed" in deployment && deployment.managed || port) && ( - - 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)]" - /> - - )} - - {("managed" in deployment && deployment.managed || healthPath) && ( - - 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)]" - /> - - )} - - - 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)]" - /> - - - {runner && ( - -
- {Object.entries(runEnv).map(([key, val]) => ( -
- - = - - 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)]" - /> - -
- ))} - -
-
- )} - - {runner && ( - - - - )} - - {"managed" in deployment && ( - - - {deployment.managed ? "Yes" : "No"} - - - )} - -
- {onDelete ? ( - - ) : ( -
- )} - -
-
- ) -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- -
{children}
-
- ) -} diff --git a/app/src/components/detail/ConfigPanel.tsx b/app/src/components/detail/ConfigPanel.tsx index d949949..0178796 100644 --- a/app/src/components/detail/ConfigPanel.tsx +++ b/app/src/components/detail/ConfigPanel.tsx @@ -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

Configuration

- + {configSection === "programs" ? ( + + ) : configSection === "services" ? ( + + ) : ( + + )}
) diff --git a/app/src/components/detail/JobFields.tsx b/app/src/components/detail/JobFields.tsx new file mode 100644 index 0000000..df17aae --- /dev/null +++ b/app/src/components/detail/JobFields.tsx @@ -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) => Promise + onDelete?: (name: string) => Promise +} + +type Obj = Record +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) + const runner = (run.runner as string) ?? "?" + + const handleSave = async () => { + setSaving(true) + setSaved(false) + try { + const config: Record = 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 ( +
+ + + + {runnerLabel(runner)} · + 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)]" + /> + + {envEditor} + onDelete(job.id) : undefined} + deleteLabel="Remove job" + /> +
+ ) +} diff --git a/app/src/components/detail/ProgramFields.tsx b/app/src/components/detail/ProgramFields.tsx new file mode 100644 index 0000000..8e2d3bb --- /dev/null +++ b/app/src/components/detail/ProgramFields.tsx @@ -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) => Promise + onDelete?: (name: string) => Promise +} + +/** 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 = { ...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 ( +
+ + + + + + + + + + + + + + {program.commands && Object.keys(program.commands).length > 0 && ( + +
+ {Object.entries(program.commands).map(([verb, cmds]) => ( +
+ {verb} + {cmds.map((a) => a.join(" ")).join(" && ")} +
+ ))} +
+
+ )} + + onDelete(program.id) : undefined} + deleteLabel="Remove program" + /> +
+ ) +} diff --git a/app/src/components/detail/ServiceFields.tsx b/app/src/components/detail/ServiceFields.tsx new file mode 100644 index 0000000..aa54b51 --- /dev/null +++ b/app/src/components/detail/ServiceFields.tsx @@ -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) => Promise + onDelete?: (name: string) => Promise +} + +type Obj = Record +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) + + const runner = (run.runner as string) ?? "?" + + const handleSave = async () => { + setSaving(true) + setSaved(false) + try { + const config: Record = 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 ( +
+ + + {runnerLabel(runner)} · + 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)]" + /> + + + + + + + {envEditor} + onDelete(service.id) : undefined} + deleteLabel="Remove service" + /> +
+ ) +} diff --git a/app/src/components/detail/fields.tsx b/app/src/components/detail/fields.tsx new file mode 100644 index 0000000..ee0ceb6 --- /dev/null +++ b/app/src/components/detail/fields.tsx @@ -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 ( +
+ +
{children}
+
+ ) +} + +export function TextField({ + label, + value, + onChange, + placeholder, + mono, + width, +}: { + label: string + value: string + onChange: (v: string) => void + placeholder?: string + mono?: boolean + width?: string +}) { + return ( + + onChange(e.target.value)} + placeholder={placeholder} + className={`${width ?? "w-full"} ${INPUT} ${mono ? "font-mono" : ""}`} + /> + + ) +} + +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) { + const { plain, secretRefs } = useMemo(() => { + const p: Record = {} + const s: Record = {} + 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>(plain) + const [secrets, setSecrets] = useState>(secretRefs) + + const merged = (): Record => { + const out: Record = { ...env } + for (const [k, name] of Object.entries(secrets)) out[k] = `\${secret:${name}}` + return out + } + + const element = ( +
+ +
+ {Object.entries(env).map(([key, val]) => ( +
+ + = + setEnv((p) => ({ ...p, [key]: e.target.value }))} + className={`flex-1 ${INPUT} text-xs font-mono`} + /> + +
+ ))} + +
+
+ + + +
+ ) + + 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 ( +
+ {onDelete ? ( + + ) : ( +
+ )} + +
+ ) +} diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 67042b9..e3062ca 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -67,7 +67,7 @@ export interface ProgramDetail extends ProgramSummary { manifest: Record } -// 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