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.
175 lines
6.2 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|