ui: Apply everywhere — power toggles, plan preview, no start/stop/install

Rework the dashboard around convergence:
- ServiceControls / ServiceCard / JobCard: replace start/restart/stop
  with a Power toggle (set enabled → apply, i.e. activate/deactivate)
  plus Restart (the one imperative bounce).
- ToolDetail: install/uninstall becomes an Enable/Disable toggle;
  `installed` stays the live state, `enabled` the desired one.
- ConfigPanel Apply and CreateDeploymentForm now POST /apply (one
  converge that renders + restarts only what changed) instead of
  /deploy + a separate start/restart.
- New ConvergePanel on Overview: a terraform-style Preview (POST /apply
  plan=true) showing would-activate/restart/deactivate, then Apply.
- useApply + useSetEnabled hooks; `enabled` added to Service/Job/
  Deployment types; both handle the self-restart connection drop.

Backend: PUT /config/deployments/{name}/enabled sets desired on/off
(edit config; caller runs apply) — the declarative toggle the UI uses.

Dashboard builds clean (tsc + vite); 59 API tests pass; live /apply and
/services?enabled verified after an api restart.
This commit is contained in:
2026-07-02 11:49:54 -07:00
parent 5d15d18e1d
commit f422c1879d
13 changed files with 292 additions and 121 deletions

View File

@@ -154,6 +154,65 @@ export function useServiceAction() {
})
}
export interface ApplyResult {
status: string
planned: boolean
changed: boolean
activated: string[]
restarted: string[]
deactivated: string[]
unchanged: string[]
messages: string[]
}
// Converge the running system to config. Pass a name to converge one deployment,
// or plan:true for a dry-run diff. Handles the API restarting itself mid-apply.
export function useApply() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ name, plan }: { name?: string; plan?: boolean } = {}) => {
try {
return await apiClient.post<ApplyResult>("/apply", { name, plan })
} catch (err) {
if (err instanceof TypeError) {
// Self-restart killed the connection — treat as accepted, wait + refresh.
await waitForApi()
return {
status: "ok", planned: false, changed: true,
activated: [], restarted: name ? [name] : [], deactivated: [],
unchanged: [], messages: [],
} as ApplyResult
}
throw err
}
},
onSuccess: (data) => {
if (!data.planned) qc.invalidateQueries()
},
})
}
// Set a deployment's desired on/off state, then converge it. One click = "make it
// so": edit config (declarative), then apply that single deployment.
export function useSetEnabled() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => {
await apiClient.put(`/config/deployments/${name}/enabled`, { enabled })
try {
return await apiClient.post<ApplyResult>("/apply", { name })
} catch (err) {
if (err instanceof TypeError) {
await waitForApi()
return null
}
throw err
}
},
onSuccess: () => qc.invalidateQueries(),
})
}
export function useProgramAction() {
const qc = useQueryClient()
return useMutation({