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:
@@ -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}.<gateway.domain>:{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>
|
||||
|
||||
Reference in New Issue
Block a user