import { useState } from "react" import type { ServiceDetail } from "@/types" import { useGateway } from "@/services/api/hooks" import { gatewayHost, publicGatewayHost } from "@/lib/labels" import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields" import type { Requirement } 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) ?? {} // The systemd launch mechanisms (a service is manager=systemd). Editable, so a // mis-set launcher can be corrected; the primary "Launch" target maps per launcher. const LAUNCHERS = ["python", "command", "container", "compose", "node"] /** Fold the "Launch" text into the run block for the chosen launcher, preserving * any other run fields (args, ports, package_manager, …) already present. */ function applyLauncher(run: Obj, launcher: string, target: string): Obj { const out: Obj = { ...run, launcher } const t = target.trim() if (launcher === "command") { out.argv = t.split(/\s+/).filter(Boolean) delete out.program } else if (launcher === "python") { out.program = t delete out.argv } else if (launcher === "container") { if (t) out.image = t } else if (launcher === "node") { if (t) out.script = t } else if (launcher === "compose") { if (t) out.file = t } return out } /** Edit a service's deployment config (run / expose / proxy / env). */ export function ServiceFields({ service, onSave, onDelete }: Props) { const { data: gateway } = useGateway() const domain = gateway?.domain const publicDomain = gateway?.public_domain 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) // A raw-TCP service (postgres, redis, …) exposes `expose.tcp`, not `expose.http`. // It's reachable at .: via DNS (no gateway HTTP route), so the // HTTP port/health/reach controls below don't apply — show its exposure read-only // and never rebuild `expose` on save (that would nuke expose.tcp). Edit TCP/TLS in // deployments/.yaml for now. const tcp = obj(obj(m.expose).tcp) const tcpTls = obj(tcp.tls) const isTcp = tcp.port != null const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [description, setDescription] = useState((m.description as string) ?? "") const [launcher, setLauncher] = useState((run.launcher as string) ?? "python") const [runProgram, setRunProgram] = useState( (run.program as string) || ((run.argv as string[]) ?? []).join(" ") || (run.image as string) || (run.script as string) || (run.file as string) || "", ) const [port, setPort] = useState(internal.port != null ? String(internal.port) : "") const [health, setHealth] = useState((httpExpose.health_path as string) ?? "") // How far the service reaches: off | internal | public. Falls back to the // legacy proxy/public booleans for any deployment not yet re-saved. const [reach, setReach] = useState( (m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"), ) // Optional exact public FQDN (apex or another zone) that overrides the default // .. Only applies when reach is public; `publicMode` picks // default vs a custom host, and `publicHost` holds the custom value. const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "") const [publicMode, setPublicMode] = useState<"default" | "custom">( m.public_host ? "custom" : "default", ) const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record) const { element: requiresEditor, value: requiresValue } = useRequires( (m.requires as Requirement[]) ?? [], ) const handleSave = async () => { setSaving(true) setSaved(false) try { const config: Record = JSON.parse(JSON.stringify(m)) delete config.id // The API merges (PATCH): omit = preserve, null = clear. So to CLEAR a field // we must send an explicit null, not drop it — otherwise the old value sticks. config.description = description || null // Rebuild the run block for the chosen launcher, preserving other fields. config.run = applyLauncher(obj(config.run), launcher, runProgram) // For a TCP service, leave expose.tcp + reach exactly as-is (they're already // in the cloned config) — only the HTTP path rebuilds expose from the form. if (!isTcp) { if (port) { config.expose = { http: { internal: { port: parseInt(port, 10) }, ...(health ? { health_path: health } : {}), }, } } else { config.expose = null // explicit clear (merge: omit would preserve the old port) } // reach needs a port to route through the gateway; without one it's off. config.reach = port ? reach : "off" // public_host only applies to a public service using a custom domain; else // clear it (send null so the PATCH merge drops any stale override). config.public_host = config.reach === "public" && publicMode === "custom" && publicHost.trim() ? publicHost.trim() : null } delete config.proxy delete config.public config.requires = requiresValue() 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 (
· setRunProgram(e.target.value)} className="w-full sm: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)]" />
{isTcp ? (
tcp · port{" "} {String(tcp.port)} {tcpTls.material && tcpTls.material !== "off" ? ( <> {" "} · tls {String(tcpTls.material)} ) : null}
reach{" "} {String(m.reach ?? "internal")} —{" "} {gatewayHost(service.id, domain)}:{String(tcp.port)}
) : ( <>
{!port && ( set a port to expose )}
{port && ( {reach === "off" && ( localhost:{port} )} {reach === "internal" && ( {gatewayHost(service.id, domain)} )} {reach === "public" && ( )} )} )} {requiresEditor} {envEditor} onDelete(service.id) : undefined} deleteLabel="Remove service" confirmMessage={`Remove service "${service.id}" from wildpc.yaml? Run a deploy afterward to tear down its unit.`} />
) }