import { useMemo, useEffect, useState, useCallback, useRef } from 'react'; import { ReactFlow, Handle, Position, applyNodeChanges, type Node, type Edge, type NodeProps, type NodeChange, type ReactFlowInstance, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { Globe, Wifi, Lock, Shield, ShieldAlert, Router, AlertCircle, Loader2, Plus, RotateCw, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui'; 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; onChangeBackend?: (address: string) => 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 BackendNode({ data, leftHandle, rightHandle, bottomHandle }: { data: Record; leftHandle: React.ReactNode; rightHandle: React.ReactNode; bottomHandle: React.ReactNode; }) { const onChangeBackend = data.onChangeBackend as ((addr: string) => void) | undefined; const hasRoutes = data.hasRoutes as boolean; const editable = !!onChangeBackend && !hasRoutes; const [editing, setEditing] = useState(false); const inputRef = useRef(null); const handleSave = () => { const val = inputRef.current?.value.trim(); if (val && onChangeBackend) onChangeBackend(val); setEditing(false); }; return ( <> {leftHandle} setEditing(true) : undefined} tooltip={editable && !editing ? 'Click to edit backend address' : undefined} > {editing && (
e.stopPropagation()}> { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setEditing(false); }} />
)}
{rightHandle} {bottomHandle} ); } function FlowNode({ data }: NodeProps) { const v = data.variant as string; const leftHandle = ; const rightHandle = ; const bottomHandle = ; 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} } interactive onClick={onTogglePublic} tooltip={ isPublic ? (
Public domain
{ddnsIp &&
IP: {ddnsIp}
} {ddnsError &&
{ddnsError}
}
Click to make private
) : '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}
} /> } />
{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} {onChangeMode && (
{(['dns-only', 'l4', 'l7'] as const).map(m => ( ))}
)}
{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}
Certificate: {certDomain}
Expires: {expiry ?? 'unknown'} ({daysLeft}d)
{issuer &&
Issuer: {issuer}
} ) : 'No TLS certificate provisioned' } > {!hasCert && onProvisionCert && ( )} {hasCert && daysLeft < 30 && onRenewCert && ( )}
{rightHandle} ); } if (v === 'backend') { return ; } if (v === 'lan') { return ( <> {leftHandle} } /> {rightHandle} ); } if (v === 'vpn') { const peerCount = data.peerCount as number; const running = data.running as boolean; return ( <> {leftHandle} } /> {rightHandle} ); } if (v === 'router') { const ports = data.ports as string; return ( <> {leftHandle} } tooltip="Your LAN router port-forwards these ports to Wild Central" /> {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 ───────────────────────────────────────────────────── /** Align pipeline nodes so their vertical centers match → straight horizontal edges */ const PIPELINE_IDS = new Set(['internet', 'router', 'security', 'gateway', 'tls', 'backend']); function alignPipelineNodes(nodes: Node[]): Node[] { const pipelineNodes = nodes .filter(n => PIPELINE_IDS.has(n.id) && (n.measured?.width ?? 0) > 0) .sort((a, b) => a.position.x - b.position.x); if (pipelineNodes.length < 2) return nodes; // Vertical: center-align all pipeline nodes let maxHeight = 0; for (const n of pipelineNodes) { const h = n.measured?.height ?? 0; if (h > maxHeight) maxHeight = h; } const centerY = maxHeight / 2; // Horizontal: equalize gaps between nodes const totalNodeWidth = pipelineNodes.reduce((sum, n) => sum + (n.measured?.width ?? 0), 0); const firstX = pipelineNodes[0].position.x; const lastNode = pipelineNodes[pipelineNodes.length - 1]; const totalSpan = (lastNode.position.x + (lastNode.measured?.width ?? 0)) - firstX; const gap = (totalSpan - totalNodeWidth) / (pipelineNodes.length - 1) * 0.75; // Build new x positions const xPositions = new Map(); let x = firstX; for (const n of pipelineNodes) { xPositions.set(n.id, x); x += (n.measured?.width ?? 0) + gap; } return nodes.map(n => { if (!PIPELINE_IDS.has(n.id)) return n; const h = n.measured?.height ?? 0; if (h === 0) return n; const targetY = centerY - h / 2; const targetX = xPositions.get(n.id) ?? n.position.x; return { ...n, position: { x: targetX, y: targetY } }; }); } function AlignedFlow({ nodes: inputNodes, edges, containerHeight }: { nodes: Node[]; edges: Edge[]; containerHeight: number }) { const [nodes, setNodes] = useState(inputNodes); const rfRef = useRef(null); // Reset nodes when inputs change (draft updated, card expanded, etc.) useEffect(() => { setNodes(inputNodes); }, [inputNodes]); // Handle React Flow's internal changes (including dimension measurements) const onNodesChange = useCallback((changes: NodeChange[]) => { setNodes(prev => { const updated = applyNodeChanges(changes, prev); if (changes.some(c => c.type === 'dimensions')) { const aligned = alignPipelineNodes(updated); requestAnimationFrame(() => rfRef.current?.fitView({ padding: 0.15 })); return aligned; } return updated; }); }, []); return (
{ rfRef.current = instance; }} nodeTypes={nodeTypes} fitView fitViewOptions={{ padding: 0.15 }} 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} />
); } export function DomainTopology(props: DomainTopologyProps) { const { domain, cert, ddnsRecord, vpnPeerCount, vpnRunning, vpnConfigured, daemons, onTogglePublic, onChangeMode, onChangeBackend, 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 showRouter = isPublic; 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 isWildcardCert = !!(cert?.cert.wildcard || cert?.coveredBy); const certDomain = hasCert ? (isWildcardCert ? `*.${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 routerX = showRouter ? x : -1; if (showRouter) x += 155; const securityX = showSecurity ? x : -1; if (showSecurity) x += 165; const gatewayX = showGateway ? x : -1; if (showGateway) x += 175; const tlsX = showTls ? x : -1; if (showTls) x += 160; const backendX = x; // Row y positions — all pipeline nodes start at y=0, // then useAlignPipelineNodes() adjusts them after measurement const pipelineY = 0; const lanY = 120; // ── 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 (showRouter) { // Show which ports the router forwards — standard HTTPS + HTTP, plus VPN if configured const ports = [':443', ':80']; if (vpnConfigured) ports.push(`:${51820}`); n.push({ id: 'router', type: 'topology', position: { x: routerX, y: pipelineY }, data: { variant: 'router', ports: `fwd ${ports.join(', ')}` }, }); } if (showSecurity) { n.push({ id: 'security', type: 'topology', position: { x: securityX, y: pipelineY }, 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, isWildcardCert, 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, onChangeBackend, hasRoutes: !!domain.routes?.length }, }); const lanX = showVpn ? sourceX + 120 : sourceX; n.push({ id: 'lan', type: 'topology', position: { x: lanX, y: lanY }, data: { variant: 'lan' }, }); if (showVpn) { n.push({ id: 'vpn', type: 'topology', position: { x: sourceX, y: lanY }, 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: Internet → Router → Security → Gateway (or shorter variants) if (isPublic) { e.push({ id: 'e-internet-router', source: 'internet', target: 'router', ...pipeline }); if (showSecurity) { e.push({ id: 'e-router-security', source: 'router', target: 'security', ...pipeline }); e.push({ id: 'e-security-gateway', source: 'security', target: 'gateway', ...pipeline }); } else if (!isDnsOnly) { e.push({ id: 'e-router-gateway', source: 'router', target: 'gateway', ...pipeline }); } else { // Direct: router forwards to backend, Central not in path e.push({ id: 'e-router-backend', source: 'router', target: 'backend', ...pipeline, style: { ...pipeline.style, strokeDasharray: '6 3' }, label: 'direct', 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 path — enters gateway/backend from below const lanTarget = isL7 ? 'gateway' : 'backend'; e.push({ id: 'e-lan-target', source: 'lan', target: lanTarget, type: 'smoothstep', sourceHandle: 'right', targetHandle: 'bottom', style: { strokeWidth: 1.5 }, label: 'dnsmasq', labelStyle }); // VPN feeds into LAN (tunnel peers land on the local network) if (showVpn) { e.push({ id: 'e-vpn-lan', source: 'vpn', target: 'lan', type: 'smoothstep', sourceHandle: 'right', targetHandle: 'left', style: { strokeWidth: 1.5 } }); } return { nodes: n, edges: e }; }, [ isPublic, isDnsOnly, isL4, isL7, showRouter, showSecurity, showGateway, showTls, showVpn, vpnConfigured, mode, daemons, ddnsStatus, ddnsSublabel, ddnsRecord, tlsStatus, certDomain, daysLeft, hasCert, provisionDomain, cert, backendLabel, backendSublabel, vpnPeerCount, vpnRunning, isMutating, isProvisioningCert, isRenewingCert, onTogglePublic, onChangeMode, onChangeBackend, onProvisionCert, onRenewCert, domain.routes, ]); const containerHeight = 200; return (
{/* Warnings */} {isDnsOnly && (
No firewall, proxy, or TLS protection from Central — traffic goes directly to your server
)} {isL7 && !hasCert && (
L7 routing is inactive until a TLS certificate is provisioned
)} {isPublic && ddnsRecord && !ddnsRecord.ok && (
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}
)} {/* Route table */} {isL7 && domain.routes && domain.routes.length > 0 && (
Route table
{domain.routes.map((route, i) => (
{route.paths?.length ? route.paths.join(', ') : '/*'} {'->'} {route.backend.address} {route.ipAllow && route.ipAllow.length > 0 && ( {route.ipAllow.length} IP rules )}
))}
)}
); }