ui: Apply (deploy & restart) after a service/job config edit

Editing a service/job only persisted to castle.yaml; the unit isn't
regenerated and the process isn't restarted (the Restart button just runs
'systemctl restart' on the *existing* unit), so a change like a new port
appeared saved but never took effect.

The config panel now, after saving a service/job, shows an amber 'not live
yet' banner with an Apply button that runs POST /deploy {name} (regenerate
unit + Caddyfile) then, for services, POST /services/{name}/restart — so the
edit actually goes live. Programs are unaffected (no deploy/restart needed).

App builds clean.
This commit is contained in:
2026-06-15 20:25:20 -07:00
parent 392d9039d8
commit 414168dc43

View File

@@ -1,7 +1,7 @@
import { useState } from "react" import { useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { Check } from "lucide-react" import { Check, RefreshCw } from "lucide-react"
import { apiClient } from "@/services/api/client" import { apiClient } from "@/services/api/client"
import type { AnyDetail, ProgramDetail, ServiceDetail, JobDetail } from "@/types" import type { AnyDetail, ProgramDetail, ServiceDetail, JobDetail } from "@/types"
import { ProgramFields } from "./ProgramFields" import { ProgramFields } from "./ProgramFields"
@@ -18,12 +18,23 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null) 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"
const handleSave = async (compName: string, config: Record<string, unknown>) => { const handleSave = async (compName: string, config: Record<string, unknown>) => {
setMessage(null) setMessage(null)
try { try {
await apiClient.put(`/config/${configSection}/${compName}`, { config }) await apiClient.put(`/config/${configSection}/${compName}`, { config })
setMessage({ type: "ok", text: "Saved to castle.yaml" }) setMessage({
type: "ok",
text: isDeployment
? "Saved to castle.yaml — not live yet; apply to deploy & restart."
: "Saved to castle.yaml",
})
if (isDeployment) setPendingApply(true)
onRefetch() onRefetch()
qc.invalidateQueries({ queryKey: [configSection] }) qc.invalidateQueries({ queryKey: [configSection] })
} catch (e: unknown) { } catch (e: unknown) {
@@ -32,6 +43,32 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
} }
} }
const handleApply = async () => {
setApplying(true)
setMessage(null)
try {
await apiClient.post(`/deploy`, { name: deployment.id })
if (configSection === "services") {
await apiClient.post(`/services/${deployment.id}/restart`, {})
}
setPendingApply(false)
setMessage({ type: "ok", text: "Applied — the change is now live." })
qc.invalidateQueries({ queryKey: ["status"] })
qc.invalidateQueries({ queryKey: [configSection] })
onRefetch()
} catch (e: unknown) {
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) => { const handleDelete = async (compName: string) => {
try { try {
await apiClient.delete(`/config/${configSection}/${compName}`) await apiClient.delete(`/config/${configSection}/${compName}`)
@@ -45,18 +82,34 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
return ( return (
<> <>
{message && ( {(message || pendingApply) && (
<div <div
className={`mb-4 px-3 py-2 rounded text-sm ${ className={`mb-4 px-3 py-2 rounded text-sm flex items-center justify-between gap-3 ${
message.type === "ok" message?.type === "error"
? "bg-green-900/30 text-green-300 border border-green-800" ? "bg-red-900/30 text-red-300 border border-red-800"
: "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"> <span className="flex items-center gap-1.5">
{message.type === "ok" && <Check size={14} />} {message?.type === "ok" && !pendingApply && <Check size={14} />}
{message.text} {message?.text}
</span> </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…"
: configSection === "services"
? "Apply (deploy & restart)"
: "Apply (deploy)"}
</button>
)}
</div> </div>
)} )}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">