fix(api): editable-spec detail + PATCH-merge saves + shared gateway parser

Deployment/program edits could silently drop spec fields:

- GET /deployments/{name} served the runtime view (no reach/program/root/expose)
  for deployed deployments, so the dashboard round-tripped a lossy manifest and
  stripped fields on save (broke astro). Now serves the editable castle.yaml spec
  when the deployment is in config.
- _save_deployment and save_program replaced the whole object with client input.
  Now shallow-merge over the existing spec (omit = preserve, null = clear), so a
  partial/lossy save can't drop untouched fields.
- save_yaml rebuilt GatewayConfig from `port` only, wiping tls/domain/tunnel/
  cert_hook on a whole-file save. Now uses a shared parse_gateway() (also used by
  load_config) so gateway fields can't drift between the two.

Dashboard forms (ServiceFields/StaticFields) send explicit null to clear, per the
merge contract; adds exposure host-label helpers. Coverage: detail-serves-spec,
save round-trip, partial-patch-preserves, null-clears, program partial-patch,
parse_gateway, and config save/load round-trip.
This commit is contained in:
2026-07-05 23:23:59 -07:00
parent eeaa65f7ce
commit 07c02aa151
10 changed files with 309 additions and 72 deletions

View File

@@ -3,6 +3,8 @@ import { useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import { X } from "lucide-react"
import { apiClient } from "@/services/api/client"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField } from "./fields"
const SELECT =
@@ -40,6 +42,9 @@ export function CreateDeploymentForm({
}) {
const qc = useQueryClient()
const navigate = useNavigate()
const { data: gateway } = useGateway()
const domain = gateway?.domain
const publicDomain = gateway?.public_domain
const [kind, setKind] = useState<DeploymentKind>(initialKind ?? "service")
const [name, setName] = useState(prefill?.name ?? "")
@@ -207,16 +212,16 @@ export function CreateDeploymentForm({
<>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono />
<Field label="Expose" hint="Route through the gateway at <name>.<gateway.domain>. Off: reachable only at host:port.">
<Field label="Expose" hint={`Route through the gateway at ${gatewayHost("<name>", domain)}. Off: reachable only at host:port.`}>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={proxy} onChange={(e) => setProxy(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">
{proxy ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"}
{proxy ? gatewayHost(name || "name", domain) : "off (host:port only)"}
</span>
</label>
</Field>
{proxy && (
<Field label="Public" hint="Also publish to the internet via the Cloudflare tunnel at <name>.<gateway.public_domain>.">
<Field label="Public" hint={`Also publish to the internet via the Cloudflare tunnel at ${publicGatewayHost("<name>", publicDomain)}.`}>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>

View File

@@ -1,5 +1,7 @@
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, useEnvSecrets } from "./fields"
interface Props {
@@ -38,6 +40,9 @@ function applyLauncher(run: Obj, launcher: string, target: string): Obj {
/** 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)
@@ -80,7 +85,9 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
try {
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
delete config.id
config.description = description || undefined
// 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)
@@ -96,7 +103,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
},
}
} else {
delete config.expose
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"
@@ -146,7 +153,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
{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/<name>.yaml for now."
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>
@@ -162,7 +169,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
<div>
reach{" "}
<span className="text-[var(--fg)]">{String(m.reach ?? "internal")}</span> {" "}
{service.id}.&lt;gateway.domain&gt;:{String(tcp.port)}
{gatewayHost(service.id, domain)}:{String(tcp.port)}
</div>
</div>
</Field>
@@ -188,7 +195,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
/>
<Field
label="Reach"
hint="How far this service is exposed. off: host:port only. internal: <service-name>.<gateway.domain> via the gateway. public: also to the internet via the Cloudflare tunnel."
hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`}
>
<div className="flex items-center gap-2">
<select
@@ -207,8 +214,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
: reach === "off"
? "host:port only"
: reach === "public"
? `${service.id}.<gateway.public_domain>`
: `${service.id}.<gateway.domain>`}
? publicGatewayHost(service.id, publicDomain)
: gatewayHost(service.id, domain)}
</span>
</div>
</Field>

View File

@@ -1,5 +1,7 @@
import { useState } from "react"
import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props {
@@ -14,6 +16,9 @@ const obj = (v: unknown): Obj => (v as Obj) ?? {}
/** Edit a static (caddy) deployment: the built dir it serves (`root`), whether it's
* also public (via the tunnel), a description, and env. No launcher/port/schedule. */
export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const { data: gateway } = useGateway()
const domain = gateway?.domain
const publicDomain = gateway?.public_domain
const m = dep.manifest
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
@@ -33,7 +38,8 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
try {
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
delete config.id
config.description = description || undefined
// Merge (PATCH) semantics: null clears, omit preserves — send null to clear.
config.description = description || null
config.root = root || "dist"
config.reach = isPublic ? "public" : "internal"
delete config.public
@@ -62,7 +68,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
/>
<Field
label="Reach"
hint="How far this static site is served. internal: <name>.<gateway.domain>. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)"
hint={`How far this static site is served. internal: ${gatewayHost(dep.id, domain)}. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)`}
>
<div className="flex items-center gap-2">
<select
@@ -74,7 +80,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
<option value="public">public</option>
</select>
<span className="font-mono text-[var(--muted)] text-xs">
{isPublic ? `${dep.id}.<gateway.public_domain>` : `${dep.id}.<gateway.domain>`}
{isPublic ? publicGatewayHost(dep.id, publicDomain) : gatewayHost(dep.id, domain)}
</span>
</div>
</Field>