412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
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 (
|
|
<div className="flex items-start gap-4">
|
|
<label className="w-24 sm:w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label>
|
|
<div className="flex-1 min-w-0">
|
|
{children}
|
|
{hint && <p className="text-xs text-[var(--muted)] mt-1 leading-snug">{hint}</p>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Field label={label} hint={hint}>
|
|
<input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={`${width ?? "w-full"} ${INPUT} ${mono ? "font-mono" : ""}`}
|
|
/>
|
|
</Field>
|
|
)
|
|
}
|
|
|
|
/** The public-address sub-control shown when a deployment's reach is `public`:
|
|
* a default/custom choice. `default` uses `<name>.<public_domain>` (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 (
|
|
<div className="space-y-1.5">
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input type="radio" checked={mode === "default"} onChange={() => onModeChange("default")} />
|
|
<span className="font-mono text-xs text-[var(--muted)]">
|
|
{defaultHost} <span className="opacity-60">· default</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input type="radio" checked={mode === "custom"} onChange={() => onModeChange("custom")} />
|
|
<span className="text-sm">custom domain</span>
|
|
</label>
|
|
{mode === "custom" && (
|
|
<div className="ml-6 space-y-1">
|
|
<input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder="example.com"
|
|
className={`w-64 ${INPUT} font-mono`}
|
|
/>
|
|
<p className="text-xs text-[var(--muted)] leading-snug">
|
|
An exact hostname — an apex (example.com) or a name in another zone. The
|
|
Cloudflare token(s) need DNS:Edit on that zone.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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<string, string>) {
|
|
const { plain, secretRefs, embeddedRefs } = useMemo(() => {
|
|
const p: Record<string, string> = {}
|
|
const s: Record<string, string> = {}
|
|
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<Record<string, string>>(plain)
|
|
const [secrets, setSecrets] = useState<Record<string, string>>(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<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">
|
|
<p className="text-xs text-[var(--muted)]">
|
|
Use <code className="font-mono">${"{port}"}</code>,{" "}
|
|
<code className="font-mono">${"{data_dir}"}</code>,{" "}
|
|
<code className="font-mono">${"{name}"}</code>,{" "}
|
|
<code className="font-mono">${"{public_url}"}</code> for castle's computed values,
|
|
and <code className="font-mono">${"{secret:NAME}"}</code> for secrets.
|
|
</p>
|
|
{Object.entries(env).map(([key, val]) => (
|
|
<div key={key} className="flex items-center gap-2">
|
|
<input
|
|
value={key}
|
|
readOnly
|
|
className={`w-24 sm:w-56 min-w-0 ${INPUT} text-xs text-[var(--muted)]`}
|
|
/>
|
|
<span className="text-[var(--muted)] shrink-0">=</span>
|
|
<input
|
|
value={val}
|
|
onChange={(e) => setEnv((p) => ({ ...p, [key]: e.target.value }))}
|
|
className={`flex-1 min-w-0 ${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 shrink-0"
|
|
title="Remove"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{adding ? (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
autoFocus
|
|
value={newKey}
|
|
onChange={(e) => 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`}
|
|
/>
|
|
<span className="text-[var(--muted)] shrink-0">=</span>
|
|
<input
|
|
value={newVal}
|
|
onChange={(e) => 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`}
|
|
/>
|
|
<button
|
|
onClick={commitNew}
|
|
disabled={!canCommit}
|
|
className="px-2 py-1 text-xs rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 shrink-0"
|
|
>
|
|
Add
|
|
</button>
|
|
<button
|
|
onClick={cancelNew}
|
|
className="text-[var(--muted)] hover:text-[var(--foreground)] p-0.5 shrink-0"
|
|
title="Cancel"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
{duplicate && (
|
|
<p className="text-xs text-amber-400">
|
|
<span className="font-mono">{trimmedKey}</span> already exists.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setAdding(true)}
|
|
className="text-xs text-[var(--primary)] hover:underline"
|
|
>
|
|
+ Add variable
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
<Field label="Secrets">
|
|
<SecretsEditor secrets={secrets} onSecretsChange={setSecrets} />
|
|
{embeddedRefs.length > 0 && (
|
|
<div className="mt-2 space-y-1">
|
|
{embeddedRefs.map(({ envKey, secretName }) => (
|
|
<p
|
|
key={`${envKey}:${secretName}`}
|
|
className="text-xs font-mono text-[var(--muted)]"
|
|
>
|
|
<span className="text-[var(--foreground)]">{secretName}</span> — embedded in{" "}
|
|
{envKey} (read-only)
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
)
|
|
|
|
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<Requirement[]>(() =>
|
|
(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 = (
|
|
<Field
|
|
label="Requires"
|
|
hint="Other deployments this one depends on (drawn as edges on the graph). Add an env var to project the target's URL into the environment (e.g. API_URL → https://target.<domain>)."
|
|
>
|
|
<div className="space-y-2">
|
|
{reqs.map((r, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<input
|
|
value={r.ref}
|
|
onChange={(e) =>
|
|
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`}
|
|
/>
|
|
<span className="text-[var(--muted)] shrink-0 text-xs">→</span>
|
|
<input
|
|
value={r.bind ?? ""}
|
|
onChange={(e) =>
|
|
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`}
|
|
/>
|
|
<button
|
|
onClick={() => setReqs((p) => p.filter((_, j) => j !== i))}
|
|
className="text-red-400 hover:text-red-300 p-0.5 shrink-0"
|
|
title="Remove"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={() => setReqs((p) => [...p, { ref: "", bind: "" }])}
|
|
className="text-xs text-[var(--primary)] hover:underline"
|
|
>
|
|
+ Add dependency
|
|
</button>
|
|
</div>
|
|
</Field>
|
|
)
|
|
|
|
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 (
|
|
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
|
{deleteBlocked ? (
|
|
<span className="text-xs text-amber-400">{deleteBlocked}</span>
|
|
) : onDelete ? (
|
|
<button
|
|
onClick={() => setConfirmOpen(true)}
|
|
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>
|
|
{onDelete && (
|
|
<ConfirmModal
|
|
open={confirmOpen}
|
|
title={deleteLabel}
|
|
body={confirmMessage}
|
|
confirmLabel={deleteLabel}
|
|
danger
|
|
onConfirm={() => {
|
|
setConfirmOpen(false)
|
|
onDelete()
|
|
}}
|
|
onCancel={() => setConfirmOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|