Repo-side rename only (Phases 1-3 of the migration plan); the live box (~/.castle, systemd units, /data/castle, domains) is a separate cutover. - Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api), dist names, entry point `wildpc = wildpc_cli.main:main`. - Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*. - Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml -> wildpc.yaml, /data/castle -> /data/wildpc. - Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc. - Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend. - Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated; redundant nested uv.lock files dropped (workspace root lock is authoritative). Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds. Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
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<string, unknown>) => Promise<void>
|
|
onDelete?: (name: string) => Promise<void>
|
|
}
|
|
|
|
type Obj = Record<string, unknown>
|
|
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 <name>.<domain>:<port> 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/<name>.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
|
|
// <name>.<public_domain>. 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<string, string>)
|
|
const { element: requiresEditor, value: requiresValue } = useRequires(
|
|
(m.requires as Requirement[]) ?? [],
|
|
)
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
setSaved(false)
|
|
try {
|
|
const config: Record<string, unknown> = 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 (
|
|
<div className="space-y-4">
|
|
<TextField label="Description" value={description} onChange={setDescription} />
|
|
<Field
|
|
label="Launch"
|
|
hint="How this service starts: the launcher, and its target — a console script (python), a command/argv (command), an image (container), a script (node), or a compose file (compose)."
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={launcher}
|
|
onChange={(e) => setLauncher(e.target.value)}
|
|
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
|
>
|
|
{LAUNCHERS.map((l) => (
|
|
<option key={l} value={l}>
|
|
{l}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="text-[var(--muted)]">·</span>
|
|
<input
|
|
value={runProgram}
|
|
onChange={(e) => 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)]"
|
|
/>
|
|
</div>
|
|
</Field>
|
|
{isTcp ? (
|
|
<Field
|
|
label="Exposure"
|
|
hint={`A raw-TCP service — reachable by name + port via DNS, not the HTTP gateway. Edit its port/TLS in deployments/${service.id}.yaml for now.`}
|
|
>
|
|
<div className="font-mono text-xs text-[var(--muted)] space-y-1">
|
|
<div>
|
|
<span className="text-[var(--fg)]">tcp</span> · port{" "}
|
|
<span className="text-[var(--fg)]">{String(tcp.port)}</span>
|
|
{tcpTls.material && tcpTls.material !== "off" ? (
|
|
<>
|
|
{" "}
|
|
· tls <span className="text-[var(--fg)]">{String(tcpTls.material)}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
<div>
|
|
reach{" "}
|
|
<span className="text-[var(--fg)]">{String(m.reach ?? "internal")}</span> —{" "}
|
|
{gatewayHost(service.id, domain)}:{String(tcp.port)}
|
|
</div>
|
|
</div>
|
|
</Field>
|
|
) : (
|
|
<>
|
|
<TextField
|
|
label="Port"
|
|
value={port}
|
|
onChange={setPort}
|
|
width="w-32"
|
|
mono
|
|
placeholder="9001"
|
|
hint="The port the service listens on. Wild PC health-checks and proxies this port; map it to the program's own var with ${port} in Environment."
|
|
/>
|
|
<TextField
|
|
label="Health path"
|
|
value={health}
|
|
onChange={setHealth}
|
|
width="w-48"
|
|
mono
|
|
placeholder="/health"
|
|
hint="HTTP path wildpc polls to report up/down."
|
|
/>
|
|
<Field
|
|
label="Reach"
|
|
hint="How far this service is exposed. off: host:port only. internal: via the gateway. public: also to the internet via the Cloudflare tunnel."
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<select
|
|
value={reach}
|
|
onChange={(e) => setReach(e.target.value)}
|
|
disabled={!port}
|
|
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
|
|
>
|
|
<option value="off">off</option>
|
|
<option value="internal">internal</option>
|
|
<option value="public">public</option>
|
|
</select>
|
|
{!port && (
|
|
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
{port && (
|
|
<Field label="Address" hint="Where this service is reachable.">
|
|
{reach === "off" && (
|
|
<span className="font-mono text-xs text-[var(--muted)]">localhost:{port}</span>
|
|
)}
|
|
{reach === "internal" && (
|
|
<span className="font-mono text-xs text-[var(--muted)]">
|
|
{gatewayHost(service.id, domain)}
|
|
</span>
|
|
)}
|
|
{reach === "public" && (
|
|
<PublicHostRadios
|
|
mode={publicMode}
|
|
onModeChange={setPublicMode}
|
|
value={publicHost}
|
|
onChange={setPublicHost}
|
|
defaultHost={publicGatewayHost(service.id, publicDomain)}
|
|
/>
|
|
)}
|
|
</Field>
|
|
)}
|
|
</>
|
|
)}
|
|
{requiresEditor}
|
|
{envEditor}
|
|
<FormFooter
|
|
saving={saving}
|
|
saved={saved}
|
|
onSave={handleSave}
|
|
onDelete={onDelete ? () => onDelete(service.id) : undefined}
|
|
deleteLabel="Remove service"
|
|
confirmMessage={`Remove service "${service.id}" from wildpc.yaml? Run a deploy afterward to tear down its unit.`}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|