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:
@@ -15,6 +15,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"@xyflow/react": "^12.11.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
|
|||||||
193
app/pnpm-lock.yaml
generated
193
app/pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@xterm/xterm':
|
'@xterm/xterm':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 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:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -651,6 +654,24 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
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':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -739,6 +760,22 @@ packages:
|
|||||||
'@xterm/xterm@6.0.0':
|
'@xterm/xterm@6.0.0':
|
||||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
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:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -792,6 +829,9 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
classcat@5.0.5:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -820,6 +860,44 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1322,6 +1400,11 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
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:
|
vite@7.3.1:
|
||||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1387,6 +1470,21 @@ packages:
|
|||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
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:
|
snapshots:
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
@@ -1828,6 +1926,27 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@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/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -1951,6 +2070,31 @@ snapshots:
|
|||||||
|
|
||||||
'@xterm/xterm@6.0.0': {}
|
'@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):
|
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -2004,6 +2148,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
classcat@5.0.5: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
@@ -2026,6 +2172,42 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
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:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -2498,6 +2680,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
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):
|
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
@@ -2527,3 +2713,10 @@ snapshots:
|
|||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Server,
|
Server,
|
||||||
Share2,
|
Share2,
|
||||||
|
Map as MapIcon,
|
||||||
Network,
|
Network,
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
@@ -40,6 +41,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
|
|||||||
},
|
},
|
||||||
{ to: "/programs", label: "Programs", icon: Package },
|
{ to: "/programs", label: "Programs", icon: Package },
|
||||||
{ to: "/graph", label: "Graph", icon: Network },
|
{ to: "/graph", label: "Graph", icon: Network },
|
||||||
|
{ to: "/map", label: "System Map", icon: MapIcon },
|
||||||
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,15 @@ select option {
|
|||||||
background-color: var(--card);
|
background-color: var(--card);
|
||||||
color: var(--foreground);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
796
app/src/pages/SystemMap.tsx
Normal file
796
app/src/pages/SystemMap.tsx
Normal file
@@ -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<string, { x: number; title: string; dim?: boolean; exposable?: boolean }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, { x: number; y: number }>
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="group relative flex items-stretch overflow-hidden rounded-md border bg-[var(--card)] text-xs shadow-sm"
|
||||||
|
style={{ borderColor: color, width: 156, opacity: d.dim ? 0.55 : 1 }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute right-0.5 top-0.5 z-10 rounded bg-[var(--card)]/80 p-0.5 text-[var(--muted)] opacity-0 hover:text-[var(--card-foreground)] group-hover:opacity-100"
|
||||||
|
title="Actions"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const r = e.currentTarget.getBoundingClientRect()
|
||||||
|
d.onMenu?.(r.right, r.bottom, id, d.kind)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVertical size={12} />
|
||||||
|
</button>
|
||||||
|
{d.program ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-6 shrink-0 items-center justify-center border-r transition-[filter] hover:brightness-150"
|
||||||
|
style={{ borderColor: color, background: `${color}1f`, color }}
|
||||||
|
title={`Open program: ${d.program}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
d.onProgram?.(d.program!)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Package size={11} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
// No program (inline infra) — a same-size, non-interactive cell with a
|
||||||
|
// muted dash so the grip column stays visually aligned across all cards.
|
||||||
|
<div
|
||||||
|
className="flex w-6 shrink-0 items-center justify-center border-r opacity-60"
|
||||||
|
style={{ borderColor: color, background: `${color}12`, color: "var(--muted)" }}
|
||||||
|
title="no program (no source)"
|
||||||
|
>
|
||||||
|
<Minus size={11} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1 px-2 py-1.5">
|
||||||
|
<div className="truncate font-medium text-[var(--card-foreground)]" title={d.label}>
|
||||||
|
{d.label}
|
||||||
|
</div>
|
||||||
|
{d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>}
|
||||||
|
{typeof d.hub === "number" && d.hub > 0 && (
|
||||||
|
<div className="text-[10px]" style={{ color }}>
|
||||||
|
← {d.hub} depend on this
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Handle
|
||||||
|
id="rs"
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
isConnectable={!!d.exposable}
|
||||||
|
style={{ ...HANDLE, opacity: d.exposable ? 0.9 : 0, background: color }}
|
||||||
|
/>
|
||||||
|
<Handle id="lt" type="target" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
|
||||||
|
<Handle id="ls" type="source" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
|
||||||
|
<Handle id="rt" type="target" position={Position.Right} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="w-[156px] rounded-md border border-dashed border-[var(--border)] px-2 py-2 text-[10px] leading-snug text-[var(--muted)]">
|
||||||
|
No source-only programs. Undeployed programs would appear here to drag onto a lane.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex w-[156px] items-center gap-1.5 rounded-md border border-dashed border-[var(--muted)] bg-[var(--card)] px-2 py-1.5 text-xs text-[var(--muted)]">
|
||||||
|
<Package size={12} />
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={d.label}>
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center gap-0.5 rounded-lg border-2 bg-[var(--card)] px-3 py-3 text-xs font-semibold"
|
||||||
|
style={{ borderColor: color, width: 130, color }}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
{d.label}
|
||||||
|
{d.sub && (
|
||||||
|
<span className="max-w-[118px] truncate font-mono text-[9px] font-normal text-[var(--muted)]" title={d.sub}>
|
||||||
|
{d.sub}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Handle id="lt" type="target" position={Position.Left} style={{ ...HANDLE, opacity: 0.9, background: color }} />
|
||||||
|
<Handle id="rs" type="source" position={Position.Right} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LaneNode({ data }: NodeProps) {
|
||||||
|
const d = data as { label: string }
|
||||||
|
return (
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-[var(--muted)]">{d.label}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypes = { map: MapNode, hub: HubNode, lane: LaneNode, program: ProgramNode }
|
||||||
|
|
||||||
|
interface Built {
|
||||||
|
nodes: Node[]
|
||||||
|
edges: Edge[]
|
||||||
|
kindOf: Record<string, string>
|
||||||
|
reachOf: Record<string, "internal" | "public">
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Node>([])
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
|
||||||
|
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<Record<string, string>>({})
|
||||||
|
const reachRef = useRef<Record<string, "internal" | "public">>({})
|
||||||
|
// Manual node positions (persisted), and the react-flow instance for fitView.
|
||||||
|
const posRef = useRef<PosMap | null>(null)
|
||||||
|
if (posRef.current == null) posRef.current = loadPositions()
|
||||||
|
const rfRef = useRef<ReactFlowInstance<Node, Edge> | null>(null)
|
||||||
|
|
||||||
|
const built = useMemo<Built>(() => {
|
||||||
|
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<string, string> = {}
|
||||||
|
const perLane: Record<string, number> = {}
|
||||||
|
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<string, string[]>()
|
||||||
|
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<string, "internal" | "public"> = {}
|
||||||
|
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<MenuItem[]>(() => {
|
||||||
|
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 (
|
||||||
|
<div className="h-[calc(100vh-3.5rem)] w-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
|
onEdgesDelete={onEdgesDelete}
|
||||||
|
onBeforeDelete={onBeforeDelete}
|
||||||
|
isValidConnection={isValidConnection}
|
||||||
|
onInit={(inst) => (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"
|
||||||
|
>
|
||||||
|
<Background color="#30363d" gap={20} />
|
||||||
|
<Controls showInteractive={false}>
|
||||||
|
<ControlButton
|
||||||
|
onClick={() => 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}
|
||||||
|
>
|
||||||
|
<BoxSelect size={12} />
|
||||||
|
</ControlButton>
|
||||||
|
<ControlButton onClick={resetLayout} title="Reset layout">
|
||||||
|
<LayoutGrid size={12} />
|
||||||
|
</ControlButton>
|
||||||
|
</Controls>
|
||||||
|
<Legend />
|
||||||
|
{(banner || busy) && (
|
||||||
|
<div
|
||||||
|
className={`absolute right-3 top-3 z-10 flex items-center gap-2 rounded-md border px-3 py-2 text-xs ${
|
||||||
|
banner?.type === "error"
|
||||||
|
? "border-red-800 bg-red-900/40 text-red-200"
|
||||||
|
: "border-[var(--border)] bg-[var(--card)]/95 text-[var(--card-foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{busy && <Loader2 size={13} className="animate-spin" />}
|
||||||
|
{banner?.text ?? "Applying…"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
|
{menu && (
|
||||||
|
<NodeMenu menu={menu} items={menuItems} onClose={() => setMenu(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!confirmDel}
|
||||||
|
danger
|
||||||
|
title={`Delete ${confirmDel?.name}?`}
|
||||||
|
body={`This removes the ${confirmDel?.kind} deployment from castle.yaml and tears it down. The program's source is kept.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={doDelete}
|
||||||
|
onCancel={() => setConfirmDel(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={onClose} onContextMenu={(e) => (e.preventDefault(), onClose())} />
|
||||||
|
<div
|
||||||
|
className="fixed z-50 w-[186px] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--card)] py-1 text-xs shadow-xl"
|
||||||
|
style={{ left, top }}
|
||||||
|
>
|
||||||
|
<div className="truncate px-3 py-1.5 font-mono text-[10px] text-[var(--muted)]" title={menu.name}>
|
||||||
|
{menu.name}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[var(--border)]" />
|
||||||
|
{items.map((it) => {
|
||||||
|
const Icon = it.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={it.label}
|
||||||
|
onClick={it.onClick}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-white/5 ${
|
||||||
|
it.danger ? "text-red-400" : "text-[var(--card-foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="absolute bottom-3 right-3 z-10 flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--card)]/90 px-3 py-2 text-[11px] text-[var(--muted)]">
|
||||||
|
{items.map((i) => (
|
||||||
|
<div key={i.label} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-block w-5 shrink-0"
|
||||||
|
style={
|
||||||
|
i.dashed
|
||||||
|
? { borderTop: `2px dashed ${i.c}` }
|
||||||
|
: { height: 2, borderRadius: 2, background: i.c }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{i.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-1 border-t border-[var(--border)] pt-1.5 leading-relaxed">
|
||||||
|
Drag a node → <span className="text-[#2ea043]">Internet</span> to publish, →{" "}
|
||||||
|
<span className="text-[#58a6ff]">LAN</span> for internal.
|
||||||
|
<br />
|
||||||
|
Select a line or node + <kbd className="rounded bg-black/40 px-1">Del</kbd> to remove.
|
||||||
|
<br />
|
||||||
|
<kbd className="rounded bg-black/40 px-1">Shift</kbd>-drag to box-select; drag any selected node to move them together.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Programs } from "@/pages/Programs"
|
|||||||
import { GatewayPage } from "@/pages/GatewayPage"
|
import { GatewayPage } from "@/pages/GatewayPage"
|
||||||
import { MeshPage } from "@/pages/MeshPage"
|
import { MeshPage } from "@/pages/MeshPage"
|
||||||
import { GraphPage } from "@/pages/GraphPage"
|
import { GraphPage } from "@/pages/GraphPage"
|
||||||
|
import { SystemMapPage } from "@/pages/SystemMap"
|
||||||
import { ServiceDetailPage } from "@/pages/ServiceDetail"
|
import { ServiceDetailPage } from "@/pages/ServiceDetail"
|
||||||
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
|
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
|
||||||
import { ToolDetailPage } from "@/pages/ToolDetail"
|
import { ToolDetailPage } from "@/pages/ToolDetail"
|
||||||
@@ -28,6 +29,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "gateway", element: <GatewayPage /> },
|
{ path: "gateway", element: <GatewayPage /> },
|
||||||
{ path: "mesh", element: <MeshPage /> },
|
{ path: "mesh", element: <MeshPage /> },
|
||||||
{ path: "graph", element: <GraphPage /> },
|
{ path: "graph", element: <GraphPage /> },
|
||||||
|
{ path: "map", element: <SystemMapPage /> },
|
||||||
{ path: "services/:name", element: <ServiceDetailPage /> },
|
{ path: "services/:name", element: <ServiceDetailPage /> },
|
||||||
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
||||||
{ path: "tools/:name", element: <ToolDetailPage /> },
|
{ path: "tools/:name", element: <ToolDetailPage /> },
|
||||||
|
|||||||
@@ -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() {
|
export function useProgramAction() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Reference in New Issue
Block a user