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

@@ -11,6 +11,7 @@ import {
} from "@/services/api/hooks"
import { NodeBar } from "@/components/NodeBar"
import { PageHeader } from "@/components/PageHeader"
import { ConvergePanel } from "@/components/ConvergePanel"
export function Overview() {
const { data: services } = useServices()
@@ -96,6 +97,8 @@ export function Overview() {
</Link>
))}
</div>
<ConvergePanel />
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom"
import { Clock } from "lucide-react"
import { useJob, useStatus } from "@/services/api/hooks"
import { useJob } from "@/services/api/hooks"
import { LogViewer } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ServiceControls } from "@/components/detail/ServiceControls"
@@ -10,8 +10,6 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
export function ScheduledDetailPage() {
const { name } = useParams<{ name: string }>()
const { data: deployment, isLoading, error, refetch } = useJob(name ?? "")
const { data: statusResp } = useStatus()
const health = statusResp?.statuses.find((s) => s.id === name)
if (isLoading) {
return (
@@ -38,7 +36,7 @@ export function ScheduledDetailPage() {
stack={deployment.stack}
source={deployment.source}
>
<ServiceControls name={deployment.id} health={health} />
<ServiceControls name={deployment.id} enabled={deployment.enabled} />
</DetailHeader>
{deployment.schedule && (

View File

@@ -51,7 +51,7 @@ export function ServiceDetailPage() {
{!isStatic && (
<div className="flex items-center gap-2">
{health && <HealthBadge status={health.status} latency={health.latency_ms} />}
<ServiceControls name={deployment.id} health={health} />
<ServiceControls name={deployment.id} enabled={deployment.enabled} />
</div>
)}
</DetailHeader>

View File

@@ -1,6 +1,6 @@
import { useParams, Link } from "react-router-dom"
import { Loader2, Package } from "lucide-react"
import { useDeployment, useProgramAction } from "@/services/api/hooks"
import { useDeployment, useSetEnabled } from "@/services/api/hooks"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel"
@@ -38,9 +38,14 @@ export function ToolDetailPage() {
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{deployment.description}</p>
)}
{/* A tool's PATH deployment: install/uninstall is its start/stop. Its
live state is `installed` (on PATH), which the endpoint sets directly. */}
<PathLifecycle name={deployment.id} installed={deployment.installed} onDone={refetch} />
{/* A tool's PATH deployment: enabling converges it onto PATH, disabling
removes it. `installed` is the live state; `enabled` the desired one. */}
<PathLifecycle
name={deployment.id}
enabled={deployment.enabled}
installed={deployment.installed}
onDone={refetch}
/>
<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-3">
@@ -63,17 +68,19 @@ export function ToolDetailPage() {
)
}
/** Install/uninstall a tool on PATH — the path deployment's lifecycle. */
/** Enable/disable a tool — convergence installs it onto PATH or removes it. */
function PathLifecycle({
name,
enabled,
installed: installedState,
onDone,
}: {
name: string
enabled: boolean
installed: boolean | null
onDone: () => void
}) {
const { mutate, isPending } = useProgramAction()
const { mutate, isPending } = useSetEnabled()
const installed = installedState === true
const dot =
installedState === true
@@ -86,21 +93,20 @@ function PathLifecycle({
<div className="flex items-center gap-2 text-sm">
<span className={`h-2 w-2 rounded-full shrink-0 ${dot}`} />
<span>{installed ? "Installed on PATH" : "Not installed"}</span>
{!enabled && <span className="text-xs text-amber-400">disabled</span>}
<span className="text-xs text-[var(--muted)]">manager: path</span>
</div>
<button
onClick={() =>
mutate({ name, action: installed ? "uninstall" : "install" }, { onSuccess: onDone })
}
onClick={() => mutate({ name, enabled: !enabled }, { onSuccess: onDone })}
disabled={isPending}
className={`flex items-center gap-1.5 px-2.5 py-1 text-sm rounded border transition-colors disabled:opacity-40 ${
installed
enabled
? "border-red-800 text-red-400 hover:bg-red-800/30"
: "border-green-800 text-green-400 hover:bg-green-800/30"
}`}
>
{isPending && <Loader2 size={14} className="animate-spin" />}
{installed ? "Uninstall" : "Install"}
{enabled ? "Disable" : "Enable"}
</button>
</div>
)