import { useMemo, useState } from "react" import { Check, Loader2, Save, Trash2 } from "lucide-react" import type { ComponentDetail } from "@/types" import { runnerLabel } from "@/lib/labels" import { SecretsEditor } from "./SecretsEditor" interface ComponentFieldsProps { component: ComponentDetail onSave: (name: string, config: Record) => Promise onDelete?: (name: string) => Promise } const SECRET_RE = /^\$\{secret:([^}]+)\}$/ export function ComponentFields({ component, onSave, onDelete }: ComponentFieldsProps) { const m = component.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.category 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(component.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)?.tool && ( <> · {(m.run as Record).tool} )} )} {(component.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)]" /> )} {(component.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 && ( )} {component.managed ? "Yes" : "No"}
{onDelete ? ( ) : (
)}
) } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
) }