Advanced pages. Model-driven controls.

This commit is contained in:
2026-07-11 02:31:14 +00:00
parent cd2f28df34
commit 1f3fcf50b4
23 changed files with 1600 additions and 444 deletions

View File

@@ -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
View File

@@ -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: {}

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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>

View 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' },
]}
/>
);
}

View 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}
/>
);
}

View 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 },
]}
/>
);
}

View 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 },
]}
/>
);
}

View 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 },
]}
/>
);
}

View 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';

View 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>
);
}

View 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>
);
}

View 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;
},
);

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { CrowdsecSubsystem } from '../../../components/advanced';
export function CrowdsecPage() {
return (
<ErrorBoundary>
<CrowdsecSubsystem />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { DnsmasqSubsystem } from '../../../components/advanced';
export function DnsmasqPage() {
return (
<ErrorBoundary>
<DnsmasqSubsystem />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { HaproxySubsystem } from '../../../components/advanced';
export function HaproxyPage() {
return (
<ErrorBoundary>
<HaproxySubsystem />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { NftablesSubsystem } from '../../../components/advanced';
export function NftablesPage() {
return (
<ErrorBoundary>
<NftablesSubsystem />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { WireguardSubsystem } from '../../../components/advanced';
export function WireguardPage() {
return (
<ErrorBoundary>
<WireguardSubsystem />
</ErrorBoundary>
);
}

View 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';

View File

@@ -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 /> },
], ],
}, },
{ {