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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user