feat(app): interactive system map (/map)

A spatial control surface for the whole node. Tools, jobs, services, and
frontends sit in lanes with program-source grips; edges show requires
(dependency), same-program siblings, and exposure to LAN/Internet targets.

Direct manipulation, each backed by the existing config PUT + apply:
- drag a node to LAN/Internet to set reach (internal/public)
- delete an exposure line or a node (with confirm) to unexpose/remove
- right-click or the per-node ⋯ menu: open, restart, expose, delete
- click the source grip to open the backing program

Nodes are draggable with Shift-/toggle box-select; layout persists to
localStorage with a reset control. Adds useSetReach/useDeleteDeployment
hooks, the @xyflow/react dep, and the nav entry + route.
This commit is contained in:
2026-07-06 13:12:15 -07:00
parent a018a21587
commit 8086a09bf7
7 changed files with 1059 additions and 0 deletions

View File

@@ -217,6 +217,59 @@ export function useSetEnabled() {
})
}
// Deployment kind → the kind-scoped config section it writes to. Mirrors
// ConfigPanel's writeSection so a mutation can't hit a same-named twin.
const REACH_SECTION: Record<string, string> = {
service: "services",
job: "jobs",
tool: "tools",
static: "static",
}
// Set a deployment's exposure (off | internal | public), then converge it. This
// is the mutation behind the System Map's drag-to-expose / drag-to-internet: a
// partial config merge (reach only, other fields preserved) followed by apply.
export function useSetReach() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
name,
kind,
reach,
}: {
name: string
kind: string
reach: "off" | "internal" | "public"
}) => {
const section = REACH_SECTION[kind] ?? "services"
await apiClient.put(`/config/${section}/${name}`, { config: { reach } })
try {
return await apiClient.post<ApplyResult>("/apply", { name })
} catch (err) {
if (err instanceof TypeError) {
await waitForApi()
return null
}
throw err
}
},
onSuccess: () => qc.invalidateQueries(),
})
}
// Delete a deployment from castle.yaml (the kind-scoped removal; keeps the
// program). Behind the System Map's node deletion.
export function useDeleteDeployment() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ name, kind }: { name: string; kind: string }) => {
const section = REACH_SECTION[kind] ?? "services"
await apiClient.delete(`/config/${section}/${name}`)
},
onSuccess: () => qc.invalidateQueries(),
})
}
export function useProgramAction() {
const qc = useQueryClient()
return useMutation({