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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user