import { useMemo, useState } from "react" import { Trash2, X } from "lucide-react" import { SecretsEditor } from "@/components/SecretsEditor" import { ConfirmModal } from "@/components/ConfirmModal" 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, hint, }: { label: string children: React.ReactNode hint?: string }) { return (
{children} {hint &&

{hint}

}
) } export function TextField({ label, value, onChange, placeholder, mono, width, hint, }: { label: string value: string onChange: (v: string) => void placeholder?: string mono?: boolean width?: string hint?: string }) { return ( onChange(e.target.value)} placeholder={placeholder} className={`${width ?? "w-full"} ${INPUT} ${mono ? "font-mono" : ""}`} /> ) } /** The public-address sub-control shown when a deployment's reach is `public`: * a default/custom choice. `default` uses `.` (shown * read-only); `custom` reveals a text input for an exact FQDN (apex or another * zone) that becomes the deployment's `public_host`. */ export function PublicHostRadios({ mode, onModeChange, value, onChange, defaultHost, }: { mode: "default" | "custom" onModeChange: (m: "default" | "custom") => void value: string onChange: (v: string) => void defaultHost: string }) { return (
{mode === "custom" && (
onChange(e.target.value)} placeholder="example.com" className={`w-64 ${INPUT} font-mono`} />

An exact hostname — an apex (example.com) or a name in another zone. The Cloudflare token(s) need DNS:Edit on that zone.

)}
) } // A value that is *exactly* a secret ref → fully editable via SecretsEditor. const SECRET_RE = /^\$\{secret:([^}]+)\}$/ // A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These // are composite literals, so we surface them read-only rather than letting // merged() rewrite the value and clobber the surrounding text. const EMBEDDED_SECRET_RE = /\$\{secret:([^}]+)\}/g /** 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, embeddedRefs } = useMemo(() => { const p: Record = {} const s: Record = {} const e: { envKey: string; secretName: string }[] = [] for (const [k, v] of Object.entries(initial)) { const m = SECRET_RE.exec(v) if (m) { s[k] = m[1] } else { // Composite values stay in `plain` so their literal text round-trips // untouched; any embedded secret names are surfaced read-only below. p[k] = v for (const em of v.matchAll(EMBEDDED_SECRET_RE)) { e.push({ envKey: k, secretName: em[1] }) } } } return { plain: p, secretRefs: s, embeddedRefs: e } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const [env, setEnv] = useState>(plain) const [secrets, setSecrets] = useState>(secretRefs) // Inline "add variable" row (replaces a native prompt). `adding` toggles it; // the key is committed only when non-blank and not already an env/secret name. const [adding, setAdding] = useState(false) const [newKey, setNewKey] = useState("") const [newVal, setNewVal] = useState("") const trimmedKey = newKey.trim() const duplicate = trimmedKey !== "" && (trimmedKey in env || trimmedKey in secrets) const canCommit = trimmedKey !== "" && !duplicate const cancelNew = () => { setAdding(false) setNewKey("") setNewVal("") } const commitNew = () => { if (!canCommit) return setEnv((p) => ({ ...p, [trimmedKey]: newVal })) cancelNew() } const merged = (): Record => { const out: Record = { ...env } for (const [k, name] of Object.entries(secrets)) out[k] = `\${secret:${name}}` return out } const element = (

Use ${"{port}"},{" "} ${"{data_dir}"},{" "} ${"{name}"},{" "} ${"{public_url}"} for castle's computed values, and ${"{secret:NAME}"} for secrets.

{Object.entries(env).map(([key, val]) => (
= setEnv((p) => ({ ...p, [key]: e.target.value }))} className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`} />
))} {adding ? (
setNewKey(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commitNew() if (e.key === "Escape") cancelNew() }} placeholder="VARIABLE_NAME" className={`w-24 sm:w-56 min-w-0 ${INPUT} text-xs font-mono`} /> = setNewVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commitNew() if (e.key === "Escape") cancelNew() }} placeholder="value" className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`} />
{duplicate && (

{trimmedKey} already exists.

)}
) : ( )}
{embeddedRefs.length > 0 && (
{embeddedRefs.map(({ envKey, secretName }) => (

{secretName} — embedded in{" "} {envKey} (read-only)

))}
)}
) return { element, merged } } /** A deployment-to-deployment requirement as stored in `requires`. `kind` defaults * to "deployment" on the backend, so the editor only surfaces ref + optional bind. */ export interface Requirement { kind?: string ref: string bind?: string } /** Hook for editing a deployment's `requires` (service→service dependencies). * Returns the editor element plus a `value()` that yields the requirement list to * save (empty entries dropped; bind omitted when blank). */ export function useRequires(initial: Requirement[]) { const [reqs, setReqs] = useState(() => (initial ?? []).map((r) => ({ ref: r.ref ?? "", bind: r.bind ?? "" })), ) const value = (): Requirement[] => reqs .filter((r) => r.ref.trim()) .map((r) => r.bind?.trim() ? { ref: r.ref.trim(), bind: r.bind.trim() } : { ref: r.ref.trim() }, ) const element = (
{reqs.map((r, i) => (
setReqs((p) => p.map((x, j) => (j === i ? { ...x, ref: e.target.value } : x))) } placeholder="deployment name" className={`w-32 sm:w-48 min-w-0 ${INPUT} text-xs font-mono`} /> setReqs((p) => p.map((x, j) => (j === i ? { ...x, bind: e.target.value } : x))) } placeholder="ENV_VAR (optional)" className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`} />
))}
) return { element, value } } /** Save/Delete footer shared by the typed config forms. */ export function FormFooter({ saving, saved, onSave, onDelete, deleteLabel, confirmMessage, deleteBlocked, }: { saving: boolean saved: boolean onSave: () => void onDelete?: () => void deleteLabel: string confirmMessage?: string /** When set, removal is disallowed and this reason is shown instead of the button. */ deleteBlocked?: string }) { const [confirmOpen, setConfirmOpen] = useState(false) return (
{deleteBlocked ? ( {deleteBlocked} ) : onDelete ? ( ) : (
)} {onDelete && ( { setConfirmOpen(false) onDelete() }} onCancel={() => setConfirmOpen(false)} /> )}
) }