From 1f3fcf50b4f6eecb76c63bd08050597327821c8b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sat, 11 Jul 2026 02:31:14 +0000 Subject: [PATCH] Advanced pages. Model-driven controls. --- web/package.json | 1 + web/pnpm-lock.yaml | 147 +++++ web/src/components/AppSidebar.tsx | 38 +- web/src/components/DomainsComponent.tsx | 421 ++------------ web/src/components/FirewallComponent.tsx | 45 +- web/src/components/SubsystemPage.tsx | 145 +++++ web/src/components/VpnComponent.tsx | 22 +- .../components/advanced/CrowdsecSubsystem.tsx | 22 + .../components/advanced/DnsmasqSubsystem.tsx | 33 ++ .../components/advanced/HaproxySubsystem.tsx | 41 ++ .../components/advanced/NftablesSubsystem.tsx | 25 + .../advanced/WireguardSubsystem.tsx | 29 + web/src/components/advanced/index.ts | 5 + web/src/components/domains/DomainCard.tsx | 379 +++++++++++++ web/src/components/domains/DomainTopology.tsx | 536 ++++++++++++++++++ web/src/components/domains/TopologyNode.tsx | 91 +++ .../router/pages/advanced/CrowdsecPage.tsx | 10 + web/src/router/pages/advanced/DnsmasqPage.tsx | 10 + web/src/router/pages/advanced/HaproxyPage.tsx | 10 + .../router/pages/advanced/NftablesPage.tsx | 10 + .../router/pages/advanced/WireguardPage.tsx | 10 + web/src/router/pages/advanced/index.ts | 5 + web/src/router/routes.tsx | 9 + 23 files changed, 1600 insertions(+), 444 deletions(-) create mode 100644 web/src/components/SubsystemPage.tsx create mode 100644 web/src/components/advanced/CrowdsecSubsystem.tsx create mode 100644 web/src/components/advanced/DnsmasqSubsystem.tsx create mode 100644 web/src/components/advanced/HaproxySubsystem.tsx create mode 100644 web/src/components/advanced/NftablesSubsystem.tsx create mode 100644 web/src/components/advanced/WireguardSubsystem.tsx create mode 100644 web/src/components/advanced/index.ts create mode 100644 web/src/components/domains/DomainCard.tsx create mode 100644 web/src/components/domains/DomainTopology.tsx create mode 100644 web/src/components/domains/TopologyNode.tsx create mode 100644 web/src/router/pages/advanced/CrowdsecPage.tsx create mode 100644 web/src/router/pages/advanced/DnsmasqPage.tsx create mode 100644 web/src/router/pages/advanced/HaproxyPage.tsx create mode 100644 web/src/router/pages/advanced/NftablesPage.tsx create mode 100644 web/src/router/pages/advanced/WireguardPage.tsx create mode 100644 web/src/router/pages/advanced/index.ts diff --git a/web/package.json b/web/package.json index 0e375bf..591cff0 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d148b52..edea2df 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@xterm/xterm': specifier: ^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: specifier: ^0.7.1 version: 0.7.1 @@ -1595,6 +1598,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -1607,6 +1613,9 @@ packages: '@types/d3-scale@4.0.9': 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': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} @@ -1616,6 +1625,12 @@ packages: '@types/d3-timer@3.0.2': 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': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1766,6 +1781,22 @@ packages: '@xterm/xterm@6.0.0': 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: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1878,6 +1909,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1933,6 +1967,14 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -1953,6 +1995,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -1969,6 +2015,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-urls@6.0.1: resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} engines: {node: '>=20'} @@ -3114,6 +3170,21 @@ packages: zod@3.25.76: 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: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4475,6 +4546,10 @@ snapshots: '@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-interpolate@3.0.4': @@ -4487,6 +4562,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -4495,6 +4572,15 @@ snapshots: '@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': dependencies: '@types/ms': 2.1.0 @@ -4690,6 +4776,31 @@ snapshots: '@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): dependencies: acorn: 8.15.0 @@ -4789,6 +4900,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -4835,6 +4948,13 @@ snapshots: 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-format@3.1.2: {} @@ -4853,6 +4973,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -4867,6 +4989,23 @@ snapshots: 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: dependencies: whatwg-mimetype: 5.0.0 @@ -6191,4 +6330,12 @@ snapshots: 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: {} diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index 6b7c9e0..e5ac4e9 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,5 +1,5 @@ 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 { Sidebar, SidebarContent, @@ -66,6 +66,14 @@ export function AppSidebar() { { 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 ( @@ -82,7 +90,7 @@ export function AppSidebar() { {state === 'collapsed' ? ( - {[...domainsItems, ...centralItems].map((item) => { + {[...domainsItems, ...centralItems, ...advancedItems].map((item) => { const daemon = 'daemon' in item ? (item as any).daemon : undefined; const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined; return ( @@ -149,6 +157,32 @@ export function AppSidebar() { + + + Advanced + + + {advancedItems.map(({ to, icon: Icon, label, daemon }) => { + const active = centralStatus?.daemons?.[daemon]?.active; + return ( + + + {({ isActive }) => ( + + + {label} + {active !== undefined && ( + + )} + + )} + + + ); + })} + + + )} diff --git a/web/src/components/DomainsComponent.tsx b/web/src/components/DomainsComponent.tsx index e532952..3f4c59e 100644 --- a/web/src/components/DomainsComponent.tsx +++ b/web/src/components/DomainsComponent.tsx @@ -3,52 +3,24 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Card, CardContent } from './ui/card'; import { Button } from './ui/button'; import { Input, Label } from './ui'; -import { Alert, AlertDescription } from './ui/alert'; -import { Textarea } from './ui/textarea'; import { Switch } from './ui/switch'; -import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; import { - Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw, - Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText, + Globe, Loader2, + Plus, X, } from 'lucide-react'; -import { useHaproxy } from '../hooks/useHaproxy'; import { useDomains } from '../hooks/useDomains'; import { useDdns } from '../hooks/useDdns'; import { useCert } from '../hooks/useCert'; +import { useVpn } from '../hooks/useVpn'; +import { useCentralStatus } from '../hooks/useCentralStatus'; 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 { domainsApi, haproxyApi } from '../services/api/networking'; - -function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) { - return ( - - {status === 'ok' && } - {status === 'error' && } - {status === 'na' && } - {label} - - ); -} - -function getTlsStatus(service: RegisteredDomain, certDomains: Set): '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'; -} +import { DomainCard } from './domains/DomainCard'; function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined { - // Exact match first const exact = certs.find(c => c.cert.exists && c.domain === domain); if (exact) return exact; - // Wildcard: check parent domain const dotIdx = domain.indexOf('.'); if (dotIdx > 0) { const parent = domain.slice(dotIdx + 1); @@ -57,27 +29,14 @@ function findCert(domain: string, certs: CertEntry[]): CertEntry | 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() { const queryClient = useQueryClient(); - - const { - generate: generateHaproxy, isGenerating: isHaproxyGenerating, - generateData: haproxyGenerateData, generateError: haproxyGenerateError, - restart: restartHaproxy, isRestarting: isHaproxyRestarting, - } = useHaproxy(); + 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: vpnStatus, peers: vpnPeers } = useVpn(); + const { data: centralStatus } = useCentralStatus(); const provisionMutation = useMutation({ mutationFn: (domain: string) => provisionCert(domain), @@ -88,13 +47,8 @@ export function DomainsComponent() { mutationFn: () => renewCert(), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }), }); - - const [expandedDomains, setExpandedDomains] = useState>(new Set()); const [showAddForm, setShowAddForm] = useState(false); - const [showAdvanced, setShowAdvanced] = useState(false); - const [advancedConfig, setAdvancedConfig] = useState(''); - const [loadingAdvanced, setLoadingAdvanced] = useState(false); // Add form state const [newDomain, setNewDomain] = useState(''); @@ -103,12 +57,24 @@ export function DomainsComponent() { const [newSubdomains, setNewSubdomains] = useState(false); const [gatewayMode, setGatewayMode] = useState<'none' | 'reverse-proxy' | 'passthrough'>('reverse-proxy'); + // Derived data for cards const certDomains = new Set( (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({ - mutationFn: (svc: Record) => domainsApi.register(svc as any), + mutationFn: (svc: Parameters[0]) => domainsApi.register(svc), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['domains'] }); setShowAddForm(false); @@ -116,47 +82,16 @@ export function DomainsComponent() { }, }); - const updateMutation = useMutation({ - mutationFn: ({ domain, updates }: { domain: string; updates: Record }) => - 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({ title: 'Domains', description: (

- Each domain is managed by Wild Central. For each, configure whether it just resolves to an IP - or routes through Central's gateway for DNS, proxy routing, TLS, and public exposure. + Each domain is managed by Wild Central. The topology diagram shows how traffic + reaches your services — from the internet, your LAN, and VPN connections.

), }); - 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 (
{/* Header */} @@ -167,7 +102,7 @@ export function DomainsComponent() {

Domains

-

Registered domains and their networking status

+

Registered domains and their networking topology

- - {/* Expanded — controls + status */} - {isExpanded && ( - - {/* Controls */} -
- {!hasRoutes(service) && ( -
- - { - const val = e.target.value.trim(); - if (val && val !== service.backend.address) { - updateMutation.mutate({ - domain: service.domain, - updates: { backend: { ...service.backend, address: val } }, - }); - } - }} - /> -
- )} - -
- updateMutation.mutate({ domain: service.domain, updates: { public: v } })} - disabled={updateMutation.isPending} - /> - -
- -
- updateMutation.mutate({ domain: service.domain, updates: { subdomains: v } })} - disabled={updateMutation.isPending} - /> - -
- - {hasGateway && ( -
- { - 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} - /> - -
- )} -
- - {/* Routes detail */} - {hasRoutes(service) && ( -
- - {service.routes!.map((route, i) => ( -
-
- - {route.paths?.length ? route.paths.join(', ') : '/*'} - - { - 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'] })); - } - }} - /> -
- - {route.headers && Object.keys(route.headers.response ?? {}).length > 0 && ( -
- - Response headers - - {Object.entries(route.headers.response!).map(([k, v]) => ( -
- {k}: {v} -
- ))} -
- )} - - {route.headers && Object.keys(route.headers.request ?? {}).length > 0 && ( -
- - Request headers - - {Object.entries(route.headers.request!).map(([k, v]) => ( -
- {k}: {v} -
- ))} -
- )} - - {route.ipAllow && route.ipAllow.length > 0 && ( -
- - IP allow: {route.ipAllow.join(', ')} - -
- )} -
- ))} -
- )} - - {/* TLS cert info */} - {hasGateway && !isPassthrough && (() => { - const cert = findCert(service.domain, certStatus?.certs ?? []); - if (!cert) { - const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain; - return ( -
-
- - No TLS certificate -
- -
- ); - } - 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 ( -
-
- - - {isWildcard ? `*.${cert.domain}` : cert.domain} - - Expires {expiry} ({daysLeft}d) - {cert.cert.issuerCN && {cert.cert.issuerCN}} -
- {daysLeft < 30 && ( - - )} -
- ); - })()} - - {/* DDNS error info */} - {ddnsStatus2 === 'error' && ddnsRecord && ( -
-
- - - DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}. - {' '}Check that your Cloudflare API token has access to this domain's zone. - -
- -
- )} - - {/* Source + deregister */} -
- Source: {service.source} - -
-
- )} - - ); - })} - - {/* Advanced */} - - - - - - - -
- - -
- {haproxyGenerateData && ( - - - {haproxyGenerateData.message} - - )} - {haproxyGenerateError && ( - {(haproxyGenerateError as Error).message} - )} -