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) => { 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) && (
{message?.type === "ok" && !pendingApply && } {message?.text} {pendingApply && ( )}
)}

Configuration

{configSection === "programs" ? ( ) : configSection === "services" ? ( ) : configSection === "tools" ? ( ) : configSection === "static" ? ( ) : ( )}
) }