import { Space, Tag } from 'antd'; import * as d3 from 'd3'; import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { ConversationEvidence, InterfaceHostProtocolEvidence, InterfaceProtocolAttachment, InterfaceProtocolPathEvidence, LabelCountEvidence, } from '../../types/analysis'; import type { PacketRow } from '../../types/packets'; export type GraphNodeKind = 'interface' | 'host' | 'protocol'; export type TopologyLayerKind = GraphNodeKind | 'ethernet' | 'ip'; export type TopologyNode = { id: string; label: string; kind: TopologyLayerKind; packetCount: number; interfaceName?: string; ipAddress?: string | null; macAddress?: string | null; protocol?: string; }; export type TopologyLink = { source: string; target: string; value: number; packetCount: number; label: string; }; export type HeatmapRow = { hostId: string; hostLabel: string; interfaceName: string; values: Record; }; export type ProtocolTableRow = { key: string; interface: string; ip_address?: string | null; mac_address?: string | null; host_packet_count: number; protocol: string; protocol_packet_count: number; accept_count: number; drop_count: number; reject_count: number; unknown_count: number; last_seen: string; }; export type TopologyData = { nodes: TopologyNode[]; links: TopologyLink[]; heatmapRows: HeatmapRow[]; protocols: string[]; tableRows: ProtocolTableRow[]; }; export type TopologyOptions = { includeEthernetLayer: boolean; includeIpLayer: boolean; }; export function formatTimestamp(value?: string | null) { if (!value) return '-'; try { const date = new Date(value); return ( date.toLocaleString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }) + `.${String(date.getMilliseconds()).padStart(3, '0')}` ); } catch { return value; } } export function formatBytes(value?: number | null) { const amount = Number(value ?? 0); if (!Number.isFinite(amount) || amount <= 0) return '0 B'; if (amount < 1024) return `${amount} B`; if (amount < 1024 ** 2) return `${(amount / 1024).toFixed(1)} KB`; if (amount < 1024 ** 3) return `${(amount / 1024 ** 2).toFixed(1)} MB`; return `${(amount / 1024 ** 3).toFixed(1)} GB`; } export function formatDurationMs(value?: number | null) { const duration = Number(value ?? 0); if (!Number.isFinite(duration) || duration <= 0) return '0 ms'; if (duration < 1000) return `${duration} ms`; const seconds = duration / 1000; if (seconds < 60) return `${seconds.toFixed(2)} s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) return `${minutes}m ${remainingSeconds.toFixed(1)}s`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return `${hours}h ${remainingMinutes}m`; } export function endpointText(ipAddress?: string | null, macAddress?: string | null) { return ipAddress ?? macAddress ?? 'unknown endpoint'; } export function normalizeIpAddress(value?: string | null) { if (value == null) return null; const trimmed = value.trim(); if (trimmed === '') return null; const slashIndex = trimmed.indexOf('/'); return slashIndex >= 0 ? trimmed.slice(0, slashIndex) : trimmed; } export function conversationRowKey(row: ConversationEvidence) { return [ row.src_ip_address, row.src_mac_address, row.src_port, row.dst_ip_address, row.dst_mac_address, row.dst_port, row.protocol, ].join('|'); } export function asRecord(value: unknown): Record | null { if (value == null || typeof value !== 'object' || Array.isArray(value)) { return null; } return value as Record; } export function packetEventLabel(packet: PacketRow) { const tcpLabel = packetTcpSummary(packet); const activityLabel = packetActivityText(packet); if (tcpLabel && activityLabel && tcpLabel !== activityLabel) { return `${tcpLabel} · ${activityLabel}`; } if (activityLabel) { return activityLabel; } if (tcpLabel) { return tcpLabel; } return String(packet.app_protocol ?? packet.ip_proto ?? packet.eth_type ?? 'Packet'); } export function packetTcpSummary(packet: PacketRow) { const dpiMetadata = asRecord(packet.dpi_metadata); const tcpMeta = asRecord(dpiMetadata?.tcp); const tcpFlags = Array.isArray(tcpMeta?.flag_names) ? tcpMeta.flag_names.filter((flag): flag is string => typeof flag === 'string') : []; const tcpPacketType = typeof tcpMeta?.packet_type === 'string' ? tcpMeta.packet_type : null; return tcpFlags.length > 0 ? tcpFlags.join('-') : tcpPacketType; } export function packetActivityText(packet: PacketRow) { const dpiMetadata = asRecord(packet.dpi_metadata); const httpMeta = asRecord(dpiMetadata?.http); const dnsMeta = asRecord(dpiMetadata?.dns); const tlsMeta = asRecord(dpiMetadata?.tls); const arpMeta = asRecord(dpiMetadata?.arp); const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null; const uri = typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null; if (method) { return `${method} ${uri ?? ''}`.trim(); } const responseCode = httpMeta?.response_code; const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : ''; if (typeof responseCode === 'number' || typeof responseCode === 'string') { return `${responseCode} ${responsePhrase}`.trim(); } const dnsName = typeof dnsMeta?.query_name === 'string' ? dnsMeta.query_name : typeof dnsMeta?.response_name === 'string' ? dnsMeta.response_name : null; if (dnsName) { return dnsName; } const serverName = typeof tlsMeta?.server_name === 'string' ? tlsMeta.server_name : typeof tlsMeta?.sni === 'string' ? tlsMeta.sni : null; if (serverName) { return serverName; } if (typeof arpMeta?.target_proto_ipv4 === 'string') { return arpMeta.target_proto_ipv4; } const appProtocol = typeof packet.app_protocol === 'string' ? packet.app_protocol : null; const ipProtocol = typeof packet.ip_proto === 'string' ? packet.ip_proto : null; const ethernetProtocol = typeof packet.eth_type === 'string' ? packet.eth_type : null; if (appProtocol && appProtocol !== ipProtocol && appProtocol !== ethernetProtocol) { return appProtocol; } return ''; } export function packetHttpDetailText(packet: PacketRow) { const dpiMetadata = asRecord(packet.dpi_metadata); const httpMeta = asRecord(dpiMetadata?.http); const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null; const uri = typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null; if (method) { return `${method} ${uri ?? ''}`.trim(); } const responseCode = httpMeta?.response_code; const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : ''; if (typeof responseCode === 'number' || typeof responseCode === 'string') { return `${responseCode} ${responsePhrase}`.trim(); } return ''; } export function packetDnsDetailText(packet: PacketRow) { const dpiMetadata = asRecord(packet.dpi_metadata); const dnsMeta = asRecord(dpiMetadata?.dns); if (dnsMeta == null) return ''; const queryType = typeof dnsMeta.query_type === 'string' ? dnsMeta.query_type : null; const queryName = typeof dnsMeta.query_name === 'string' ? dnsMeta.query_name : null; const responseName = typeof dnsMeta.response_name === 'string' ? dnsMeta.response_name : null; if (dnsMeta.is_response === false) { return `Query${queryType ? ` ${queryType}` : ''}${queryName ? ` ${queryName}` : ''}`.trim(); } if (dnsMeta.is_response === true) { return `Response${responseName ? ` ${responseName}` : queryName ? ` ${queryName}` : ''}`.trim(); } return queryName ?? responseName ?? ''; } export function packetTlsDetailText(packet: PacketRow) { const dpiMetadata = asRecord(packet.dpi_metadata); const tlsMeta = asRecord(dpiMetadata?.tls); if (tlsMeta == null) return ''; const version = typeof tlsMeta.handshake_version === 'string' ? tlsMeta.handshake_version : null; const serverName = typeof tlsMeta.server_name === 'string' ? tlsMeta.server_name : typeof tlsMeta.sni === 'string' ? tlsMeta.sni : null; const alpn = typeof tlsMeta.alpn === 'string' ? tlsMeta.alpn : null; return [version, serverName, alpn].filter((value): value is string => Boolean(value)).join(' · '); } export function packetArpDetailText(packet: PacketRow) { const dpiMetadata = asRecord(packet.dpi_metadata); const arpMeta = asRecord(dpiMetadata?.arp); if (arpMeta == null) return ''; const opcode = typeof arpMeta.opcode === 'number' ? arpMeta.opcode : null; const opcodeLabel = opcode === 1 ? 'Request' : opcode === 2 ? 'Reply' : opcode === 10 ? 'NAK' : opcode === 16 ? 'InARP' : opcode === 24 ? 'NAK Reply' : opcode === 25 ? 'Peer Request' : opcode != null ? `Op ${opcode}` : null; const targetIp = typeof arpMeta.target_proto_ipv4 === 'string' ? arpMeta.target_proto_ipv4 : null; return [opcodeLabel, targetIp].filter((value): value is string => Boolean(value)).join(' · '); } export function packetIcmpDetailText(packet: PacketRow) { const dpiMetadata = asRecord(packet.dpi_metadata); const icmpMeta = asRecord(dpiMetadata?.icmp); if (icmpMeta == null) return ''; const icmpType = typeof icmpMeta.type_label === 'string' ? icmpMeta.type_label : typeof icmpMeta.type === 'string' ? icmpMeta.type : typeof icmpMeta.icmp_type === 'string' ? icmpMeta.icmp_type : null; const code = typeof icmpMeta.code === 'number' ? String(icmpMeta.code) : typeof icmpMeta.icmp_code === 'string' ? icmpMeta.icmp_code : null; return [icmpType, code ? `code ${code}` : null].filter((value): value is string => Boolean(value)).join(' · '); } function stringList(value: unknown) { if (Array.isArray(value)) { return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== ''); } if (typeof value === 'string' && value.trim() !== '') { return [value]; } return []; } function tcpFlagColor(flagName: string) { const normalized = flagName.toUpperCase(); if (normalized === 'SYN') return 'blue'; if (normalized === 'ACK') return 'cyan'; if (normalized === 'PSH') return 'green'; if (normalized === 'FIN') return 'orange'; if (normalized === 'RST') return 'red'; if (normalized === 'URG') return 'volcano'; if (normalized === 'ECE' || normalized === 'CWR') return 'purple'; return 'default'; } export function renderPacketBadges(packet: PacketRow): ReactNode[] { const badges: ReactNode[] = []; const dpiMetadata = asRecord(packet.dpi_metadata); return badges.concat(renderPacketTcpBadges(packet)); } export function renderPacketTcpBadges(packet: PacketRow): ReactNode[] { const badges: ReactNode[] = []; const dpiMetadata = asRecord(packet.dpi_metadata); const tcpMeta = asRecord(dpiMetadata?.tcp); const tsharkMeta = asRecord(dpiMetadata?.tshark); const flagNames = Array.from( new Set([...stringList(tcpMeta?.flag_names), ...stringList(tsharkMeta?.tcp_flag_names)]), ); for (const flagName of flagNames) { badges.push( {flagName} , ); } const tcpPacketType = typeof tcpMeta?.packet_type === 'string' ? tcpMeta.packet_type : typeof tsharkMeta?.tcp_packet_type === 'string' ? tsharkMeta.tcp_packet_type : null; if (tcpPacketType && flagNames.length === 0) { badges.push( {tcpPacketType} , ); } if (tcpMeta?.retransmission === true) { badges.push( Retransmission , ); } if (tcpMeta?.duplicate_ack === true) { badges.push( Dup ACK , ); } if (tcpMeta?.keep_alive === true) { badges.push( Keep-Alive , ); } return badges; } export function endpointMatches( packetIp: string | null | undefined, packetMac: string | null | undefined, targetIp: string | null | undefined, targetMac: string | null | undefined, ) { if (targetIp == null && targetMac == null) { return false; } const ipMatches = targetIp == null || normalizeIpAddress(packetIp) === normalizeIpAddress(targetIp); const macMatches = targetMac == null || packetMac === targetMac; return ipMatches && macMatches; } export function packetDirection(packet: PacketRow, conversation: ConversationEvidence) { const forward = endpointMatches(packet.src_ip, packet.src_mac, conversation.src_ip_address, conversation.src_mac_address) && endpointMatches(packet.dst_ip, packet.dst_mac, conversation.dst_ip_address, conversation.dst_mac_address) && (conversation.src_port == null || packet.src_port === conversation.src_port) && (conversation.dst_port == null || packet.dst_port === conversation.dst_port); if (forward) return 'forward'; const reverse = endpointMatches(packet.src_ip, packet.src_mac, conversation.dst_ip_address, conversation.dst_mac_address) && endpointMatches(packet.dst_ip, packet.dst_mac, conversation.src_ip_address, conversation.src_mac_address) && (conversation.src_port == null || packet.dst_port === conversation.src_port) && (conversation.dst_port == null || packet.src_port === conversation.dst_port); if (reverse) return 'reverse'; return 'unknown'; } export function renderLabelTags(values: string[], color = 'default') { if (values.length === 0) return '—'; return ( {values.map((value) => ( {value} ))} ); } export function renderLabelCountTags(values: LabelCountEvidence[]) { if (values.length === 0) return '—'; return ( {values.map((value) => ( {value.label}: {value.packet_count} ))} ); } export function hostIdentity(host: InterfaceHostProtocolEvidence) { return `${host.ip_address ?? 'no-ip'}|${host.mac_address ?? 'no-mac'}`; } export function hostLabel(interfaceName: string, host: InterfaceHostProtocolEvidence) { const ip = host.ip_address ?? 'unknown ip'; const mac = host.mac_address ?? 'unknown mac'; return `${interfaceName} • ${ip}\n${mac}`; } export function protocolColor(protocol: string) { const palette = d3.schemeTableau10; let hash = 0; for (let index = 0; index < protocol.length; index += 1) { hash = (hash * 31 + protocol.charCodeAt(index)) >>> 0; } return palette[hash % palette.length]; } export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } export type ChartKind = 'sankey' | 'parallel' | 'force' | 'heatmap' | 'matrix' | 'timeline' | 'sequence'; // Single place to tune overall Analysis visualization sizing. export const ANALYSIS_VISUAL_SCALE = 1; // Per-visualization tuning layered on top of the global Analysis scale. export const ANALYSIS_VISUAL_SCALE_RATES: Record = { sankey: 1.5, parallel: 1.5, force: 1.5, heatmap: 1, matrix: 1.2, timeline: 1.5, sequence: 1.2, }; export function scaleVisual(value: number) { return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE)); } export function scaleVisualFor(kind: ChartKind, value: number) { return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE * ANALYSIS_VISUAL_SCALE_RATES[kind])); } export function useResponsiveChartWidth() { const containerRef = useRef(null); const [viewportWidth, setViewportWidth] = useState(0); useEffect(() => { const container = containerRef.current; if (!container) return; const measureWidth = () => { let width = container.getBoundingClientRect().width; let ancestor = container.parentElement; let depth = 0; while (ancestor != null && depth < 3) { width = Math.max(width, ancestor.getBoundingClientRect().width); ancestor = ancestor.parentElement; depth += 1; } return width; }; const updateSize = () => { const width = measureWidth(); setViewportWidth(width > 0 ? Math.floor(width) : 0); }; updateSize(); const observer = new ResizeObserver(() => updateSize()); observer.observe(container); return () => observer.disconnect(); }, []); return { containerRef, viewportWidth }; } type ChartPolicy = { minWidth: number; minHeight: number; maxHeightPx: number; maxHeightVh: number; }; const chartPolicies: Record = { sankey: { minWidth: scaleVisualFor('sankey', 560), minHeight: scaleVisualFor('sankey', 230), maxHeightPx: scaleVisualFor('sankey', 600), maxHeightVh: 0.62, }, parallel: { minWidth: scaleVisualFor('parallel', 660), minHeight: scaleVisualFor('parallel', 380), maxHeightPx: scaleVisualFor('parallel', 740), maxHeightVh: 0.72, }, force: { minWidth: scaleVisualFor('force', 660), minHeight: scaleVisualFor('force', 340), maxHeightPx: scaleVisualFor('force', 580), maxHeightVh: 0.62, }, heatmap: { minWidth: scaleVisualFor('heatmap', 660), minHeight: scaleVisualFor('heatmap', 320), maxHeightPx: scaleVisualFor('heatmap', 600), maxHeightVh: 0.62, }, matrix: { minWidth: scaleVisualFor('matrix', 660), minHeight: scaleVisualFor('matrix', 320), maxHeightPx: scaleVisualFor('matrix', 600), maxHeightVh: 0.62, }, timeline: { minWidth: scaleVisualFor('timeline', 740), minHeight: scaleVisualFor('timeline', 300), maxHeightPx: scaleVisualFor('timeline', 620), maxHeightVh: 0.66, }, sequence: { minWidth: scaleVisualFor('sequence', 740), minHeight: scaleVisualFor('sequence', 340), maxHeightPx: scaleVisualFor('sequence', 780), maxHeightVh: 0.76, }, }; type ChartSizeOverrides = Partial; function resolveChartMaxHeight(viewportHeight: number, minHeight: number, maxHeightPx: number, maxHeightVh: number) { const viewportCap = viewportHeight > 0 ? Math.floor(viewportHeight * maxHeightVh) : maxHeightPx; return Math.min(maxHeightPx, Math.max(minHeight, viewportCap)); } export function useResponsiveChartSize(kind: ChartKind, overrides: ChartSizeOverrides = {}) { const { containerRef, viewportWidth } = useResponsiveChartWidth(); const [viewportHeight, setViewportHeight] = useState(0); useEffect(() => { const updateViewportHeight = () => { const height = typeof window !== 'undefined' ? window.innerHeight : 0; setViewportHeight(height > 0 ? Math.floor(height) : 0); }; updateViewportHeight(); if (typeof window === 'undefined') return; window.addEventListener('resize', updateViewportHeight); return () => window.removeEventListener('resize', updateViewportHeight); }, []); const policy = { ...chartPolicies[kind], ...overrides }; const chartWidth = viewportWidth > 0 ? Math.floor(viewportWidth) : policy.minWidth; const maxChartHeight = resolveChartMaxHeight( viewportHeight, policy.minHeight, policy.maxHeightPx, policy.maxHeightVh, ); const fitHeight = (desiredHeight: number, localOverrides: ChartSizeOverrides = {}) => { const localMinHeight = localOverrides.minHeight ?? policy.minHeight; const localMaxHeight = resolveChartMaxHeight( viewportHeight, localMinHeight, localOverrides.maxHeightPx ?? policy.maxHeightPx, localOverrides.maxHeightVh ?? policy.maxHeightVh, ); return clamp(desiredHeight, localMinHeight, localMaxHeight); }; return { containerRef, viewportWidth, viewportHeight, chartWidth, minChartHeight: policy.minHeight, maxChartHeight, fitHeight, }; } export function sankeyVisualWeight(packetCount: number) { return Math.max(1, Math.sqrt(Math.max(0, packetCount))); } function addOrUpdateLink( links: Map, source: string, target: string, packetCount: number, label: string, ) { const linkId = `${source}->${target}`; const existing = links.get(linkId); if (existing) { existing.packetCount += packetCount; existing.value = existing.packetCount; existing.label = `${existing.label.split(' (')[0]} (${existing.packetCount})`; return; } links.set(linkId, { source, target, value: packetCount, packetCount, label: `${label} (${packetCount})`, }); } function ensureProtocolNode(nodes: Map, id: string, label: string, kind: TopologyLayerKind) { if (!nodes.has(id)) { nodes.set(id, { id, label, kind, packetCount: 0, protocol: label, }); } return nodes.get(id)!; } export function buildTopologyData(interfaces: InterfaceProtocolAttachment[], options: TopologyOptions): TopologyData { const nodes = new Map(); const links = new Map(); const heatmapByHost = new Map(); const protocols = new Set(); const tableRows: ProtocolTableRow[] = []; for (const entry of interfaces) { const interfaceNodeId = `iface:${entry.interface}`; nodes.set(interfaceNodeId, { id: interfaceNodeId, label: entry.interface, kind: 'interface', packetCount: entry.hosts.reduce((sum, host) => sum + host.packet_count, 0), interfaceName: entry.interface, }); for (const host of entry.hosts) { const hostId = `host:${entry.interface}:${hostIdentity(host)}`; nodes.set(hostId, { id: hostId, label: hostLabel(entry.interface, host), kind: 'host', packetCount: host.packet_count, interfaceName: entry.interface, ipAddress: host.ip_address, macAddress: host.mac_address, }); const interfaceHostLinkId = `${interfaceNodeId}->${hostId}`; links.set(interfaceHostLinkId, { source: interfaceNodeId, target: hostId, value: host.packet_count, packetCount: host.packet_count, label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`, }); const heatmapRow: HeatmapRow = { hostId, hostLabel: `${entry.interface} • ${host.ip_address ?? 'unknown ip'}`, interfaceName: entry.interface, values: {}, }; for (const protocol of host.protocols) { protocols.add(protocol.protocol); const layerPaths = protocol.layer_paths.length > 0 ? protocol.layer_paths : [ { ethernet_protocol: protocol.ethernet_protocol ?? null, ip_protocol: protocol.ip_protocol ?? null, packet_count: protocol.packet_count, last_seen: protocol.last_seen, accept_count: protocol.accept_count, drop_count: protocol.drop_count, reject_count: protocol.reject_count, unknown_count: protocol.unknown_count, }, ]; for (const layerPath of layerPaths) { let currentNodeId = hostId; let currentLabel = host.ip_address ?? host.mac_address ?? 'host'; if ( options.includeEthernetLayer && layerPath.ethernet_protocol && layerPath.ethernet_protocol !== protocol.protocol ) { const ethernetId = `ethernet:${layerPath.ethernet_protocol}`; const ethernetNode = ensureProtocolNode(nodes, ethernetId, layerPath.ethernet_protocol, 'ethernet'); ethernetNode.packetCount += layerPath.packet_count; addOrUpdateLink( links, currentNodeId, ethernetId, layerPath.packet_count, `${currentLabel} -> ${layerPath.ethernet_protocol}`, ); currentNodeId = ethernetId; currentLabel = layerPath.ethernet_protocol; } if ( options.includeIpLayer && layerPath.ip_protocol && layerPath.ip_protocol !== currentLabel && layerPath.ip_protocol !== protocol.protocol ) { const ipId = `ip:${layerPath.ip_protocol}`; const ipNode = ensureProtocolNode(nodes, ipId, layerPath.ip_protocol, 'ip'); ipNode.packetCount += layerPath.packet_count; addOrUpdateLink( links, currentNodeId, ipId, layerPath.packet_count, `${currentLabel} -> ${layerPath.ip_protocol}`, ); currentNodeId = ipId; currentLabel = layerPath.ip_protocol; } if (currentLabel !== protocol.protocol || currentNodeId === hostId) { const protocolId = `protocol:${protocol.protocol}`; const protocolNode = ensureProtocolNode(nodes, protocolId, protocol.protocol, 'protocol'); protocolNode.packetCount += layerPath.packet_count; addOrUpdateLink( links, currentNodeId, protocolId, layerPath.packet_count, `${currentLabel} -> ${protocol.protocol}`, ); } } heatmapRow.values[protocol.protocol] = protocol.packet_count; tableRows.push({ key: `${entry.interface}-${hostIdentity(host)}-${protocol.protocol}`, interface: entry.interface, ip_address: host.ip_address, mac_address: host.mac_address, host_packet_count: host.packet_count, protocol: protocol.protocol, protocol_packet_count: protocol.packet_count, accept_count: protocol.accept_count, drop_count: protocol.drop_count, reject_count: protocol.reject_count, unknown_count: protocol.unknown_count, last_seen: protocol.last_seen, }); } heatmapByHost.set(hostId, heatmapRow); } } return { nodes: Array.from(nodes.values()), links: Array.from(links.values()), heatmapRows: Array.from(heatmapByHost.values()).sort((left, right) => left.hostLabel.localeCompare(right.hostLabel), ), protocols: Array.from(protocols).sort(), tableRows: tableRows.sort( (left, right) => right.protocol_packet_count - left.protocol_packet_count || left.interface.localeCompare(right.interface), ), }; } export function buildDirectionalSankeyData( paths: InterfaceProtocolPathEvidence[], options: TopologyOptions, ): TopologyData { const nodes = new Map(); const links = new Map(); for (const path of paths) { const packetCount = path.packet_count; const ingressLabel = path.ingress_interface ? `${path.ingress_interface} (ingress)` : 'Unknown ingress'; const ingressId = `ingress:${path.ingress_interface ?? 'unknown'}`; const sourceLabel = endpointText(path.src_ip_address, path.src_mac_address); const sourceId = `source:${path.src_ip_address ?? 'no-ip'}|${path.src_mac_address ?? 'no-mac'}`; const protocolLabel = path.protocol; const protocolId = `protocol:${protocolLabel}`; const destinationLabel = endpointText(path.dst_ip_address, path.dst_mac_address); const destinationId = `destination:${path.dst_ip_address ?? 'no-ip'}|${path.dst_mac_address ?? 'no-mac'}`; const egressLabel = path.egress_interface ? `${path.egress_interface} (egress)` : 'Unknown egress'; const egressId = `egress:${path.egress_interface ?? 'unknown'}`; ensureProtocolNode(nodes, ingressId, ingressLabel, 'interface').packetCount += packetCount; nodes.set(sourceId, { ...(nodes.get(sourceId) ?? { id: sourceId, label: sourceLabel, kind: 'host' as const, packetCount: 0, ipAddress: path.src_ip_address, macAddress: path.src_mac_address, }), packetCount: (nodes.get(sourceId)?.packetCount ?? 0) + packetCount, }); ensureProtocolNode(nodes, protocolId, protocolLabel, 'protocol').packetCount += packetCount; nodes.set(destinationId, { ...(nodes.get(destinationId) ?? { id: destinationId, label: destinationLabel, kind: 'host' as const, packetCount: 0, ipAddress: path.dst_ip_address, macAddress: path.dst_mac_address, }), packetCount: (nodes.get(destinationId)?.packetCount ?? 0) + packetCount, }); ensureProtocolNode(nodes, egressId, egressLabel, 'interface').packetCount += packetCount; addOrUpdateLink(links, sourceId, ingressId, packetCount, `${sourceLabel} -> ${ingressLabel}`); let currentNodeId = ingressId; let currentLabel = ingressLabel; if (options.includeEthernetLayer && path.ethernet_protocol && path.ethernet_protocol !== protocolLabel) { const ethernetId = `ethernet:${path.ethernet_protocol}`; ensureProtocolNode(nodes, ethernetId, path.ethernet_protocol, 'ethernet').packetCount += packetCount; addOrUpdateLink(links, currentNodeId, ethernetId, packetCount, `${currentLabel} -> ${path.ethernet_protocol}`); currentNodeId = ethernetId; currentLabel = path.ethernet_protocol; } if ( options.includeIpLayer && path.ip_protocol && path.ip_protocol !== currentLabel && path.ip_protocol !== protocolLabel ) { const ipId = `ip:${path.ip_protocol}`; ensureProtocolNode(nodes, ipId, path.ip_protocol, 'ip').packetCount += packetCount; addOrUpdateLink(links, currentNodeId, ipId, packetCount, `${currentLabel} -> ${path.ip_protocol}`); currentNodeId = ipId; currentLabel = path.ip_protocol; } addOrUpdateLink(links, currentNodeId, protocolId, packetCount, `${currentLabel} -> ${protocolLabel}`); addOrUpdateLink(links, protocolId, egressId, packetCount, `${protocolLabel} -> ${egressLabel}`); addOrUpdateLink(links, egressId, destinationId, packetCount, `${egressLabel} -> ${destinationLabel}`); } return { nodes: Array.from(nodes.values()), links: Array.from(links.values()), heatmapRows: [], protocols: [], tableRows: [], }; }