Advanced pages. Model-driven controls.
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-serialize": "^0.14.0",
|
"@xterm/addon-serialize": "^0.14.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
147
web/pnpm-lock.yaml
generated
147
web/pnpm-lock.yaml
generated
@@ -56,6 +56,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.2
|
||||||
|
version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(immer@11.1.11)(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
|
||||||
@@ -1595,6 +1598,9 @@ packages:
|
|||||||
'@types/d3-color@3.1.3':
|
'@types/d3-color@3.1.3':
|
||||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
'@types/d3-ease@3.0.2':
|
'@types/d3-ease@3.0.2':
|
||||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
@@ -1607,6 +1613,9 @@ packages:
|
|||||||
'@types/d3-scale@4.0.9':
|
'@types/d3-scale@4.0.9':
|
||||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11':
|
||||||
|
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||||
|
|
||||||
'@types/d3-shape@3.1.8':
|
'@types/d3-shape@3.1.8':
|
||||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
@@ -1616,6 +1625,12 @@ packages:
|
|||||||
'@types/d3-timer@3.0.2':
|
'@types/d3-timer@3.0.2':
|
||||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
|
'@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/debug@4.1.12':
|
'@types/debug@4.1.12':
|
||||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||||
|
|
||||||
@@ -1766,6 +1781,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.2':
|
||||||
|
resolution: {integrity: sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==}
|
||||||
|
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.79':
|
||||||
|
resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1878,6 +1909,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'}
|
||||||
@@ -1933,6 +1967,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
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:
|
d3-ease@3.0.1:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1953,6 +1995,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1969,6 +2015,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
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'}
|
||||||
|
|
||||||
data-urls@6.0.1:
|
data-urls@6.0.1:
|
||||||
resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==}
|
resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3114,6 +3170,21 @@ packages:
|
|||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
zwitch@2.0.4:
|
zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
@@ -4475,6 +4546,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-color@3.1.3': {}
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/d3-ease@3.0.2': {}
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
'@types/d3-interpolate@3.0.4':
|
'@types/d3-interpolate@3.0.4':
|
||||||
@@ -4487,6 +4562,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-time': 3.0.4
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11': {}
|
||||||
|
|
||||||
'@types/d3-shape@3.1.8':
|
'@types/d3-shape@3.1.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-path': 3.1.1
|
'@types/d3-path': 3.1.1
|
||||||
@@ -4495,6 +4572,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-timer@3.0.2': {}
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
|
'@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/debug@4.1.12':
|
'@types/debug@4.1.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@@ -4690,6 +4776,31 @@ snapshots:
|
|||||||
|
|
||||||
'@xterm/xterm@6.0.0': {}
|
'@xterm/xterm@6.0.0': {}
|
||||||
|
|
||||||
|
'@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(immer@11.1.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@xyflow/system': 0.0.79
|
||||||
|
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.13)(immer@11.1.11)(react@19.2.4)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.13
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.13)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.79':
|
||||||
|
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.15.0):
|
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
@@ -4789,6 +4900,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:
|
||||||
@@ -4835,6 +4948,13 @@ snapshots:
|
|||||||
|
|
||||||
d3-color@3.1.0: {}
|
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-ease@3.0.1: {}
|
||||||
|
|
||||||
d3-format@3.1.2: {}
|
d3-format@3.1.2: {}
|
||||||
@@ -4853,6 +4973,8 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-time-format: 4.1.0
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-path: 3.1.0
|
d3-path: 3.1.0
|
||||||
@@ -4867,6 +4989,23 @@ snapshots:
|
|||||||
|
|
||||||
d3-timer@3.0.1: {}
|
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)
|
||||||
|
|
||||||
data-urls@6.0.1:
|
data-urls@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -6191,4 +6330,12 @@ snapshots:
|
|||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@19.2.13)(immer@11.1.11)(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.13
|
||||||
|
immer: 11.1.11
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
zwitch@2.0.4: {}
|
zwitch@2.0.4: {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning } from 'lucide-react';
|
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -66,6 +66,14 @@ export function AppSidebar() {
|
|||||||
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const advancedItems = [
|
||||||
|
{ to: '/central/advanced/haproxy', icon: Network, label: 'HAProxy', daemon: 'haproxy' as const },
|
||||||
|
{ to: '/central/advanced/dnsmasq', icon: Wifi, label: 'dnsmasq', daemon: 'dnsmasq' as const },
|
||||||
|
{ to: '/central/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
|
||||||
|
{ to: '/central/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
||||||
|
{ to: '/central/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="sidebar" collapsible="icon">
|
<Sidebar variant="sidebar" collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@@ -82,7 +90,7 @@ export function AppSidebar() {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{state === 'collapsed' ? (
|
{state === 'collapsed' ? (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{[...domainsItems, ...centralItems].map((item) => {
|
{[...domainsItems, ...centralItems, ...advancedItems].map((item) => {
|
||||||
const daemon = 'daemon' in item ? (item as any).daemon : undefined;
|
const daemon = 'daemon' in item ? (item as any).daemon : undefined;
|
||||||
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
|
||||||
return (
|
return (
|
||||||
@@ -149,6 +157,32 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Advanced</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{advancedItems.map(({ to, icon: Icon, label, daemon }) => {
|
||||||
|
const active = centralStatus?.daemons?.[daemon]?.active;
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={to}>
|
||||||
|
<NavLink to={to}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<SidebarMenuButton isActive={isActive}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{active !== undefined && (
|
||||||
|
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|||||||
@@ -3,52 +3,24 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Card, CardContent } from './ui/card';
|
import { Card, CardContent } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input, Label } from './ui';
|
import { Input, Label } from './ui';
|
||||||
import { Alert, AlertDescription } from './ui/alert';
|
|
||||||
import { Textarea } from './ui/textarea';
|
|
||||||
import { Switch } from './ui/switch';
|
import { Switch } from './ui/switch';
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
|
|
||||||
import {
|
import {
|
||||||
Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw,
|
Globe, Loader2,
|
||||||
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
|
Plus, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useHaproxy } from '../hooks/useHaproxy';
|
|
||||||
import { useDomains } from '../hooks/useDomains';
|
import { useDomains } from '../hooks/useDomains';
|
||||||
import { useDdns } from '../hooks/useDdns';
|
import { useDdns } from '../hooks/useDdns';
|
||||||
import { useCert } from '../hooks/useCert';
|
import { useCert } from '../hooks/useCert';
|
||||||
|
import { useVpn } from '../hooks/useVpn';
|
||||||
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||||
import { usePageHelp } from '../hooks/usePageHelp';
|
import { usePageHelp } from '../hooks/usePageHelp';
|
||||||
import type { RegisteredDomain } from '../services/api/networking';
|
import { domainsApi } from '../services/api/networking';
|
||||||
import type { CertEntry } from '../services/api/cert';
|
import type { CertEntry } from '../services/api/cert';
|
||||||
import { domainsApi, haproxyApi } from '../services/api/networking';
|
import { DomainCard } from './domains/DomainCard';
|
||||||
|
|
||||||
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs">
|
|
||||||
{status === 'ok' && <CheckCircle className="h-3.5 w-3.5 text-green-500" />}
|
|
||||||
{status === 'error' && <AlertCircle className="h-3.5 w-3.5 text-red-500" />}
|
|
||||||
{status === 'na' && <CheckCircle className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
||||||
<span className="text-muted-foreground">{label}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTlsStatus(service: RegisteredDomain, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
|
||||||
const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
|
|
||||||
if (service.tls === 'passthrough' || service.tls === 'none' || backendType === 'tcp-passthrough' || backendType === 'dns-only') return 'na';
|
|
||||||
// Check exact match or wildcard coverage
|
|
||||||
if (certDomains.has(service.domain)) return 'ok';
|
|
||||||
const dotIdx = service.domain.indexOf('.');
|
|
||||||
if (dotIdx > 0) {
|
|
||||||
const parent = service.domain.slice(dotIdx + 1);
|
|
||||||
if (certDomains.has(parent)) return 'ok';
|
|
||||||
}
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
|
function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
|
||||||
// Exact match first
|
|
||||||
const exact = certs.find(c => c.cert.exists && c.domain === domain);
|
const exact = certs.find(c => c.cert.exists && c.domain === domain);
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
// Wildcard: check parent domain
|
|
||||||
const dotIdx = domain.indexOf('.');
|
const dotIdx = domain.indexOf('.');
|
||||||
if (dotIdx > 0) {
|
if (dotIdx > 0) {
|
||||||
const parent = domain.slice(dotIdx + 1);
|
const parent = domain.slice(dotIdx + 1);
|
||||||
@@ -57,27 +29,14 @@ function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRoutes(service: RegisteredDomain): boolean {
|
|
||||||
return (service.routes?.length ?? 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function getBackendType(service: RegisteredDomain): string {
|
|
||||||
if (hasRoutes(service)) return service.routes![0].backend.type;
|
|
||||||
return service.backend.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DomainsComponent() {
|
export function DomainsComponent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
|
||||||
generate: generateHaproxy, isGenerating: isHaproxyGenerating,
|
|
||||||
generateData: haproxyGenerateData, generateError: haproxyGenerateError,
|
|
||||||
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
|
|
||||||
} = useHaproxy();
|
|
||||||
const { domains, isLoading: isDomainsLoading } = useDomains();
|
const { domains, isLoading: isDomainsLoading } = useDomains();
|
||||||
const { status: ddnsStatus, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns();
|
const { status: ddnsStatus } = useDdns();
|
||||||
const { status: certStatus, provision: provisionCert, renew: renewCert } = useCert();
|
const { status: certStatus, provision: provisionCert, renew: renewCert } = useCert();
|
||||||
|
const { status: vpnStatus, peers: vpnPeers } = useVpn();
|
||||||
|
const { data: centralStatus } = useCentralStatus();
|
||||||
|
|
||||||
const provisionMutation = useMutation({
|
const provisionMutation = useMutation({
|
||||||
mutationFn: (domain: string) => provisionCert(domain),
|
mutationFn: (domain: string) => provisionCert(domain),
|
||||||
@@ -89,12 +48,7 @@ export function DomainsComponent() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
const [advancedConfig, setAdvancedConfig] = useState('');
|
|
||||||
const [loadingAdvanced, setLoadingAdvanced] = useState(false);
|
|
||||||
|
|
||||||
// Add form state
|
// Add form state
|
||||||
const [newDomain, setNewDomain] = useState('');
|
const [newDomain, setNewDomain] = useState('');
|
||||||
@@ -103,12 +57,24 @@ export function DomainsComponent() {
|
|||||||
const [newSubdomains, setNewSubdomains] = useState(false);
|
const [newSubdomains, setNewSubdomains] = useState(false);
|
||||||
const [gatewayMode, setGatewayMode] = useState<'none' | 'reverse-proxy' | 'passthrough'>('reverse-proxy');
|
const [gatewayMode, setGatewayMode] = useState<'none' | 'reverse-proxy' | 'passthrough'>('reverse-proxy');
|
||||||
|
|
||||||
|
// Derived data for cards
|
||||||
const certDomains = new Set<string>(
|
const certDomains = new Set<string>(
|
||||||
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
|
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const daemons = {
|
||||||
|
haproxy: centralStatus?.daemons?.haproxy?.active,
|
||||||
|
nftables: centralStatus?.daemons?.nftables?.active,
|
||||||
|
crowdsec: centralStatus?.daemons?.crowdsec?.active,
|
||||||
|
wireguard: centralStatus?.daemons?.wireguard?.active,
|
||||||
|
};
|
||||||
|
|
||||||
|
const vpnRunning = vpnStatus?.running ?? false;
|
||||||
|
const vpnConfigured = !!(vpnStatus || vpnPeers.length > 0);
|
||||||
|
const vpnPeerCount = vpnPeers.length;
|
||||||
|
|
||||||
const registerMutation = useMutation({
|
const registerMutation = useMutation({
|
||||||
mutationFn: (svc: Record<string, unknown>) => domainsApi.register(svc as any),
|
mutationFn: (svc: Parameters<typeof domainsApi.register>[0]) => domainsApi.register(svc),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['domains'] });
|
queryClient.invalidateQueries({ queryKey: ['domains'] });
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
@@ -116,47 +82,16 @@ export function DomainsComponent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ domain, updates }: { domain: string; updates: Record<string, unknown> }) =>
|
|
||||||
domainsApi.register({ ...(domains.find(d => d.domain === domain) ?? {}), ...updates } as any),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deregisterMutation = useMutation({
|
|
||||||
mutationFn: (domain: string) => domainsApi.deregister(domain),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
usePageHelp({
|
usePageHelp({
|
||||||
title: 'Domains',
|
title: 'Domains',
|
||||||
description: (
|
description: (
|
||||||
<p className="leading-relaxed">
|
<p className="leading-relaxed">
|
||||||
Each domain is managed by Wild Central. For each, configure whether it just resolves to an IP
|
Each domain is managed by Wild Central. The topology diagram shows how traffic
|
||||||
or routes through Central's gateway for DNS, proxy routing, TLS, and public exposure.
|
reaches your services — from the internet, your LAN, and VPN connections.
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleExpand = (domain: string) => {
|
|
||||||
setExpandedDomains(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(domain)) next.delete(domain); else next.add(domain);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowAdvanced = async () => {
|
|
||||||
if (!showAdvanced) {
|
|
||||||
setLoadingAdvanced(true);
|
|
||||||
try {
|
|
||||||
const result = await haproxyApi.getConfig();
|
|
||||||
setAdvancedConfig(result.content || '');
|
|
||||||
} catch { setAdvancedConfig('# Could not load HAProxy config'); }
|
|
||||||
setLoadingAdvanced(false);
|
|
||||||
}
|
|
||||||
setShowAdvanced(!showAdvanced);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -167,7 +102,7 @@ export function DomainsComponent() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold">Domains</h2>
|
<h2 className="text-2xl font-semibold">Domains</h2>
|
||||||
<p className="text-muted-foreground">Registered domains and their networking status</p>
|
<p className="text-muted-foreground">Registered domains and their networking topology</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
|
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
|
||||||
@@ -253,294 +188,24 @@ export function DomainsComponent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Domain Cards */}
|
{/* Domain Cards */}
|
||||||
{domains.map(service => {
|
{domains.map(domain => (
|
||||||
const isExpanded = expandedDomains.has(service.domain);
|
<DomainCard
|
||||||
const backendType = getBackendType(service);
|
key={domain.domain}
|
||||||
const isDnsOnly = backendType === 'dns-only';
|
domain={domain}
|
||||||
const isPassthrough = service.tls === 'passthrough' || backendType === 'tcp-passthrough';
|
cert={findCert(domain.domain, certStatus?.certs ?? [])}
|
||||||
const hasGateway = !isDnsOnly;
|
ddnsRecord={ddnsStatus?.records?.find(r => r.name === domain.domain)}
|
||||||
const tlsStatus = getTlsStatus(service, certDomains);
|
vpnPeerCount={vpnPeerCount}
|
||||||
const ddnsRecord = ddnsStatus?.records?.find(r => r.name === service.domain);
|
vpnRunning={vpnRunning}
|
||||||
const ddnsStatus2: 'ok' | 'error' | 'na' = !service.public ? 'na' : ddnsRecord?.ok ? 'ok' : ddnsRecord ? 'error' : 'na';
|
vpnConfigured={vpnConfigured}
|
||||||
const routeCount = service.routes?.length ?? 0;
|
daemons={daemons}
|
||||||
|
certDomains={certDomains}
|
||||||
return (
|
onProvisionCert={d => provisionMutation.mutate(d)}
|
||||||
<Card key={service.domain}>
|
onRenewCert={() => renewMutation.mutate()}
|
||||||
{/* Header — always visible */}
|
isProvisioningCert={provisionMutation.isPending}
|
||||||
<button type="button" onClick={() => toggleExpand(service.domain)} className="w-full px-4 py-3 text-left hover:bg-muted/30 transition-colors">
|
isRenewingCert={renewMutation.isPending}
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="font-mono text-sm font-medium truncate">
|
|
||||||
{service.subdomains ? `*.${service.domain}` : service.domain}
|
|
||||||
</span>
|
|
||||||
{routeCount > 1 && (
|
|
||||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{routeCount} routes</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
|
||||||
<StatusDot status="ok" label="DNS" />
|
|
||||||
{hasGateway && <StatusDot status="ok" label="Gateway" />}
|
|
||||||
{hasGateway && !isPassthrough && <StatusDot status={tlsStatus} label="TLS" />}
|
|
||||||
<StatusDot status={ddnsStatus2} label="DDNS" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded — controls + status */}
|
|
||||||
{isExpanded && (
|
|
||||||
<CardContent className="pt-0 pb-4 px-4 space-y-4">
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
|
|
||||||
{!hasRoutes(service) && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-muted-foreground">{isDnsOnly ? 'Resolves to' : 'Backend'}</Label>
|
|
||||||
<Input
|
|
||||||
defaultValue={service.backend.address}
|
|
||||||
className="mt-0.5 h-8 font-mono text-sm w-48"
|
|
||||||
onBlur={e => {
|
|
||||||
const val = e.target.value.trim();
|
|
||||||
if (val && val !== service.backend.address) {
|
|
||||||
updateMutation.mutate({
|
|
||||||
domain: service.domain,
|
|
||||||
updates: { backend: { ...service.backend, address: val } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={service.public}
|
|
||||||
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { public: v } })}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
/>
|
|
||||||
<Label className="text-sm">{service.public ? 'Public' : 'Private'}</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={service.subdomains}
|
|
||||||
onCheckedChange={v => updateMutation.mutate({ domain: service.domain, updates: { subdomains: v } })}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
/>
|
|
||||||
<Label className="text-sm">Subdomains</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasGateway && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={!isPassthrough}
|
|
||||||
onCheckedChange={v => {
|
|
||||||
if (hasRoutes(service)) {
|
|
||||||
updateMutation.mutate({
|
|
||||||
domain: service.domain,
|
|
||||||
updates: { tls: v ? 'terminate' : 'passthrough' },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateMutation.mutate({
|
|
||||||
domain: service.domain,
|
|
||||||
updates: {
|
|
||||||
tls: v ? 'terminate' : 'passthrough',
|
|
||||||
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={updateMutation.isPending}
|
|
||||||
/>
|
|
||||||
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Routes detail */}
|
|
||||||
{hasRoutes(service) && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
<Route className="h-3 w-3" />Routes
|
|
||||||
</Label>
|
|
||||||
{service.routes!.map((route, i) => (
|
|
||||||
<div key={i} className="bg-muted/50 rounded-md p-3 space-y-2 text-sm">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
defaultValue={route.backend.address}
|
|
||||||
className="h-7 font-mono text-xs w-44"
|
|
||||||
onBlur={e => {
|
|
||||||
const val = e.target.value.trim();
|
|
||||||
if (val && val !== route.backend.address) {
|
|
||||||
const updatedRoutes = [...service.routes!];
|
|
||||||
updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } };
|
|
||||||
domainsApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
|
|
||||||
.then(() => queryClient.invalidateQueries({ queryKey: ['domains'] }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{route.headers && Object.keys(route.headers.response ?? {}).length > 0 && (
|
|
||||||
<div className="text-xs space-y-0.5">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<FileText className="h-3 w-3" />Response headers
|
|
||||||
</span>
|
|
||||||
{Object.entries(route.headers.response!).map(([k, v]) => (
|
|
||||||
<div key={k} className="font-mono pl-4 text-muted-foreground">
|
|
||||||
{k}: <span className="text-foreground">{v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{route.headers && Object.keys(route.headers.request ?? {}).length > 0 && (
|
|
||||||
<div className="text-xs space-y-0.5">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<FileText className="h-3 w-3" />Request headers
|
|
||||||
</span>
|
|
||||||
{Object.entries(route.headers.request!).map(([k, v]) => (
|
|
||||||
<div key={k} className="font-mono pl-4 text-muted-foreground">
|
|
||||||
{k}: <span className="text-foreground">{v}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{route.ipAllow && route.ipAllow.length > 0 && (
|
|
||||||
<div className="text-xs">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<Shield className="h-3 w-3" />IP allow: <span className="font-mono text-foreground">{route.ipAllow.join(', ')}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TLS cert info */}
|
|
||||||
{hasGateway && !isPassthrough && (() => {
|
|
||||||
const cert = findCert(service.domain, certStatus?.certs ?? []);
|
|
||||||
if (!cert) {
|
|
||||||
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5" />
|
|
||||||
No TLS certificate
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
|
||||||
disabled={provisionMutation.isPending}
|
|
||||||
onClick={() => provisionMutation.mutate(provisionDomain)}>
|
|
||||||
{provisionMutation.isPending
|
|
||||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
: <Plus className="h-3 w-3" />}
|
|
||||||
Provision {provisionDomain}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const isWildcard = cert.domain !== service.domain;
|
|
||||||
const daysLeft = cert.cert.daysLeft ?? 0;
|
|
||||||
const expiry = cert.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : 'unknown';
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
|
||||||
{isWildcard ? `*.${cert.domain}` : cert.domain}
|
|
||||||
</span>
|
|
||||||
<span>Expires {expiry} ({daysLeft}d)</span>
|
|
||||||
{cert.cert.issuerCN && <span className="truncate max-w-48">{cert.cert.issuerCN}</span>}
|
|
||||||
</div>
|
|
||||||
{daysLeft < 30 && (
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
|
||||||
disabled={renewMutation.isPending}
|
|
||||||
onClick={() => renewMutation.mutate()}>
|
|
||||||
{renewMutation.isPending
|
|
||||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
: <RotateCw className="h-3 w-3" />}
|
|
||||||
Renew
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* DDNS error info */}
|
|
||||||
{ddnsStatus2 === 'error' && ddnsRecord && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>
|
|
||||||
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}.
|
|
||||||
{' '}Check that your Cloudflare API token has access to this domain's zone.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs gap-1 shrink-0 ml-2"
|
|
||||||
disabled={isDdnsTriggering}
|
|
||||||
onClick={() => triggerDdns()}>
|
|
||||||
{isDdnsTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Source + deregister */}
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>Source: {service.source}</span>
|
|
||||||
<Button variant="ghost" size="sm"
|
|
||||||
className="h-7 text-xs text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
|
||||||
onClick={e => { e.stopPropagation(); deregisterMutation.mutate(service.domain); }}
|
|
||||||
disabled={deregisterMutation.isPending}>
|
|
||||||
<Trash2 className="h-3 w-3 mr-1" />Deregister
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Advanced */}
|
|
||||||
<Collapsible open={showAdvanced} onOpenChange={handleShowAdvanced}>
|
|
||||||
<Card>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button type="button" className="flex items-center justify-between w-full text-left px-4 py-3">
|
|
||||||
<span className="text-sm font-medium">Advanced</span>
|
|
||||||
{loadingAdvanced ? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
: showAdvanced ? <ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
: <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent className="space-y-4 pt-0">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={() => generateHaproxy()} disabled={isHaproxyGenerating} size="sm" className="gap-1">
|
|
||||||
{isHaproxyGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
|
||||||
Generate & Apply
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => restartHaproxy()} disabled={isHaproxyRestarting} size="sm" className="gap-1">
|
|
||||||
{isHaproxyRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
|
||||||
Restart HAProxy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{haproxyGenerateData && (
|
|
||||||
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
|
||||||
<AlertDescription className="text-green-700 dark:text-green-300">{haproxyGenerateData.message}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{haproxyGenerateError && (
|
|
||||||
<Alert variant="error"><AlertCircle className="h-4 w-4" /><AlertDescription>{(haproxyGenerateError as Error).message}</AlertDescription></Alert>
|
|
||||||
)}
|
|
||||||
<Textarea value={advancedConfig} readOnly className="font-mono text-xs min-h-[200px]" />
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Card>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Input } from './ui/input';
|
|||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Alert, AlertDescription } from './ui/alert';
|
import { Alert, AlertDescription } from './ui/alert';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
import { Shield, Loader2, AlertCircle, CheckCircle, Check, X, Plus, Trash2, BookOpen, ChevronDown, ChevronUp, Lock } from 'lucide-react';
|
import { Shield, Loader2, AlertCircle, CheckCircle, Check, X, Plus, Trash2, BookOpen, Lock } from 'lucide-react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNftables } from '../hooks/useNftables';
|
import { useNftables } from '../hooks/useNftables';
|
||||||
@@ -46,7 +46,7 @@ export function FirewallComponent() {
|
|||||||
mutationFn: (data: Parameters<typeof nftablesConfigApi.update>[0]) => nftablesConfigApi.update(data),
|
mutationFn: (data: Parameters<typeof nftablesConfigApi.update>[0]) => nftablesConfigApi.update(data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['nftables', 'config'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['nftables', 'config'] }),
|
||||||
});
|
});
|
||||||
const { status: nftablesStatus, isLoadingStatus: isNftablesLoading, interfaces, refetchStatus } = useNftables();
|
const { interfaces, refetchStatus } = useNftables();
|
||||||
const { config: vpnConfig } = useVpn();
|
const { config: vpnConfig } = useVpn();
|
||||||
|
|
||||||
const isEnabled = nftCfg?.enabled !== false;
|
const isEnabled = nftCfg?.enabled !== false;
|
||||||
@@ -58,7 +58,6 @@ export function FirewallComponent() {
|
|||||||
const [wanValue, setWanValue] = useState('');
|
const [wanValue, setWanValue] = useState('');
|
||||||
const [savingWan, setSavingWan] = useState(false);
|
const [savingWan, setSavingWan] = useState(false);
|
||||||
const [togglingFirewall, setTogglingFirewall] = useState(false);
|
const [togglingFirewall, setTogglingFirewall] = useState(false);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
@@ -484,46 +483,6 @@ export function FirewallComponent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Advanced: raw rules */}
|
|
||||||
<Card className={!isEnabled ? 'opacity-50' : ''}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Active Ruleset</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isNftablesLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Badge variant={nftablesStatus?.rules ? 'success' : isEnabled ? 'warning' : 'secondary'} className="gap-1">
|
|
||||||
{nftablesStatus?.rules && <CheckCircle className="h-3 w-3" />}
|
|
||||||
{nftablesStatus?.rules ? 'Active' : 'Not loaded'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs gap-1"
|
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
||||||
>
|
|
||||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
||||||
Advanced
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{showAdvanced && (
|
|
||||||
<CardContent>
|
|
||||||
{nftablesStatus?.rules ? (
|
|
||||||
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono">
|
|
||||||
{nftablesStatus.rules}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No rules loaded yet. Rules are generated when you apply an HAProxy configuration.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
145
web/src/components/SubsystemPage.tsx
Normal file
145
web/src/components/SubsystemPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Alert, AlertDescription } from './ui/alert';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Textarea } from './ui/textarea';
|
||||||
|
import { Loader2, CheckCircle, AlertCircle, Play, RotateCw } from 'lucide-react';
|
||||||
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||||
|
|
||||||
|
interface MetadataItem {
|
||||||
|
label: string;
|
||||||
|
value: string | number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubsystemPageProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
daemonKey: string;
|
||||||
|
|
||||||
|
configContent?: string;
|
||||||
|
isLoadingConfig?: boolean;
|
||||||
|
|
||||||
|
rawStatusLabel?: string;
|
||||||
|
rawStatusContent?: string;
|
||||||
|
|
||||||
|
onGenerate?: () => void;
|
||||||
|
isGenerating?: boolean;
|
||||||
|
generateLabel?: string;
|
||||||
|
|
||||||
|
onRestart?: () => void;
|
||||||
|
isRestarting?: boolean;
|
||||||
|
restartLabel?: string;
|
||||||
|
|
||||||
|
successMessage?: string | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
|
||||||
|
metadata?: MetadataItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubsystemPage({
|
||||||
|
title, description, icon: Icon, daemonKey,
|
||||||
|
configContent, isLoadingConfig,
|
||||||
|
rawStatusLabel, rawStatusContent,
|
||||||
|
onGenerate, isGenerating, generateLabel = 'Generate & Apply',
|
||||||
|
onRestart, isRestarting, restartLabel = 'Restart',
|
||||||
|
successMessage, errorMessage,
|
||||||
|
metadata,
|
||||||
|
}: SubsystemPageProps) {
|
||||||
|
const { data: centralStatus } = useCentralStatus();
|
||||||
|
const active = centralStatus?.daemons?.[daemonKey]?.active;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Icon className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">{title}</h2>
|
||||||
|
<p className="text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active !== undefined && (
|
||||||
|
<Badge variant={active ? 'success' : 'destructive'} className="gap-1">
|
||||||
|
{active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||||
|
{active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{successMessage && (
|
||||||
|
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription className="text-green-700 dark:text-green-300">{successMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{errorMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions + Metadata */}
|
||||||
|
{(onGenerate || onRestart || metadata?.length) && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-4">
|
||||||
|
{(onGenerate || onRestart) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{onGenerate && (
|
||||||
|
<Button onClick={onGenerate} disabled={isGenerating} size="sm" className="gap-1">
|
||||||
|
{isGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
|
||||||
|
{generateLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onRestart && (
|
||||||
|
<Button variant="outline" onClick={onRestart} disabled={isRestarting} size="sm" className="gap-1">
|
||||||
|
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||||
|
{restartLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata && metadata.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
{metadata.map(m => (
|
||||||
|
<div key={m.label}>
|
||||||
|
<span className="text-muted-foreground">{m.label}</span>
|
||||||
|
<div className="font-mono mt-0.5">{m.value ?? '--'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config */}
|
||||||
|
{isLoadingConfig ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<Loader2 className="h-8 w-8 text-primary mx-auto mb-2 animate-spin" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading configuration...</p>
|
||||||
|
</Card>
|
||||||
|
) : configContent !== undefined ? (
|
||||||
|
<Textarea value={configContent} readOnly className="font-mono text-xs min-h-[300px]" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Raw status */}
|
||||||
|
{rawStatusLabel && rawStatusContent && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-2">
|
||||||
|
<span className="text-sm font-medium">{rawStatusLabel}</span>
|
||||||
|
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono">
|
||||||
|
{rawStatusContent}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Shield, Loader2, Edit2, Plus, Trash2, Copy, Check, CheckCircle, RefreshCw, KeyRound, Play, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Shield, Loader2, Edit2, Plus, Trash2, Copy, Check, CheckCircle, RefreshCw, KeyRound, Play } from 'lucide-react';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
@@ -65,7 +65,6 @@ export function VpnComponent() {
|
|||||||
const [peerConfigModal, setPeerConfigModal] = useState<VpnPeerConfig | null>(null);
|
const [peerConfigModal, setPeerConfigModal] = useState<VpnPeerConfig | null>(null);
|
||||||
const [loadingPeerConfig, setLoadingPeerConfig] = useState<string | null>(null);
|
const [loadingPeerConfig, setLoadingPeerConfig] = useState<string | null>(null);
|
||||||
const [deleteConfirmPeer, setDeleteConfirmPeer] = useState<VpnPeer | null>(null);
|
const [deleteConfirmPeer, setDeleteConfirmPeer] = useState<VpnPeer | null>(null);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -233,25 +232,6 @@ export function VpnComponent() {
|
|||||||
<p className="font-mono">{status?.listenPort ?? config?.listenPort ?? 51820}</p>
|
<p className="font-mono">{status?.listenPort ?? config?.listenPort ?? 51820}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1 h-7 text-xs"
|
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
||||||
>
|
|
||||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
||||||
Advanced
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{showAdvanced && status?.rawOutput && (
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<span className="text-sm font-medium">WireGuard interface status</span>
|
|
||||||
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono mt-2">
|
|
||||||
{status.rawOutput}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
22
web/src/components/advanced/CrowdsecSubsystem.tsx
Normal file
22
web/src/components/advanced/CrowdsecSubsystem.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { useCrowdSec } from '../../hooks/useCrowdSec';
|
||||||
|
import { SubsystemPage } from '../SubsystemPage';
|
||||||
|
|
||||||
|
export function CrowdsecSubsystem() {
|
||||||
|
const { status, isLoadingStatus } = useCrowdSec();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubsystemPage
|
||||||
|
title="CrowdSec"
|
||||||
|
description="Collaborative intrusion detection"
|
||||||
|
icon={ShieldAlert}
|
||||||
|
daemonKey="crowdsec"
|
||||||
|
isLoadingConfig={isLoadingStatus}
|
||||||
|
metadata={[
|
||||||
|
{ label: 'Machines', value: status?.machines?.length },
|
||||||
|
{ label: 'Bouncers', value: status?.bouncers?.length },
|
||||||
|
{ label: 'Firewall Bouncer', value: status?.firewallBouncer ? 'Active' : 'Inactive' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
web/src/components/advanced/DnsmasqSubsystem.tsx
Normal file
33
web/src/components/advanced/DnsmasqSubsystem.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Wifi } from 'lucide-react';
|
||||||
|
import { useDnsmasq } from '../../hooks/useDnsmasq';
|
||||||
|
import { SubsystemPage } from '../SubsystemPage';
|
||||||
|
|
||||||
|
export function DnsmasqSubsystem() {
|
||||||
|
const {
|
||||||
|
config, isLoadingConfig, fetchConfig,
|
||||||
|
generateConfig, isGenerating, generateData, generateError,
|
||||||
|
restart, isRestarting, restartError,
|
||||||
|
} = useDnsmasq();
|
||||||
|
|
||||||
|
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubsystemPage
|
||||||
|
title="dnsmasq"
|
||||||
|
description="DNS resolver and DHCP server"
|
||||||
|
icon={Wifi}
|
||||||
|
daemonKey="dnsmasq"
|
||||||
|
configContent={config?.content}
|
||||||
|
isLoadingConfig={isLoadingConfig}
|
||||||
|
onGenerate={() => generateConfig(false)}
|
||||||
|
isGenerating={isGenerating}
|
||||||
|
generateLabel="Regenerate"
|
||||||
|
onRestart={() => restart()}
|
||||||
|
isRestarting={isRestarting}
|
||||||
|
restartLabel="Restart dnsmasq"
|
||||||
|
successMessage={generateData?.message}
|
||||||
|
errorMessage={generateError?.message ?? restartError?.message ?? null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
web/src/components/advanced/HaproxySubsystem.tsx
Normal file
41
web/src/components/advanced/HaproxySubsystem.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Network } from 'lucide-react';
|
||||||
|
import { useHaproxy } from '../../hooks/useHaproxy';
|
||||||
|
import { haproxyApi } from '../../services/api/networking';
|
||||||
|
import { SubsystemPage } from '../SubsystemPage';
|
||||||
|
|
||||||
|
export function HaproxySubsystem() {
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
generate, isGenerating, generateData, generateError,
|
||||||
|
restart, isRestarting,
|
||||||
|
} = useHaproxy();
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: ['haproxy', 'config'],
|
||||||
|
queryFn: () => haproxyApi.getConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubsystemPage
|
||||||
|
title="HAProxy"
|
||||||
|
description="Reverse proxy and load balancer"
|
||||||
|
icon={Network}
|
||||||
|
daemonKey="haproxy"
|
||||||
|
configContent={configQuery.data?.content}
|
||||||
|
isLoadingConfig={configQuery.isLoading}
|
||||||
|
onGenerate={() => generate()}
|
||||||
|
isGenerating={isGenerating}
|
||||||
|
generateLabel="Generate & Apply"
|
||||||
|
onRestart={() => restart()}
|
||||||
|
isRestarting={isRestarting}
|
||||||
|
restartLabel="Restart HAProxy"
|
||||||
|
successMessage={generateData?.message}
|
||||||
|
errorMessage={generateError ? (generateError as Error).message : null}
|
||||||
|
metadata={[
|
||||||
|
{ label: 'Config File', value: status?.configFile },
|
||||||
|
{ label: 'PID', value: status?.pid },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
web/src/components/advanced/NftablesSubsystem.tsx
Normal file
25
web/src/components/advanced/NftablesSubsystem.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
import { useNftables } from '../../hooks/useNftables';
|
||||||
|
import { SubsystemPage } from '../SubsystemPage';
|
||||||
|
|
||||||
|
export function NftablesSubsystem() {
|
||||||
|
const { status, isLoadingStatus, apply, isApplying, applyError } = useNftables();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubsystemPage
|
||||||
|
title="nftables"
|
||||||
|
description="Kernel packet filtering and firewall"
|
||||||
|
icon={Shield}
|
||||||
|
daemonKey="nftables"
|
||||||
|
configContent={status?.rules ?? (isLoadingStatus ? undefined : '# No rules loaded')}
|
||||||
|
isLoadingConfig={isLoadingStatus}
|
||||||
|
onGenerate={() => apply()}
|
||||||
|
isGenerating={isApplying}
|
||||||
|
generateLabel="Apply Rules"
|
||||||
|
errorMessage={applyError ? (applyError as Error).message : null}
|
||||||
|
metadata={[
|
||||||
|
{ label: 'Rules File', value: status?.rulesFile },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
web/src/components/advanced/WireguardSubsystem.tsx
Normal file
29
web/src/components/advanced/WireguardSubsystem.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
import { useVpn } from '../../hooks/useVpn';
|
||||||
|
import { SubsystemPage } from '../SubsystemPage';
|
||||||
|
|
||||||
|
export function WireguardSubsystem() {
|
||||||
|
const { status, config, isLoadingConfig, apply, isApplying, applyError } = useVpn();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubsystemPage
|
||||||
|
title="WireGuard"
|
||||||
|
description="VPN tunnel interface"
|
||||||
|
icon={Lock}
|
||||||
|
daemonKey="wireguard"
|
||||||
|
configContent={config ? JSON.stringify(config, null, 2) : undefined}
|
||||||
|
isLoadingConfig={isLoadingConfig}
|
||||||
|
onRestart={() => apply()}
|
||||||
|
isRestarting={isApplying}
|
||||||
|
restartLabel="Apply Config"
|
||||||
|
errorMessage={applyError ? (applyError as Error).message : null}
|
||||||
|
rawStatusLabel="WireGuard Interface Status"
|
||||||
|
rawStatusContent={status?.rawOutput}
|
||||||
|
metadata={[
|
||||||
|
{ label: 'Interface', value: status?.interface },
|
||||||
|
{ label: 'Listen Port', value: status?.listenPort },
|
||||||
|
{ label: 'Peers', value: status?.peerCount },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
web/src/components/advanced/index.ts
Normal file
5
web/src/components/advanced/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { HaproxySubsystem } from './HaproxySubsystem';
|
||||||
|
export { DnsmasqSubsystem } from './DnsmasqSubsystem';
|
||||||
|
export { NftablesSubsystem } from './NftablesSubsystem';
|
||||||
|
export { WireguardSubsystem } from './WireguardSubsystem';
|
||||||
|
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
||||||
379
web/src/components/domains/DomainCard.tsx
Normal file
379
web/src/components/domains/DomainCard.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Trash2, CheckCircle, AlertCircle, Pencil, Loader2, Plus } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { DomainTopology } from './DomainTopology';
|
||||||
|
import { domainsApi, type RegisteredDomain } from '@/services/api/networking';
|
||||||
|
import type { CertEntry } from '@/services/api/cert';
|
||||||
|
import type { DdnsRecordStatus } from '@/services/api/networking';
|
||||||
|
|
||||||
|
interface DomainCardProps {
|
||||||
|
domain: RegisteredDomain;
|
||||||
|
cert: CertEntry | undefined;
|
||||||
|
ddnsRecord: DdnsRecordStatus | undefined;
|
||||||
|
vpnPeerCount: number;
|
||||||
|
vpnRunning: boolean;
|
||||||
|
vpnConfigured: boolean;
|
||||||
|
daemons: {
|
||||||
|
haproxy?: boolean;
|
||||||
|
nftables?: boolean;
|
||||||
|
crowdsec?: boolean;
|
||||||
|
wireguard?: boolean;
|
||||||
|
};
|
||||||
|
certDomains: Set<string>;
|
||||||
|
onProvisionCert: (domain: string) => void;
|
||||||
|
onRenewCert: () => void;
|
||||||
|
isProvisioningCert: boolean;
|
||||||
|
isRenewingCert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayMode = 'dns-only' | 'l4' | 'l7';
|
||||||
|
|
||||||
|
function getGatewayMode(d: RegisteredDomain): GatewayMode {
|
||||||
|
const bt = d.routes?.length ? d.routes[0].backend.type : d.backend.type;
|
||||||
|
if (bt === 'dns-only') return 'dns-only';
|
||||||
|
if (bt === 'tcp-passthrough') return 'l4';
|
||||||
|
return 'l7';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModeLabel(mode: GatewayMode): string {
|
||||||
|
switch (mode) {
|
||||||
|
case 'dns-only': return 'DNS only';
|
||||||
|
case 'l4': return 'L4 Passthrough';
|
||||||
|
case 'l7': return 'L7 Proxy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTlsStatus(d: RegisteredDomain, certDomains: Set<string>): 'ok' | 'error' | 'na' {
|
||||||
|
const bt = d.routes?.length ? d.routes[0].backend.type : d.backend.type;
|
||||||
|
if (d.tls === 'passthrough' || d.tls === 'none' || bt === 'tcp-passthrough' || bt === 'dns-only') return 'na';
|
||||||
|
if (certDomains.has(d.domain)) return 'ok';
|
||||||
|
const dotIdx = d.domain.indexOf('.');
|
||||||
|
if (dotIdx > 0 && certDomains.has(d.domain.slice(dotIdx + 1))) return 'ok';
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIssues(d: RegisteredDomain, certDomains: Set<string>, ddnsRecord: DdnsRecordStatus | undefined): string[] {
|
||||||
|
const issues: string[] = [];
|
||||||
|
const tls = getTlsStatus(d, certDomains);
|
||||||
|
if (tls === 'error') issues.push('No TLS cert');
|
||||||
|
if (d.public && ddnsRecord && !ddnsRecord.ok) issues.push('DDNS failed');
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact controls for mobile — replaces the topology diagram */
|
||||||
|
function MobileControls({
|
||||||
|
domain, cert, mode,
|
||||||
|
onTogglePublic, onChangeMode, onProvisionCert, isProvisioningCert, isMutating,
|
||||||
|
}: {
|
||||||
|
domain: RegisteredDomain;
|
||||||
|
cert: CertEntry | undefined;
|
||||||
|
mode: GatewayMode;
|
||||||
|
onTogglePublic: () => void;
|
||||||
|
onChangeMode: (m: GatewayMode) => void;
|
||||||
|
onProvisionCert: (d: string) => void;
|
||||||
|
isProvisioningCert: boolean;
|
||||||
|
isMutating: boolean;
|
||||||
|
}) {
|
||||||
|
const isL7 = mode === 'l7';
|
||||||
|
const hasCert = !!cert?.cert.exists;
|
||||||
|
const provisionDomain = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
{/* Public toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">Public (internet-accessible)</Label>
|
||||||
|
<Switch checked={domain.public} onCheckedChange={onTogglePublic} disabled={isMutating} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode selector */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1.5 block">Routing mode</Label>
|
||||||
|
<div className="flex gap-1 bg-muted rounded-md p-1">
|
||||||
|
{([
|
||||||
|
{ value: 'dns-only' as const, label: 'DNS only' },
|
||||||
|
{ value: 'l4' as const, label: 'L4 Passthrough' },
|
||||||
|
{ value: 'l7' as const, label: 'L7 Proxy' },
|
||||||
|
]).map(opt => (
|
||||||
|
<button key={opt.value} type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
|
||||||
|
mode === opt.value ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
onClick={() => onChangeMode(opt.value)}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TLS status */}
|
||||||
|
{isL7 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{hasCert ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
<span>TLS: {cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain}</span>
|
||||||
|
{cert!.cert.daysLeft != null && <span className="text-muted-foreground">({cert!.cert.daysLeft}d)</span>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
<span>No TLS certificate</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!hasCert && (
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
|
||||||
|
disabled={isProvisioningCert}
|
||||||
|
onClick={() => onProvisionCert(provisionDomain)}
|
||||||
|
>
|
||||||
|
{isProvisioningCert ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||||
|
Provision
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backend */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">Backend: </span>
|
||||||
|
<span className="font-mono">{domain.routes?.length ? `${domain.routes.length} routes` : domain.backend.address}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainCard({
|
||||||
|
domain,
|
||||||
|
cert,
|
||||||
|
ddnsRecord,
|
||||||
|
vpnPeerCount,
|
||||||
|
vpnRunning,
|
||||||
|
vpnConfigured,
|
||||||
|
daemons,
|
||||||
|
certDomains,
|
||||||
|
onProvisionCert,
|
||||||
|
onRenewCert,
|
||||||
|
isProvisioningCert,
|
||||||
|
isRenewingCert,
|
||||||
|
}: DomainCardProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [editingBackend, setEditingBackend] = useState(false);
|
||||||
|
const backendInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isManaged = domain.source !== 'manual';
|
||||||
|
const mode = getGatewayMode(domain);
|
||||||
|
const issues = getIssues(domain, certDomains, ddnsRecord);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ updates }: { updates: Record<string, unknown> }) =>
|
||||||
|
domainsApi.register({ ...domain, ...updates } as Parameters<typeof domainsApi.register>[0]),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deregisterMutation = useMutation({
|
||||||
|
mutationFn: () => domainsApi.deregister(domain.domain),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTogglePublic = () => {
|
||||||
|
updateMutation.mutate({ updates: { public: !domain.public } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeMode = (newMode: GatewayMode) => {
|
||||||
|
if (newMode === mode) return;
|
||||||
|
const backendType = newMode === 'dns-only' ? 'dns-only' : newMode === 'l4' ? 'tcp-passthrough' : 'http';
|
||||||
|
const tls = newMode === 'dns-only' ? 'none' : newMode === 'l4' ? 'passthrough' : 'terminate';
|
||||||
|
updateMutation.mutate({
|
||||||
|
updates: {
|
||||||
|
tls,
|
||||||
|
backend: { ...domain.backend, type: backendType },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBackend = () => {
|
||||||
|
const val = backendInputRef.current?.value.trim();
|
||||||
|
if (val && val !== domain.backend.address) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
updates: { backend: { ...domain.backend, address: val } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setEditingBackend(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleSubdomains = () => {
|
||||||
|
updateMutation.mutate({ updates: { subdomains: !domain.subdomains } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const domainDisplay = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
{/* === Collapsed Header === */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
{/* Left: domain name */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-mono text-sm font-medium truncate">{domainDisplay}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: mode + visibility + backend + status */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
||||||
|
{getModeLabel(mode)}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant={domain.public ? 'default' : 'secondary'}
|
||||||
|
className="text-[10px] px-1.5 py-0 h-5"
|
||||||
|
>
|
||||||
|
{domain.public ? 'Public' : 'Private'}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{!domain.routes?.length && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono hidden sm:inline">
|
||||||
|
{'-> '}{domain.backend.address}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{issues.length === 0 ? (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="hidden sm:inline">{issues[0]}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* === Expanded Content === */}
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent className="pt-0 pb-4 px-4 space-y-3">
|
||||||
|
{/* Managed domain notice */}
|
||||||
|
{isManaged && (
|
||||||
|
<div className="text-[10px] text-muted-foreground bg-muted/30 rounded-md px-3 py-1.5">
|
||||||
|
Registered by <span className="font-medium">{domain.source}</span> — changes may be overwritten on next sync
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Topology diagram (desktop) / compact controls (mobile) */}
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileControls
|
||||||
|
domain={domain}
|
||||||
|
cert={cert}
|
||||||
|
mode={mode}
|
||||||
|
onTogglePublic={handleTogglePublic}
|
||||||
|
onChangeMode={handleChangeMode}
|
||||||
|
onProvisionCert={onProvisionCert}
|
||||||
|
isProvisioningCert={isProvisioningCert}
|
||||||
|
isMutating={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DomainTopology
|
||||||
|
domain={domain}
|
||||||
|
cert={cert}
|
||||||
|
ddnsRecord={ddnsRecord ? { ok: ddnsRecord.ok, ip: ddnsRecord.ip, error: ddnsRecord.error } : undefined}
|
||||||
|
vpnPeerCount={vpnPeerCount}
|
||||||
|
vpnRunning={vpnRunning}
|
||||||
|
vpnConfigured={vpnConfigured}
|
||||||
|
daemons={daemons}
|
||||||
|
isManaged={isManaged}
|
||||||
|
onTogglePublic={handleTogglePublic}
|
||||||
|
onChangeMode={handleChangeMode}
|
||||||
|
onProvisionCert={onProvisionCert}
|
||||||
|
onRenewCert={onRenewCert}
|
||||||
|
isProvisioningCert={isProvisioningCert}
|
||||||
|
isRenewingCert={isRenewingCert}
|
||||||
|
isMutating={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* === Footer: backend editing + subdomains + source + deregister === */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-border/50">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{!domain.routes?.length && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{editingBackend ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
ref={backendInputRef}
|
||||||
|
defaultValue={domain.backend.address}
|
||||||
|
className="h-6 text-xs font-mono w-40"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleSaveBackend();
|
||||||
|
if (e.key === 'Escape') setEditingBackend(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[10px] px-1.5" onClick={handleSaveBackend}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[10px] px-1.5" onClick={() => setEditingBackend(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group"
|
||||||
|
onClick={() => setEditingBackend(true)}
|
||||||
|
>
|
||||||
|
<span className="font-mono">{domain.backend.address}</span>
|
||||||
|
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={handleToggleSubdomains}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{domain.subdomains ? '*.wildcard on' : 'wildcard off'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
Source: <span className="font-mono">{domain.source}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="sm"
|
||||||
|
className="h-6 text-[10px] text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||||
|
onClick={e => { e.stopPropagation(); deregisterMutation.mutate(); }}
|
||||||
|
disabled={deregisterMutation.isPending}
|
||||||
|
>
|
||||||
|
{deregisterMutation.isPending
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||||
|
: <Trash2 className="h-3 w-3 mr-1" />}
|
||||||
|
Deregister
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
536
web/src/components/domains/DomainTopology.tsx
Normal file
536
web/src/components/domains/DomainTopology.tsx
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import {
|
||||||
|
ReactFlow, Handle, Position,
|
||||||
|
type Node, type Edge, type NodeProps,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import {
|
||||||
|
Globe, Wifi, Lock, Shield, ShieldAlert,
|
||||||
|
AlertCircle, Loader2, Plus, RotateCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { TopologyNode, type NodeStatus } from './TopologyNode';
|
||||||
|
import type { RegisteredDomain } from '@/services/api/networking';
|
||||||
|
import type { CertEntry } from '@/services/api/cert';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type GatewayMode = 'dns-only' | 'l4' | 'l7';
|
||||||
|
|
||||||
|
interface DaemonStatuses {
|
||||||
|
haproxy?: boolean;
|
||||||
|
nftables?: boolean;
|
||||||
|
crowdsec?: boolean;
|
||||||
|
wireguard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DdnsRecordInfo {
|
||||||
|
ok: boolean;
|
||||||
|
ip?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainTopologyProps {
|
||||||
|
domain: RegisteredDomain;
|
||||||
|
cert: CertEntry | undefined;
|
||||||
|
ddnsRecord: DdnsRecordInfo | undefined;
|
||||||
|
vpnPeerCount: number;
|
||||||
|
vpnRunning: boolean;
|
||||||
|
vpnConfigured: boolean;
|
||||||
|
daemons: DaemonStatuses;
|
||||||
|
isManaged: boolean;
|
||||||
|
onTogglePublic: () => void;
|
||||||
|
onChangeMode: (mode: GatewayMode) => void;
|
||||||
|
onProvisionCert: (domain: string) => void;
|
||||||
|
onRenewCert: () => void;
|
||||||
|
isProvisioningCert: boolean;
|
||||||
|
isRenewingCert: boolean;
|
||||||
|
isMutating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom React Flow node ─────────────────────────────────────────────
|
||||||
|
// Callbacks are passed through data to avoid context issues with React Flow.
|
||||||
|
|
||||||
|
const handleStyle = { opacity: 0, width: 4, height: 4 };
|
||||||
|
|
||||||
|
function FlowNode({ data }: NodeProps) {
|
||||||
|
const v = data.variant as string;
|
||||||
|
|
||||||
|
const leftHandle = <Handle id="left" type="target" position={Position.Left} style={handleStyle} />;
|
||||||
|
const rightHandle = <Handle id="right" type="source" position={Position.Right} style={handleStyle} />;
|
||||||
|
const bottomHandle = <Handle id="bottom" type="target" position={Position.Bottom} style={handleStyle} />;
|
||||||
|
|
||||||
|
if (v === 'internet') {
|
||||||
|
const isPublic = data.isPublic as boolean;
|
||||||
|
const status = data.status as NodeStatus;
|
||||||
|
const sublabel = data.sublabel as string;
|
||||||
|
const ddnsIp = data.ddnsIp as string | undefined;
|
||||||
|
const ddnsError = data.ddnsError as string | undefined;
|
||||||
|
const onTogglePublic = data.onTogglePublic as (() => void) | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<TopologyNode
|
||||||
|
label="Internet"
|
||||||
|
sublabel={sublabel}
|
||||||
|
status={status}
|
||||||
|
icon={<Globe className="h-3.5 w-3.5" />}
|
||||||
|
interactive
|
||||||
|
onClick={onTogglePublic}
|
||||||
|
tooltip={
|
||||||
|
isPublic ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium">Public domain</div>
|
||||||
|
{ddnsIp && <div>IP: {ddnsIp}</div>}
|
||||||
|
{ddnsError && <div className="text-red-400">{ddnsError}</div>}
|
||||||
|
<div className="text-muted-foreground">Click to make private</div>
|
||||||
|
</div>
|
||||||
|
) : 'Click to make public (enables DDNS)'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{rightHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === 'security') {
|
||||||
|
const fwOk = data.firewallOk as boolean;
|
||||||
|
const csOk = data.crowdsecOk as boolean;
|
||||||
|
const isL4 = data.isL4 as boolean;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Link to="/central/firewall" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<TopologyNode
|
||||||
|
label="Firewall"
|
||||||
|
status={fwOk ? 'ok' : 'na'}
|
||||||
|
icon={<Shield className="h-3 w-3" />}
|
||||||
|
className="group-hover:border-primary/40"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link to="/central/crowdsec" className="group nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<TopologyNode
|
||||||
|
label="CrowdSec"
|
||||||
|
sublabel={isL4 ? 'IP only' : 'HTTP + IP'}
|
||||||
|
status={csOk ? 'ok' : 'na'}
|
||||||
|
icon={<ShieldAlert className="h-3 w-3" />}
|
||||||
|
className="group-hover:border-primary/40"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{rightHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === 'gateway') {
|
||||||
|
const mode = data.mode as GatewayMode;
|
||||||
|
const haproxyOk = data.haproxyOk as boolean;
|
||||||
|
const isMutating = data.isMutating as boolean;
|
||||||
|
const onChangeMode = data.onChangeMode as ((m: GatewayMode) => void) | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<TopologyNode
|
||||||
|
label={mode === 'dns-only' ? 'No gateway' : mode === 'l4' ? 'L4 Passthrough' : 'L7 Proxy'}
|
||||||
|
sublabel={mode === 'dns-only' ? 'DNS resolution only' : mode === 'l4' ? 'SNI routing' : 'HTTP reverse proxy'}
|
||||||
|
status={mode === 'dns-only' ? 'inactive' : haproxyOk ? 'ok' : 'error'}
|
||||||
|
>
|
||||||
|
{onChangeMode && (
|
||||||
|
<div className="flex gap-0.5 mt-2 bg-muted rounded p-0.5">
|
||||||
|
{(['dns-only', 'l4', 'l7'] as const).map(m => (
|
||||||
|
<button key={m} type="button"
|
||||||
|
className={cn(
|
||||||
|
'nopan nodrag flex-1 text-[10px] py-0.5 px-2 rounded transition-colors !cursor-pointer',
|
||||||
|
mode === m ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
style={{ pointerEvents: 'all' }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={() => onChangeMode(m)}
|
||||||
|
disabled={isMutating}
|
||||||
|
>
|
||||||
|
{m === 'dns-only' ? 'DNS' : m.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TopologyNode>
|
||||||
|
{rightHandle}
|
||||||
|
{bottomHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === 'tls') {
|
||||||
|
const status = data.status as NodeStatus;
|
||||||
|
const certDomain = data.certDomain as string | undefined;
|
||||||
|
const daysLeft = data.daysLeft as number;
|
||||||
|
const hasCert = data.hasCert as boolean;
|
||||||
|
const provisionDomain = data.provisionDomain as string;
|
||||||
|
const expiry = data.expiry as string | undefined;
|
||||||
|
const issuer = data.issuer as string | undefined;
|
||||||
|
const isProvisioningCert = data.isProvisioningCert as boolean;
|
||||||
|
const isRenewingCert = data.isRenewingCert as boolean;
|
||||||
|
const onProvisionCert = data.onProvisionCert as ((d: string) => void) | undefined;
|
||||||
|
const onRenewCert = data.onRenewCert as (() => void) | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<TopologyNode
|
||||||
|
label="TLS"
|
||||||
|
sublabel={certDomain ?? 'No certificate'}
|
||||||
|
status={status}
|
||||||
|
tooltip={
|
||||||
|
hasCert ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>Certificate: {certDomain}</div>
|
||||||
|
<div>Expires: {expiry ?? 'unknown'} ({daysLeft}d)</div>
|
||||||
|
{issuer && <div>Issuer: {issuer}</div>}
|
||||||
|
</div>
|
||||||
|
) : 'No TLS certificate provisioned'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!hasCert && onProvisionCert && (
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
className="mt-1.5 h-5 text-[10px] gap-0.5 w-full !cursor-pointer"
|
||||||
|
style={{ pointerEvents: 'all' }}
|
||||||
|
disabled={isProvisioningCert}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={() => onProvisionCert(provisionDomain)}
|
||||||
|
>
|
||||||
|
{isProvisioningCert ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Plus className="h-2.5 w-2.5" />}
|
||||||
|
Provision
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasCert && daysLeft < 30 && onRenewCert && (
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
className="mt-1.5 h-5 text-[10px] gap-0.5 w-full !cursor-pointer"
|
||||||
|
style={{ pointerEvents: 'all' }}
|
||||||
|
disabled={isRenewingCert}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={() => onRenewCert()}
|
||||||
|
>
|
||||||
|
{isRenewingCert ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCw className="h-2.5 w-2.5" />}
|
||||||
|
Renew
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TopologyNode>
|
||||||
|
{rightHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === 'backend') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<TopologyNode
|
||||||
|
label={data.label as string}
|
||||||
|
sublabel={data.sublabel as string | undefined}
|
||||||
|
status="ok"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
{rightHandle}
|
||||||
|
{bottomHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === 'lan') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<TopologyNode
|
||||||
|
label="LAN"
|
||||||
|
sublabel="Always connected"
|
||||||
|
status="ok"
|
||||||
|
icon={<Wifi className="h-3.5 w-3.5" />}
|
||||||
|
/>
|
||||||
|
{rightHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === 'vpn') {
|
||||||
|
const peerCount = data.peerCount as number;
|
||||||
|
const running = data.running as boolean;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{leftHandle}
|
||||||
|
<Link to="/central/vpn" className="nopan nodrag" style={{ pointerEvents: 'all' }} onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<TopologyNode
|
||||||
|
label="VPN"
|
||||||
|
sublabel={running ? `${peerCount} peer${peerCount !== 1 ? 's' : ''}` : 'Not running'}
|
||||||
|
status={running ? 'ok' : 'na'}
|
||||||
|
icon={<Lock className="h-3.5 w-3.5" />}
|
||||||
|
className="hover:border-primary/40"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
{rightHandle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypes = { topology: FlowNode };
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getGatewayMode(domain: RegisteredDomain): GatewayMode {
|
||||||
|
const bt = domain.routes?.length ? domain.routes[0].backend.type : domain.backend.type;
|
||||||
|
if (bt === 'dns-only') return 'dns-only';
|
||||||
|
if (bt === 'tcp-passthrough') return 'l4';
|
||||||
|
return 'l7';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DomainTopology(props: DomainTopologyProps) {
|
||||||
|
const {
|
||||||
|
domain, cert, ddnsRecord, vpnPeerCount, vpnRunning, vpnConfigured,
|
||||||
|
daemons,
|
||||||
|
onTogglePublic, onChangeMode, onProvisionCert, onRenewCert,
|
||||||
|
isProvisioningCert, isRenewingCert, isMutating,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const mode = getGatewayMode(domain);
|
||||||
|
const isPublic = domain.public;
|
||||||
|
const isDnsOnly = mode === 'dns-only';
|
||||||
|
const isL4 = mode === 'l4';
|
||||||
|
const isL7 = mode === 'l7';
|
||||||
|
const showSecurity = isPublic && !isDnsOnly;
|
||||||
|
const showGateway = true; // Always show so mode selector is accessible
|
||||||
|
const showTls = isL7;
|
||||||
|
const showVpn = vpnConfigured;
|
||||||
|
|
||||||
|
// DDNS
|
||||||
|
const ddnsStatus: NodeStatus = !isPublic ? 'inactive' : ddnsRecord?.ok ? 'ok' : ddnsRecord ? 'error' : 'na';
|
||||||
|
const ddnsSublabel = !isPublic ? 'Disconnected' : ddnsRecord?.ok ? (ddnsRecord.ip ?? 'Synced') : ddnsRecord?.error ? 'Sync failed' : 'DDNS';
|
||||||
|
|
||||||
|
// TLS
|
||||||
|
const hasCert = !!cert?.cert.exists;
|
||||||
|
const tlsStatus: NodeStatus = hasCert ? 'ok' : 'error';
|
||||||
|
const certDomain = hasCert ? (cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain) : undefined;
|
||||||
|
const daysLeft = cert?.cert.daysLeft ?? 0;
|
||||||
|
const provisionDomain = domain.subdomains ? `*.${domain.domain}` : domain.domain;
|
||||||
|
|
||||||
|
// Backend
|
||||||
|
const backendLabel = domain.routes?.length ? `${domain.routes.length} backends` : domain.backend.address;
|
||||||
|
const backendSublabel = isDnsOnly ? 'Direct access' : isL4 ? 'Handles own TLS' : domain.routes?.length ? 'Path-based routing' : undefined;
|
||||||
|
|
||||||
|
// ── Build nodes & edges ──
|
||||||
|
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
const n: Node[] = [];
|
||||||
|
const e: Edge[] = [];
|
||||||
|
|
||||||
|
// Column x positions
|
||||||
|
let x = 0;
|
||||||
|
const sourceX = x;
|
||||||
|
|
||||||
|
x += 170;
|
||||||
|
const securityX = showSecurity ? x : -1;
|
||||||
|
if (showSecurity) x += 160;
|
||||||
|
|
||||||
|
const gatewayX = showGateway ? x : -1;
|
||||||
|
if (showGateway) x += showTls ? 150 : 200;
|
||||||
|
|
||||||
|
const tlsX = showTls ? x : -1;
|
||||||
|
if (showTls) x += 140;
|
||||||
|
|
||||||
|
const backendX = x;
|
||||||
|
|
||||||
|
// Row y positions
|
||||||
|
const pipelineY = 0;
|
||||||
|
const lanY = 110;
|
||||||
|
const vpnY = 180;
|
||||||
|
|
||||||
|
// ── Nodes (callbacks passed via data) ──
|
||||||
|
|
||||||
|
n.push({
|
||||||
|
id: 'internet', type: 'topology',
|
||||||
|
position: { x: sourceX, y: pipelineY },
|
||||||
|
data: {
|
||||||
|
variant: 'internet', isPublic, status: ddnsStatus, sublabel: ddnsSublabel,
|
||||||
|
ddnsIp: ddnsRecord?.ip, ddnsError: ddnsRecord?.error,
|
||||||
|
onTogglePublic,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (showSecurity) {
|
||||||
|
n.push({
|
||||||
|
id: 'security', type: 'topology',
|
||||||
|
position: { x: securityX, y: pipelineY - 10 },
|
||||||
|
data: { variant: 'security', firewallOk: !!daemons.nftables, crowdsecOk: !!daemons.crowdsec, isL4 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showGateway) {
|
||||||
|
n.push({
|
||||||
|
id: 'gateway', type: 'topology',
|
||||||
|
position: { x: gatewayX, y: pipelineY },
|
||||||
|
data: { variant: 'gateway', mode, haproxyOk: !!daemons.haproxy, isMutating, onChangeMode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTls) {
|
||||||
|
n.push({
|
||||||
|
id: 'tls', type: 'topology',
|
||||||
|
position: { x: tlsX, y: pipelineY },
|
||||||
|
data: {
|
||||||
|
variant: 'tls', status: tlsStatus, certDomain, daysLeft, hasCert, provisionDomain,
|
||||||
|
expiry: cert?.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : undefined,
|
||||||
|
issuer: cert?.cert.issuerCN,
|
||||||
|
isProvisioningCert, isRenewingCert, onProvisionCert, onRenewCert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
n.push({
|
||||||
|
id: 'backend', type: 'topology',
|
||||||
|
position: { x: backendX, y: pipelineY },
|
||||||
|
data: { variant: 'backend', label: backendLabel, sublabel: backendSublabel },
|
||||||
|
});
|
||||||
|
|
||||||
|
n.push({
|
||||||
|
id: 'lan', type: 'topology',
|
||||||
|
position: { x: sourceX, y: lanY },
|
||||||
|
data: { variant: 'lan' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (showVpn) {
|
||||||
|
n.push({
|
||||||
|
id: 'vpn', type: 'topology',
|
||||||
|
position: { x: sourceX, y: vpnY },
|
||||||
|
data: { variant: 'vpn', peerCount: vpnPeerCount, running: vpnRunning },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edges ──
|
||||||
|
|
||||||
|
const pipeline = { type: 'smoothstep' as const, sourceHandle: 'right', targetHandle: 'left', style: { strokeWidth: 1.5 } };
|
||||||
|
const labelStyle = { fontSize: 9, fill: 'var(--color-muted-foreground)' };
|
||||||
|
|
||||||
|
// Internet path
|
||||||
|
if (isPublic && showSecurity) {
|
||||||
|
e.push({ id: 'e-internet-security', source: 'internet', target: 'security', ...pipeline });
|
||||||
|
e.push({ id: 'e-security-gateway', source: 'security', target: 'gateway', ...pipeline });
|
||||||
|
} else if (isPublic && !isDnsOnly) {
|
||||||
|
e.push({ id: 'e-internet-gateway', source: 'internet', target: 'gateway', ...pipeline });
|
||||||
|
} else if (isPublic && isDnsOnly) {
|
||||||
|
e.push({ id: 'e-internet-backend', source: 'internet', target: 'backend', ...pipeline, style: { ...pipeline.style, strokeDasharray: '6 3' }, label: 'DNS', labelStyle });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central processing → backend (not for DNS-only)
|
||||||
|
if (!isDnsOnly) {
|
||||||
|
if (isL7 && showTls) {
|
||||||
|
e.push({ id: 'e-gateway-tls', source: 'gateway', target: 'tls', ...pipeline });
|
||||||
|
e.push({ id: 'e-tls-backend', source: 'tls', target: 'backend', ...pipeline, animated: hasCert });
|
||||||
|
} else {
|
||||||
|
e.push({ id: 'e-gateway-backend', source: 'gateway', target: 'backend', ...pipeline, style: { ...pipeline.style, strokeWidth: isL4 ? 2.5 : 1.5 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAN / VPN paths — enter from below via bottom handle
|
||||||
|
const lanVpnTarget = isL7 ? 'gateway' : 'backend';
|
||||||
|
const lanVpnEdge = { type: 'smoothstep' as const, sourceHandle: 'right', targetHandle: 'bottom', style: { strokeWidth: 1.5 }, label: 'dnsmasq', labelStyle };
|
||||||
|
|
||||||
|
e.push({ id: 'e-lan-target', source: 'lan', target: lanVpnTarget, ...lanVpnEdge });
|
||||||
|
|
||||||
|
if (showVpn) {
|
||||||
|
e.push({ id: 'e-vpn-target', source: 'vpn', target: lanVpnTarget, ...lanVpnEdge });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: n, edges: e };
|
||||||
|
}, [
|
||||||
|
isPublic, isDnsOnly, isL4, isL7, showSecurity, showGateway, showTls, showVpn,
|
||||||
|
mode, daemons, ddnsStatus, ddnsSublabel, ddnsRecord,
|
||||||
|
tlsStatus, certDomain, daysLeft, hasCert, provisionDomain, cert,
|
||||||
|
backendLabel, backendSublabel, vpnPeerCount, vpnRunning,
|
||||||
|
isMutating, isProvisioningCert, isRenewingCert,
|
||||||
|
onTogglePublic, onChangeMode, onProvisionCert, onRenewCert,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const containerHeight = showVpn ? 260 : 200;
|
||||||
|
|
||||||
|
const onInit = useCallback((instance: { fitView: () => void }) => {
|
||||||
|
setTimeout(() => instance.fitView(), 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<div style={{ height: containerHeight }} className="w-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
|
onInit={onInit}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable={false}
|
||||||
|
panOnDrag={false}
|
||||||
|
zoomOnScroll={false}
|
||||||
|
zoomOnPinch={false}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
preventScrolling={false}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
className="!bg-transparent"
|
||||||
|
style={{ '--xy-edge-label-background-color': 'hsl(var(--card))', '--xy-edge-label-color': 'hsl(var(--muted-foreground))' } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{isDnsOnly && (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-950/20 rounded-md px-3 py-1.5">
|
||||||
|
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||||
|
No firewall, proxy, or TLS protection from Central — traffic goes directly to your server
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isL7 && !hasCert && (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||||
|
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||||
|
L7 routing is inactive until a TLS certificate is provisioned
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPublic && ddnsRecord && !ddnsRecord.ok && (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-red-600 dark:text-red-400 bg-red-50/50 dark:bg-red-950/20 rounded-md px-3 py-1.5">
|
||||||
|
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||||
|
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Route table */}
|
||||||
|
{isL7 && domain.routes && domain.routes.length > 0 && (
|
||||||
|
<div className="bg-muted/30 rounded-md p-2 space-y-1">
|
||||||
|
<div className="text-[10px] text-muted-foreground font-medium mb-1">Route table</div>
|
||||||
|
{domain.routes.map((route, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 text-[10px]">
|
||||||
|
<span className="font-mono text-muted-foreground w-20 truncate">
|
||||||
|
{route.paths?.length ? route.paths.join(', ') : '/*'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{'->'}</span>
|
||||||
|
<span className="font-mono">{route.backend.address}</span>
|
||||||
|
{route.ipAllow && route.ipAllow.length > 0 && (
|
||||||
|
<span className="text-muted-foreground flex items-center gap-0.5">
|
||||||
|
<Shield className="h-2.5 w-2.5" />
|
||||||
|
{route.ipAllow.length} IP rules
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
web/src/components/domains/TopologyNode.tsx
Normal file
91
web/src/components/domains/TopologyNode.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
export type NodeStatus = 'ok' | 'error' | 'na' | 'inactive';
|
||||||
|
|
||||||
|
interface TopologyNodeProps {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
status?: NodeStatus;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
interactive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
tooltip?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<NodeStatus, string> = {
|
||||||
|
ok: 'border-green-500/60 bg-green-50/50 dark:bg-green-950/20',
|
||||||
|
error: 'border-red-500/60 bg-red-50/50 dark:bg-red-950/20',
|
||||||
|
na: 'border-muted-foreground/30 bg-muted/30',
|
||||||
|
inactive: 'border-dashed border-muted-foreground/20 bg-muted/10 opacity-50',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusDotColors: Record<NodeStatus, string> = {
|
||||||
|
ok: 'bg-green-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
na: 'bg-muted-foreground/40',
|
||||||
|
inactive: 'bg-muted-foreground/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TopologyNode = forwardRef<HTMLDivElement, TopologyNodeProps>(
|
||||||
|
function TopologyNode(
|
||||||
|
{ label, sublabel, status = 'ok', icon, onClick, interactive, disabled, tooltip, className, children },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const isClickable = (interactive || onClick) && !disabled;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role={isClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
|
onClick={isClickable ? onClick : undefined}
|
||||||
|
// Stop pointerdown propagation so React Flow doesn't swallow the click
|
||||||
|
onPointerDown={isClickable ? (e) => e.stopPropagation() : undefined}
|
||||||
|
onKeyDown={isClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick?.(); } } : undefined}
|
||||||
|
style={{ pointerEvents: 'all', position: 'relative' }}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-2 text-xs transition-all duration-200',
|
||||||
|
'nopan nodrag',
|
||||||
|
statusColors[status],
|
||||||
|
isClickable && '!cursor-pointer hover:shadow-sm hover:border-primary/40',
|
||||||
|
disabled && '!pointer-events-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon && <span className="shrink-0 text-muted-foreground">{icon}</span>}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full shrink-0', statusDotColors[status])} />
|
||||||
|
<span className="font-medium truncate">{label}</span>
|
||||||
|
</div>
|
||||||
|
{sublabel && (
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5 truncate">{sublabel}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="max-w-64 text-xs">
|
||||||
|
{tooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
},
|
||||||
|
);
|
||||||
10
web/src/router/pages/advanced/CrowdsecPage.tsx
Normal file
10
web/src/router/pages/advanced/CrowdsecPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { CrowdsecSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function CrowdsecPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<CrowdsecSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/advanced/DnsmasqPage.tsx
Normal file
10
web/src/router/pages/advanced/DnsmasqPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { DnsmasqSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function DnsmasqPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<DnsmasqSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/advanced/HaproxyPage.tsx
Normal file
10
web/src/router/pages/advanced/HaproxyPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { HaproxySubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function HaproxyPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<HaproxySubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/advanced/NftablesPage.tsx
Normal file
10
web/src/router/pages/advanced/NftablesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { NftablesSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function NftablesPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<NftablesSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/advanced/WireguardPage.tsx
Normal file
10
web/src/router/pages/advanced/WireguardPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { WireguardSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function WireguardPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<WireguardSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
web/src/router/pages/advanced/index.ts
Normal file
5
web/src/router/pages/advanced/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { HaproxyPage } from './HaproxyPage';
|
||||||
|
export { DnsmasqPage } from './DnsmasqPage';
|
||||||
|
export { NftablesPage } from './NftablesPage';
|
||||||
|
export { WireguardPage } from './WireguardPage';
|
||||||
|
export { CrowdsecPage } from './CrowdsecPage';
|
||||||
@@ -8,6 +8,10 @@ import { FirewallPage } from './pages/FirewallPage';
|
|||||||
import { VpnPage } from './pages/VpnPage';
|
import { VpnPage } from './pages/VpnPage';
|
||||||
import { CrowdSecPage } from './pages/CrowdSecPage';
|
import { CrowdSecPage } from './pages/CrowdSecPage';
|
||||||
import { DhcpPage } from './pages/DhcpPage';
|
import { DhcpPage } from './pages/DhcpPage';
|
||||||
|
import {
|
||||||
|
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
|
||||||
|
CrowdsecPage as CrowdsecAdvancedPage,
|
||||||
|
} from './pages/advanced';
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
@@ -24,6 +28,11 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: 'vpn', element: <VpnPage /> },
|
{ path: 'vpn', element: <VpnPage /> },
|
||||||
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
||||||
{ path: 'dhcp', element: <DhcpPage /> },
|
{ path: 'dhcp', element: <DhcpPage /> },
|
||||||
|
{ path: 'advanced/haproxy', element: <HaproxyPage /> },
|
||||||
|
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
|
||||||
|
{ path: 'advanced/nftables', element: <NftablesPage /> },
|
||||||
|
{ path: 'advanced/wireguard', element: <WireguardPage /> },
|
||||||
|
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user