From 8086a09bf7fa137d303c3753204d894fb8420ffb Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 6 Jul 2026 13:12:15 -0700 Subject: [PATCH] feat(app): interactive system map (/map) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/package.json | 1 + app/pnpm-lock.yaml | 193 +++++++++ app/src/components/Layout.tsx | 2 + app/src/index.css | 12 + app/src/pages/SystemMap.tsx | 796 ++++++++++++++++++++++++++++++++++ app/src/router/routes.tsx | 2 + app/src/services/api/hooks.ts | 53 +++ 7 files changed, 1059 insertions(+) create mode 100644 app/src/pages/SystemMap.tsx diff --git a/app/package.json b/app/package.json index 1e27b6b..42575a9 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.90.21", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "^12.11.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.575.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 9b64490..5ab10c8 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 + '@xyflow/react': + specifier: ^12.11.1 + version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -651,6 +654,24 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -739,6 +760,22 @@ packages: '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@xyflow/react@12.11.1': + resolution: {integrity: sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.78': + resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -792,6 +829,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -820,6 +860,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1322,6 +1400,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1387,6 +1470,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@babel/code-frame@7.29.0': @@ -1828,6 +1926,27 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -1951,6 +2070,31 @@ snapshots: '@xterm/xterm@6.0.0': {} + '@xyflow/react@12.11.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@xyflow/system': 0.0.78 + classcat: 5.0.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.78': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2004,6 +2148,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -2026,6 +2172,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2498,6 +2680,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.3 @@ -2527,3 +2713,10 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index 3f7a497..fb302c7 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -12,6 +12,7 @@ import { Package, Server, Share2, + Map as MapIcon, Network, Wrench, X, @@ -40,6 +41,7 @@ const NAV: (NavLeaf | NavGroup)[] = [ }, { to: "/programs", label: "Programs", icon: Package }, { to: "/graph", label: "Graph", icon: Network }, + { to: "/map", label: "System Map", icon: MapIcon }, { to: "/mesh", label: "Mesh", icon: Share2 }, ] diff --git a/app/src/index.css b/app/src/index.css index 4ec13e4..1593b82 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -27,3 +27,15 @@ select option { background-color: var(--card); color: var(--foreground); } + +/* System Map: make a selected edge unmistakable — thicken it and lay a bright + halo behind the colored stroke so the current selection reads at a glance + before a Backspace/Delete removes it. */ +.react-flow__edge.selected .react-flow__edge-path { + stroke-width: 3.5; + filter: drop-shadow(0 0 5px rgba(240, 246, 252, 0.9)); +} +.react-flow__edge:hover .react-flow__edge-path { + stroke-width: 3; + cursor: pointer; +} diff --git a/app/src/pages/SystemMap.tsx b/app/src/pages/SystemMap.tsx new file mode 100644 index 0000000..09dd68a --- /dev/null +++ b/app/src/pages/SystemMap.tsx @@ -0,0 +1,796 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + Background, + ControlButton, + Controls, + Handle, + Position, + ReactFlow, + SelectionMode, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, + type NodeProps, + type ReactFlowInstance, +} from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { useNavigate } from "react-router-dom" +import { + BoxSelect, + ExternalLink, + Globe, + LayoutGrid, + Loader2, + Lock, + Minus, + MoreVertical, + Package, + RotateCw, + Router, + Trash2, +} from "lucide-react" +import { + useDeleteDeployment, + useGateway, + useGraph, + useJobs, + usePrograms, + useServiceAction, + useServices, + useSetReach, +} from "@/services/api/hooks" +import { ConfirmModal } from "@/components/ConfirmModal" + +// A spatial, interactive control surface for the whole box. Lanes read left→right: +// leaf tools/jobs, then the service+static spine where every dependency line lives, +// then the exposure lane (gateway → internet). Manipulating the diagram mutates +// config and converges: +// • drag a node's edge to Internet → reach: public (+ apply) +// • drag a node's edge to Gateway → reach: internal (+ apply) +// • delete a green line → downgrade to internal +// • delete a blue line → reach: off (services) +// • select a node + Delete → delete the deployment (with confirm) +// • right-click / ⋯ → per-deployment action menu +// • drag a node → rearrange (persisted); "Reset layout" restores the columns + +const LANES: Record = { + tool: { x: 0, title: "Tools", dim: true }, + job: { x: 230, title: "Jobs", dim: true }, + service: { x: 470, title: "Services", exposable: true }, + static: { x: 720, title: "Frontends", exposable: true }, +} +const PROG_X = -250 // source-only (undeployed) programs — left of everything +const GATEWAY_X = 990 +const INTERNET_X = 1220 +const ROW_H = 52 +const TOP = 70 + +const KIND_COLOR: Record = { + service: "#2ea043", + job: "#8957e5", + tool: "#388bfd", + static: "#39c5cf", +} + +const HANDLE = { opacity: 0, width: 8, height: 8 } as const + +// User's manual node arrangement. Positions default to the computed lane layout; +// any node the user drags is remembered here and survives refetches + reloads. +const POS_KEY = "castle-map-positions" +type PosMap = Record +function loadPositions(): PosMap { + try { + return JSON.parse(localStorage.getItem(POS_KEY) || "{}") as PosMap + } catch { + return {} + } +} +function savePositions(p: PosMap): void { + try { + localStorage.setItem(POS_KEY, JSON.stringify(p)) + } catch { + /* storage disabled — arrangement is session-only */ + } +} + +// A deployment card, split into two zones: a left "source" grip (the program it's +// realized from — a future click target for the program/info panel) and the main +// deployment body. The right handle is a connection *source* (drag it to an +// exposure target); the others exist only to anchor pre-built edges. +function MapNode({ id, data }: NodeProps) { + const d = data as { + label: string + kind: string + sub?: string + dim?: boolean + hub?: number + exposable?: boolean + program?: string | null + onMenu?: (x: number, y: number, name: string, kind: string) => void + onProgram?: (program: string) => void + } + const color = KIND_COLOR[d.kind] ?? "#8b949e" + return ( +
+ + {d.program ? ( + + ) : ( + // No program (inline infra) — a same-size, non-interactive cell with a + // muted dash so the grip column stays visually aligned across all cards. +
+ +
+ )} +
+
+ {d.label} +
+ {d.sub &&
{d.sub}
} + {typeof d.hub === "number" && d.hub > 0 && ( +
+ ← {d.hub} depend on this +
+ )} +
+ + + + +
+ ) +} + +// A source-only program (no deployment yet) — the palette for a future +// drag-onto-a-lane "deploy this" gesture. Rendered dashed to read as "not running." +function ProgramNode({ data }: NodeProps) { + const d = data as { label: string; placeholder?: boolean } + if (d.placeholder) { + return ( +
+ No source-only programs. Undeployed programs would appear here to drag onto a lane. +
+ ) + } + return ( +
+ + + {d.label} + +
+ ) +} + +// Gateway / Internet — the exposure targets. Target handle only (drop a line here). +function HubNode({ data }: NodeProps) { + const d = data as { label: string; kind: "gateway" | "internet"; sub?: string | null } + const Icon = d.kind === "internet" ? Globe : Router + const color = d.kind === "internet" ? "#2ea043" : "#58a6ff" + return ( +
+ + {d.label} + {d.sub && ( + + {d.sub} + + )} + + +
+ ) +} + +function LaneNode({ data }: NodeProps) { + const d = data as { label: string } + return ( +
{d.label}
+ ) +} + +const nodeTypes = { map: MapNode, hub: HubNode, lane: LaneNode, program: ProgramNode } + +interface Built { + nodes: Node[] + edges: Edge[] + kindOf: Record + reachOf: Record +} + +interface MenuItem { + label: string + icon: typeof Globe + danger?: boolean + onClick: () => void +} + +export function SystemMapPage() { + const { data: graph } = useGraph() + const { data: services } = useServices() + const { data: jobs } = useJobs() + const { data: programs } = usePrograms() + const { data: gateway } = useGateway() + + const setReach = useSetReach() + const deleteDeployment = useDeleteDeployment() + const serviceAction = useServiceAction() + const navigate = useNavigate() + + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const [banner, setBanner] = useState<{ type: "info" | "error"; text: string } | null>(null) + const [confirmDel, setConfirmDel] = useState<{ name: string; kind: string } | null>(null) + const [menu, setMenu] = useState<{ x: number; y: number; name: string; kind: string } | null>(null) + // Lasso mode: when on, a plain left-drag draws a selection box (pan moves to + // middle/right mouse). When off, left-drag pans and Shift-drag still lassos. + const [lasso, setLasso] = useState(false) + + const openMenu = useCallback( + (x: number, y: number, name: string, kind: string) => setMenu({ x, y, name, kind }), + [], + ) + const openProgram = useCallback((program: string) => navigate(`/programs/${program}`), [navigate]) + + // Kind lookup for handlers (which config section to write). Kept in a ref so the + // stable callbacks below don't need it in their dep arrays. + const kindRef = useRef>({}) + const reachRef = useRef>({}) + // Manual node positions (persisted), and the react-flow instance for fitView. + const posRef = useRef(null) + if (posRef.current == null) posRef.current = loadPositions() + const rfRef = useRef | null>(null) + + const built = useMemo(() => { + if (!graph) return { nodes: [], edges: [], kindOf: {}, reachOf: {} } + + const portOf = new Map((services ?? []).map((s) => [s.id, s.port])) + const scheduleOf = new Map((jobs ?? []).map((j) => [j.id, j.schedule])) + // A deployment only links to a program if that program actually exists in the + // catalog (has source). Infra like mqtt/postgres are inline container/compose + // services — the graph fills `.program` with the deployment's own name as a + // fallback, but there's no ProgramSpec, so no grip/link. + const catalog = new Set((programs ?? []).map((p) => p.id)) + const routes = gateway?.routes ?? [] + const exposed = new Set(routes.map((r) => r.name).filter(Boolean) as string[]) + const publicSet = new Set( + routes.filter((r) => r.public_url).map((r) => r.name).filter(Boolean) as string[], + ) + + // A job shows its cron; a service shows its port. + const subFor = (n: (typeof graph.nodes)[number]): string | undefined => { + if (n.kind === "job") return scheduleOf.get(n.name) ?? undefined + const port = portOf.get(n.name) + return port ? `:${port}` : undefined + } + + const kindOf: Record = {} + const perLane: Record = {} + const nodes: Node[] = graph.nodes + .filter((n) => LANES[n.kind]) + .sort((a, b) => b.depended_on_by - a.depended_on_by || a.name.localeCompare(b.name)) + .map((n) => { + const lane = LANES[n.kind] + const row = (perLane[n.kind] = (perLane[n.kind] ?? 0) + 1) - 1 + kindOf[n.name] = n.kind + return { + id: n.name, + type: "map", + position: { x: lane.x, y: TOP + row * ROW_H }, + deletable: true, + data: { + label: n.name, + kind: n.kind, + sub: subFor(n), + dim: lane.dim, + hub: n.depended_on_by, + exposable: !!lane.exposable, + program: n.program && catalog.has(n.program) ? n.program : null, + }, + } satisfies Node + }) + + // Undeployed (source-only) programs — the drag-to-deploy palette. Today this + // is usually empty (every program is deployed); render a placeholder so the + // affordance is visible. + const undeployed = (programs ?? []).filter((p) => (p.deployments?.length ?? 0) === 0) + undeployed.forEach((p, i) => { + nodes.push({ + id: `__prog_${p.id}__`, + type: "program", + position: { x: PROG_X, y: TOP + i * ROW_H }, + draggable: false, + deletable: false, + data: { label: p.id }, + }) + }) + if (undeployed.length === 0) { + nodes.push({ + id: "__prog_empty__", + type: "program", + position: { x: PROG_X, y: TOP }, + selectable: false, + draggable: false, + deletable: false, + data: { label: "", placeholder: true }, + }) + } + + const maxRows = Math.max(1, ...Object.values(perLane)) + const midY = TOP + ((maxRows - 1) * ROW_H) / 2 + // Gateway shows its host origin; Internet shows the public (tunnel) zone. + const gwHost = gateway?.domain ?? (gateway?.hostname ? `${gateway.hostname}:${gateway.port}` : null) + const hubs: [string, number, "gateway" | "internet", string, string | null][] = [ + ["__gateway__", GATEWAY_X, "gateway", "LAN", gwHost], + ["__internet__", INTERNET_X, "internet", "Internet", gateway?.public_domain ?? null], + ] + for (const [id, x, kind, label, sub] of hubs) { + nodes.push({ + id, + type: "hub", + position: { x, y: midY }, + deletable: false, + data: { label, kind, sub }, + }) + } + + const headers: [number, string][] = [ + [PROG_X, "Programs"], + ...Object.values(LANES).map((l) => [l.x, l.title] as [number, string]), + [GATEWAY_X, "Exposure"], + ] + for (const [x, title] of headers) { + nodes.push({ + id: `__lane_${title}__`, + type: "lane", + position: { x, y: TOP - 40 }, + data: { label: title }, + selectable: false, + draggable: false, + deletable: false, + }) + } + + const present = new Set(nodes.map((n) => n.id)) + const edges: Edge[] = [] + + for (const e of graph.edges) { + if (e.kind !== "deployment") continue + if (!present.has(e.src) || !present.has(e.dst)) continue + edges.push({ + id: `dep:${e.src}->${e.dst}`, + source: e.src, + target: e.dst, + sourceHandle: "ls", + targetHandle: "rt", + label: e.bind ?? undefined, + deletable: false, + style: { stroke: "#6e7681", strokeWidth: 1.5 }, + labelStyle: { fill: "#8b949e", fontSize: 10 }, + labelBgStyle: { fill: "#0d1117" }, + }) + } + + // "Same program" links — a program realized as more than one deployment (e.g. + // protonmail as a tool AND a job) draws a dashed link between its siblings. + const byProgram = new Map() + for (const n of graph.nodes) { + if (!n.program || !present.has(n.name) || !LANES[n.kind]) continue + const arr = byProgram.get(n.program) ?? [] + arr.push(n.name) + byProgram.set(n.program, arr) + } + for (const [program, members] of byProgram) { + if (members.length < 2) continue + const ordered = [...members].sort((a, b) => LANES[kindOf[a]].x - LANES[kindOf[b]].x) + for (let i = 0; i < ordered.length - 1; i++) { + edges.push({ + id: `prog:${program}:${ordered[i]}-${ordered[i + 1]}`, + source: ordered[i], + target: ordered[i + 1], + sourceHandle: "rs", + targetHandle: "lt", + label: program, + deletable: false, + style: { stroke: "#bc8cff", strokeWidth: 1.25, strokeDasharray: "4 3" }, + labelStyle: { fill: "#bc8cff", fontSize: 10 }, + labelBgStyle: { fill: "#0d1117" }, + }) + } + } + + // Reach is modal: one line per exposed node. public → a single green line to + // Internet; internal → a single blue line to Gateway. Dragging a new line to + // the other target switches the mode; deleting the line removes exposure. + const reachOf: Record = {} + for (const name of exposed) { + if (!present.has(name)) continue + const isPub = publicSet.has(name) + reachOf[name] = isPub ? "public" : "internal" + edges.push({ + id: `exp:${name}`, + source: name, + target: isPub ? "__internet__" : "__gateway__", + sourceHandle: "rs", + targetHandle: "lt", + animated: isPub, + deletable: true, + style: { stroke: isPub ? "#2ea043" : "#58a6ff", strokeWidth: 1.75 }, + }) + } + + return { nodes, edges, kindOf, reachOf } + }, [graph, services, jobs, programs, gateway]) + + // Turn a Built into live nodes: inject the (stable) menu/program openers into + // deployment nodes, and override each position with the user's saved layout + // (falling back to the computed lane position). + const materialize = useCallback( + (b: Built): Node[] => + b.nodes.map((n) => ({ + ...n, + position: posRef.current![n.id] ?? n.position, + data: n.type === "map" ? { ...n.data, onMenu: openMenu, onProgram: openProgram } : n.data, + })), + [openMenu, openProgram], + ) + + // Server is the source of truth: whenever derived data changes, resync the + // canvas (preserving the user's dragged positions via posRef). Interactions fire + // mutations that invalidate the queries, which flow back through here. + useEffect(() => { + kindRef.current = built.kindOf + reachRef.current = built.reachOf + setNodes(materialize(built)) + setEdges(built.edges) + }, [built, setNodes, setEdges, materialize]) + + // Remember where the user dropped a node (persist across refetches + reloads). + const onNodeDragStop = useCallback((_e: MouseEvent | TouchEvent, _n: Node, dragged: Node[]) => { + for (const nd of dragged) posRef.current![nd.id] = nd.position + savePositions(posRef.current!) + }, []) + + // Snap everything back to the computed lane layout. + const resetLayout = useCallback(() => { + posRef.current = {} + savePositions({}) + setNodes(materialize(built)) + requestAnimationFrame(() => rfRef.current?.fitView({ padding: 0.1 })) + }, [built, materialize, setNodes]) + + const applyReach = useCallback( + (name: string, kind: string, reach: "off" | "internal" | "public") => { + setBanner({ type: "info", text: `${name} → reach: ${reach} — applying…` }) + setReach.mutate( + { name, kind, reach }, + { + onSuccess: () => setBanner({ type: "info", text: `${name} is now ${reach}.` }), + onError: (e) => + setBanner({ type: "error", text: `${name}: ${e instanceof Error ? e.message : String(e)}` }), + }, + ) + }, + [setReach], + ) + + // Drop a connection on a hub → set exposure. Anything else is ignored. + const onConnect = useCallback( + (c: Connection) => { + if (!c.source) return + const kind = kindRef.current[c.source] + if (!kind) return + if (c.target === "__internet__") applyReach(c.source, kind, "public") + else if (c.target === "__gateway__") applyReach(c.source, kind, "internal") + }, + [applyReach], + ) + + const isValidConnection = useCallback((c: Connection | Edge) => { + const kind = c.source ? kindRef.current[c.source] : undefined + const exposable = kind === "service" || kind === "static" + return exposable && (c.target === "__internet__" || c.target === "__gateway__") + }, []) + + // Deleting a node's exposure line removes exposure: a service drops to off; a + // static (always served) can't go off, so a public static drops to internal and + // an internal one isn't deletable (blocked in onBeforeDelete). + const onEdgesDelete = useCallback( + (deleted: Edge[]) => { + for (const e of deleted) { + if (!e.id.startsWith("exp:")) continue + const kind = kindRef.current[e.source] + if (kind === "static") { + if (reachRef.current[e.source] === "public") applyReach(e.source, kind, "internal") + } else { + applyReach(e.source, kind, "off") + } + } + }, + [applyReach], + ) + + // Intercept deletion: allow exposure edges through (onEdgesDelete handles them), + // route node deletion to a confirm modal and block the immediate removal. + const onBeforeDelete = useCallback( + async ({ nodes: delNodes, edges: delEdges }: { nodes: Node[]; edges: Edge[] }) => { + const target = delNodes.find((n) => n.type === "map") + if (target) { + const kind = kindRef.current[target.id] + if (kind) setConfirmDel({ name: target.id, kind }) + } + const allowedEdges = delEdges.filter((e) => { + if (!e.id.startsWith("exp:")) return false + // A static's internal line can't be removed — it's always served. + if (kindRef.current[e.source] === "static") return reachRef.current[e.source] === "public" + return true + }) + return { nodes: [], edges: allowedEdges } + }, + [], + ) + + const doDelete = useCallback(() => { + if (!confirmDel) return + const { name, kind } = confirmDel + setConfirmDel(null) + setBanner({ type: "info", text: `Deleting ${name}…` }) + deleteDeployment.mutate( + { name, kind }, + { + onSuccess: () => setBanner({ type: "info", text: `Deleted ${name}.` }), + onError: (e) => + setBanner({ type: "error", text: `${name}: ${e instanceof Error ? e.message : String(e)}` }), + }, + ) + }, [confirmDel, deleteDeployment]) + + // Right-click a deployment → same menu as the hover kebab. + const onNodeContextMenu = useCallback( + (e: React.MouseEvent, node: Node) => { + if (node.type !== "map") return + e.preventDefault() + openMenu(e.clientX, e.clientY, node.id, (node.data as { kind: string }).kind) + }, + [openMenu], + ) + + const restart = useCallback( + (name: string) => { + setBanner({ type: "info", text: `Restarting ${name}…` }) + serviceAction.mutate( + { name, action: "restart" }, + { + onSuccess: () => setBanner({ type: "info", text: `${name} restarted.` }), + onError: (err) => + setBanner({ type: "error", text: `${name}: ${err instanceof Error ? err.message : String(err)}` }), + }, + ) + }, + [serviceAction], + ) + + // The menu's actions for the right-clicked/kebabbed deployment. Exposure items + // are contextual on the current reach (from reachRef); statics are always served. + const menuItems = useMemo(() => { + if (!menu) return [] + const { name, kind } = menu + const close = () => setMenu(null) + const path = kind === "job" ? `/jobs/${name}` : kind === "tool" ? `/tools/${name}` : `/services/${name}` + const items: MenuItem[] = [ + { label: "Open", icon: ExternalLink, onClick: () => (close(), navigate(path)) }, + ] + if (kind === "service") items.push({ label: "Restart", icon: RotateCw, onClick: () => (close(), restart(name)) }) + if (kind === "service" || kind === "static") { + const reach = built.reachOf[name] // internal | public | undefined(off) + if (reach !== "public") + items.push({ label: "Publish to internet", icon: Globe, onClick: () => (close(), applyReach(name, kind, "public")) }) + if (reach !== "internal") + items.push({ label: "Restrict to LAN", icon: Router, onClick: () => (close(), applyReach(name, kind, "internal")) }) + if (kind === "service" && reach) + items.push({ label: "Make private (off)", icon: Lock, onClick: () => (close(), applyReach(name, kind, "off")) }) + } + items.push({ + label: `Delete ${kind}`, + icon: Trash2, + danger: true, + onClick: () => (close(), setConfirmDel({ name, kind })), + }) + return items + }, [menu, built, navigate, restart, applyReach]) + + const busy = setReach.isPending || deleteDeployment.isPending || serviceAction.isPending + + return ( +
+ (rfRef.current = inst)} + nodeTypes={nodeTypes} + fitView + fitViewOptions={{ padding: 0.1 }} + deleteKeyCode={["Backspace", "Delete"]} + nodesConnectable + selectionOnDrag={lasso} + panOnDrag={lasso ? [1, 2] : true} + selectionMode={SelectionMode.Partial} + selectionKeyCode="Shift" + proOptions={{ hideAttribution: true }} + colorMode="dark" + > + + + setLasso((v) => !v)} + title={lasso ? "Lasso select: on — drag to box-select (pan = middle/right mouse)" : "Lasso select: off — drag pans (Shift-drag to box-select)"} + style={lasso ? { background: "var(--primary)", color: "#fff" } : undefined} + > + + + + + + + + {(banner || busy) && ( +
+ {busy && } + {banner?.text ?? "Applying…"} +
+ )} +
+ + {menu && ( + setMenu(null)} /> + )} + + setConfirmDel(null)} + /> +
+ ) +} + +// The deployment popup — a fixed-position action list at the cursor/kebab, with a +// backdrop that closes it. Clamped to stay on-screen. +function NodeMenu({ + menu, + items, + onClose, +}: { + menu: { x: number; y: number; name: string; kind: string } + items: MenuItem[] + onClose: () => void +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose() + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [onClose]) + + const left = Math.min(menu.x, window.innerWidth - 200) + const top = Math.min(menu.y, window.innerHeight - (items.length * 34 + 40)) + return ( + <> +
(e.preventDefault(), onClose())} /> +
+
+ {menu.name} +
+
+ {items.map((it) => { + const Icon = it.icon + return ( + + ) + })} +
+ + ) +} + +function Legend() { + const items = [ + { c: "#6e7681", label: "requires (dependency)" }, + { c: "#bc8cff", label: "same program", dashed: true }, + { c: "#58a6ff", label: "internal — on your LAN" }, + { c: "#2ea043", label: "public — on the internet" }, + ] + return ( +
+ {items.map((i) => ( +
+ + {i.label} +
+ ))} +
+ Drag a node → Internet to publish, →{" "} + LAN for internal. +
+ Select a line or node + Del to remove. +
+ Shift-drag to box-select; drag any selected node to move them together. +
+
+ ) +} diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx index 5d33bcc..eb0b556 100644 --- a/app/src/router/routes.tsx +++ b/app/src/router/routes.tsx @@ -8,6 +8,7 @@ import { Programs } from "@/pages/Programs" import { GatewayPage } from "@/pages/GatewayPage" import { MeshPage } from "@/pages/MeshPage" import { GraphPage } from "@/pages/GraphPage" +import { SystemMapPage } from "@/pages/SystemMap" import { ServiceDetailPage } from "@/pages/ServiceDetail" import { ScheduledDetailPage } from "@/pages/ScheduledDetail" import { ToolDetailPage } from "@/pages/ToolDetail" @@ -28,6 +29,7 @@ export const router = createBrowserRouter([ { path: "gateway", element: }, { path: "mesh", element: }, { path: "graph", element: }, + { path: "map", element: }, { path: "services/:name", element: }, { path: "jobs/:name", element: }, { path: "tools/:name", element: }, diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index d063490..e7bcf47 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -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 = { + 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("/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({