Files
wild-pc/app/src/components/detail/ConfigPanel.tsx
Paul Payne 20bf78caf1 api+ui: kind-scoped save/delete endpoints and links
API: /config/{services,jobs,tools,static}/{name} now pin the twin they
target — _save_deployment/_delete_deployment take an explicit kind so a
patch to a 'backup' service can't bleed into a 'backup' job/tool. Add
/tools and /static endpoints; keep /deployments/{name} kind-agnostic.
New test_kind_twins proves per-kind save/delete isolation on disk.

UI: ConfigPanel and CreateDeploymentForm write to the kind-scoped
resource; GatewayPanel/NodeDetail/DeploymentsSection link via a shared
detailPath(name, kind) helper instead of the ambiguous /deployment/:name.

Includes incidental ruff-format reflow of untouched api files.
2026-07-06 02:51:08 -07:00

175 lines
6.2 KiB
TypeScript

import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { Check, RefreshCw } from "lucide-react"
import { apiClient } from "@/services/api/client"
import type {
AnyDetail,
DeploymentDetail,
ProgramDetail,
ServiceDetail,
JobDetail,
} from "@/types"
import { ProgramFields } from "./ProgramFields"
import { ServiceFields } from "./ServiceFields"
import { JobFields } from "./JobFields"
import { ToolFields } from "./ToolFields"
import { StaticFields } from "./StaticFields"
interface ConfigPanelProps {
deployment: AnyDetail | DeploymentDetail
configSection: "services" | "jobs" | "programs" | "tools" | "static"
onRefetch: () => void
}
export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPanelProps) {
const navigate = useNavigate()
const qc = useQueryClient()
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
// A deployment edit only persists to castle.yaml; the unit/process still need
// a deploy (regenerate) + restart to actually take effect.
const [pendingApply, setPendingApply] = useState(false)
const [applying, setApplying] = useState(false)
const isDeployment = configSection !== "programs"
// Save/delete against the kind-scoped resource (services/jobs/tools/static) so
// a save can't hit a same-named twin of another kind. `configSection` already
// carries the kind; programs write to their own catalog.
const writeSection = configSection
const handleSave = async (compName: string, config: Record<string, unknown>) => {
setMessage(null)
try {
await apiClient.put(`/config/${writeSection}/${compName}`, { config })
setMessage({
type: "ok",
text: isDeployment
? "Saved to castle.yaml — not live yet; apply to converge."
: "Saved to castle.yaml",
})
if (isDeployment) setPendingApply(true)
onRefetch()
qc.invalidateQueries({ queryKey: [configSection] })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setMessage({ type: "error", text: msg })
}
}
const handleApply = async () => {
setApplying(true)
setMessage(null)
try {
// One converge: renders units/routes and reconciles the runtime (restarts
// only what changed). No separate restart call needed.
await apiClient.post(`/apply`, { name: deployment.id })
setPendingApply(false)
setMessage({ type: "ok", text: "Applied — the change is now live." })
qc.invalidateQueries()
onRefetch()
} catch (e: unknown) {
// A self-apply of castle-api restarts it, killing the connection — expected.
if (e instanceof TypeError) {
setPendingApply(false)
setMessage({ type: "ok", text: "Applied — the service is restarting." })
setApplying(false)
return
}
let msg = e instanceof Error ? e.message : String(e)
try {
msg = JSON.parse((e as Error).message).detail ?? msg
} catch {
/* keep msg */
}
setMessage({ type: "error", text: `Apply failed: ${msg}` })
} finally {
setApplying(false)
}
}
const handleDelete = async (compName: string) => {
// Deleting a program cascades: its deployments are torn down and removed too
// (a program and its 1:1 tool/static deployment are one thing). Deleting a
// service/job deployment is a plain removal (keeps the program).
const url =
configSection === "programs"
? `/config/programs/${compName}?cascade=true`
: `/config/${writeSection}/${compName}`
try {
await apiClient.delete(url)
qc.invalidateQueries({ queryKey: [configSection] })
qc.invalidateQueries({ queryKey: ["programs"] })
navigate("/")
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setMessage({ type: "error", text: msg })
}
}
return (
<>
{(message || pendingApply) && (
<div
className={`mb-4 px-3 py-2 rounded text-sm flex items-center justify-between gap-3 ${
message?.type === "error"
? "bg-red-900/30 text-red-300 border border-red-800"
: pendingApply
? "bg-amber-900/30 text-amber-200 border border-amber-800"
: "bg-green-900/30 text-green-300 border border-green-800"
}`}
>
<span className="flex items-center gap-1.5">
{message?.type === "ok" && !pendingApply && <Check size={14} />}
{message?.text}
</span>
{pendingApply && (
<button
onClick={handleApply}
disabled={applying}
className="shrink-0 flex items-center gap-1.5 px-3 py-1 text-xs rounded bg-amber-700 hover:bg-amber-600 text-white transition-colors disabled:opacity-50"
>
<RefreshCw size={12} className={applying ? "animate-spin" : ""} />
{applying ? "Applying…" : "Apply"}
</button>
)}
</div>
)}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-4">
Configuration
</h2>
{configSection === "programs" ? (
<ProgramFields
program={deployment as ProgramDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
) : configSection === "services" ? (
<ServiceFields
service={deployment as ServiceDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
) : configSection === "tools" ? (
<ToolFields
tool={deployment as DeploymentDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
) : configSection === "static" ? (
<StaticFields
static_={deployment as DeploymentDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
) : (
<JobFields
job={deployment as JobDetail}
onSave={handleSave}
onDelete={handleDelete}
/>
)}
</div>
</>
)
}