From 74b961e15210ba84aaa167cf57e0db5c2a21c1aa Mon Sep 17 00:00:00 2001 From: malmert Date: Wed, 1 Apr 2026 23:10:59 +0200 Subject: [PATCH] add visualizations, better scaling --- .../src/components/PacketInspectModal.tsx | 586 ++++ frontend/src/components/PacketViewer.tsx | 131 +- .../analysis/CommunicationViews.tsx | 1064 +++++++ .../analysis/IntelligenceRiskViews.tsx | 104 + .../src/components/analysis/TopologyViews.tsx | 769 +++++ frontend/src/components/analysis/shared.tsx | 926 ++++++ frontend/src/pages/Analysis.tsx | 2758 +---------------- 7 files changed, 3494 insertions(+), 2844 deletions(-) create mode 100644 frontend/src/components/PacketInspectModal.tsx create mode 100644 frontend/src/components/analysis/CommunicationViews.tsx create mode 100644 frontend/src/components/analysis/IntelligenceRiskViews.tsx create mode 100644 frontend/src/components/analysis/TopologyViews.tsx create mode 100644 frontend/src/components/analysis/shared.tsx diff --git a/frontend/src/components/PacketInspectModal.tsx b/frontend/src/components/PacketInspectModal.tsx new file mode 100644 index 0000000..ca12825 --- /dev/null +++ b/frontend/src/components/PacketInspectModal.tsx @@ -0,0 +1,586 @@ +import { DownloadOutlined } from '@ant-design/icons'; +import { Button, Descriptions, Modal, Space, Tabs, Typography } from 'antd'; +import type { ReactNode } from 'react'; +import { useMemo } from 'react'; + +import type { PacketRow } from '../types/packets'; + +const { Text } = Typography; + +const ARP_OPCODE_LABELS: Record = { + 0: 'Reserved', + 1: 'REQUEST', + 2: 'REPLY', + 3: 'request Reverse', + 4: 'reply Reverse', + 5: 'DRARP-Request', + 6: 'DRARP-Reply', + 7: 'DRARP-Error', + 8: 'InARP-Request', + 9: 'InARP-Reply', + 10: 'ARP-NAK', + 11: 'MARS-Request', + 12: 'MARS-Multi', + 13: 'MARS-MServ', + 14: 'MARS-Join', + 15: 'MARS-Leave', + 16: 'MARS-NAK', + 17: 'MARS-Unserv', + 18: 'MARS-SJoin', + 19: 'MARS-SLeave', + 20: 'MARS-Grouplist-Request', + 21: 'MARS-Grouplist-Reply', + 22: 'MARS-Redirect-Map', + 23: 'MAPOS-UNARP', + 24: 'OP_EXP1', + 25: 'OP_EXP2', +}; + +const ICMP_TYPE_LABELS: Record = { + 0: 'Echo Reply', + 3: 'Destination Unreachable', + 4: 'Source Quench (Deprecated)', + 5: 'Redirect', + 6: 'Alternate Host Address (Deprecated)', + 8: 'Echo', + 9: 'Router Advertisement', + 10: 'Router Solicitation', + 11: 'Time Exceeded', + 12: 'Parameter Problem', + 13: 'Timestamp', + 14: 'Timestamp Reply', + 15: 'Information Request (Deprecated)', + 16: 'Information Reply (Deprecated)', + 17: 'Address Mask Request (Deprecated)', + 18: 'Address Mask Reply (Deprecated)', + 19: 'Reserved (for Security)', + 30: 'Traceroute (Deprecated)', + 31: 'Datagram Conversion Error (Deprecated)', + 32: 'Mobile Host Redirect (Deprecated)', + 33: 'IPv6 Where-Are-You (Deprecated)', + 34: 'IPv6 I-Am-Here (Deprecated)', + 35: 'Mobile Registration Request (Deprecated)', + 36: 'Mobile Registration Reply (Deprecated)', + 37: 'Domain Name Request (Deprecated)', + 38: 'Domain Name Reply (Deprecated)', + 39: 'SKIP (Deprecated)', + 40: 'Photuris', + 41: 'ICMP experimental mobility', + 42: 'Extended Echo Request', + 43: 'Extended Echo Reply', + 253: 'RFC3692-style Experiment 1', + 254: 'RFC3692-style Experiment 2', + 255: 'Reserved', +}; + +const DNS_QUERY_TYPE_LABELS: Record = { + 1: 'A', + 2: 'NS', + 5: 'CNAME', + 6: 'SOA', + 12: 'PTR', + 15: 'MX', + 16: 'TXT', + 28: 'AAAA', + 33: 'SRV', + 41: 'OPT', + 43: 'DS', + 46: 'RRSIG', + 47: 'NSEC', + 48: 'DNSKEY', + 50: 'NSEC3', + 51: 'NSEC3PARAM', + 52: 'TLSA', + 59: 'CDS', + 60: 'CDNSKEY', + 61: 'OPENPGPKEY', + 62: 'CSYNC', + 64: 'SVCB', + 65: 'HTTPS', + 255: 'ANY', + 257: 'CAA', +}; + +function base64ToHex(b64: string) { + try { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i); + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(' '); + } catch { + return '(invalid base64)'; + } +} + +function base64ToBlob(b64: string) { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) arr[i] = bin.charCodeAt(i); + return new Blob([arr.buffer], { type: 'application/octet-stream' }); +} + +function base64ToBytes(b64: string) { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) arr[i] = bin.charCodeAt(i); + return arr; +} + +function formatJson(value: unknown) { + if (value == null) return '(no tshark data)'; + try { + return JSON.stringify(value, null, 2); + } catch { + return '(failed to format tshark data)'; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function buildReducedMetadata(packet: PacketRow | null) { + if (!packet) return null; + const dpi = isPlainObject(packet.dpi_metadata) ? { ...packet.dpi_metadata } : null; + if (dpi && 'layers' in dpi) delete dpi.layers; + return { + dpi_metadata: dpi, + capture_metadata: packet.capture_metadata ?? null, + telemetry_metadata: packet.telemetry_metadata ?? null, + }; +} + +function getDpiDict(packet: PacketRow, key: string): Record | null { + const dpi = packet.dpi_metadata; + if (!dpi || typeof dpi !== 'object' || Array.isArray(dpi)) return null; + const value = dpi[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function getStringValue(value: unknown) { + return value == null ? null : String(value); +} + +function getNumberValue(value: unknown) { + return typeof value === 'number' ? value : value == null ? null : Number(value); +} + +function formatTimestamp(ts?: string) { + if (!ts) return '-'; + try { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return String(ts); + return ( + d.toLocaleString('de-DE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + `.${String(d.getMilliseconds()).padStart(3, '0')}` + ); + } catch { + return String(ts); + } +} + +function formatArpOpcode(opcode: number | null) { + if (opcode == null) return null; + if (Object.prototype.hasOwnProperty.call(ARP_OPCODE_LABELS, opcode)) return ARP_OPCODE_LABELS[opcode]; + if (opcode >= 26 && opcode <= 65534) return 'Unassigned'; + return `Op ${opcode}`; +} + +function formatIcmpType(type: number | null) { + if (type == null) return null; + if (Object.prototype.hasOwnProperty.call(ICMP_TYPE_LABELS, type)) return ICMP_TYPE_LABELS[type]; + if (type === 1 || type === 2 || type === 7) return 'Unassigned'; + if (type >= 20 && type <= 29) return 'Reserved (for Robustness Experiment)'; + if (type >= 44 && type <= 252) return 'Unassigned'; + return `Type ${type}`; +} + +function formatDnsQueryType(value: unknown) { + const numeric = getNumberValue(value); + if (numeric != null) return DNS_QUERY_TYPE_LABELS[numeric] ?? `TYPE${numeric}`; + const text = getStringValue(value); + return text || null; +} + +function getFlowId(packet: PacketRow) { + if (packet.flow_id) return String(packet.flow_id); + const tcp = getDpiDict(packet, 'tcp'); + if (tcp?.stream != null) return `tcp:${String(tcp.stream)}`; + const udp = getDpiDict(packet, 'udp'); + if (udp?.stream != null) return `udp:${String(udp.stream)}`; + const tshark = getDpiDict(packet, 'tshark'); + if (tshark?.tcp_stream != null) return `tcp:${String(tshark.tcp_stream)}`; + if (tshark?.udp_stream != null) return `udp:${String(tshark.udp_stream)}`; + return null; +} + +function formatIpProto(packet: PacketRow) { + if (packet.ip_proto) return String(packet.ip_proto); + if (typeof packet.ip_proto_raw === 'number') return String(packet.ip_proto_raw); + return '-'; +} + +function formatProtocolLabel(packet: PacketRow) { + return formatIpProto(packet); +} + +function isLikelyText(bytes: Uint8Array) { + if (bytes.length === 0) return false; + let printable = 0; + for (const byte of bytes) { + if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) printable += 1; + } + return printable / bytes.length >= 0.75; +} + +function decodePayloadText(bytes: Uint8Array) { + try { + return new TextDecoder('utf-8', { fatal: false }).decode(bytes).replace(/\0/g, ''); + } catch { + return null; + } +} + +function extractTransportPayload(packet: PacketRow | null): Uint8Array | null { + if (!packet?.raw_b64) return null; + try { + const bytes = base64ToBytes(packet.raw_b64); + if (bytes.length < 14) return null; + let etherType = (bytes[12] << 8) | bytes[13]; + let offset = 14; + if (etherType === 0x8100 || etherType === 0x88a8) { + if (bytes.length < 18) return null; + etherType = (bytes[16] << 8) | bytes[17]; + offset = 18; + } + if (etherType === 0x0800) { + if (bytes.length < offset + 20) return null; + const ipHeaderLength = (bytes[offset] & 0x0f) * 4; + const protocol = bytes[offset + 9]; + const transportOffset = offset + ipHeaderLength; + if (protocol === 6) { + if (bytes.length < transportOffset + 20) return null; + const tcpHeaderLength = ((bytes[transportOffset + 12] >> 4) & 0x0f) * 4; + return bytes.slice(Math.min(transportOffset + tcpHeaderLength, bytes.length)); + } + if (protocol === 17) { + if (bytes.length < transportOffset + 8) return null; + return bytes.slice(Math.min(transportOffset + 8, bytes.length)); + } + return bytes.slice(Math.min(transportOffset, bytes.length)); + } + if (etherType === 0x86dd) { + if (bytes.length < offset + 40) return null; + const protocol = bytes[offset + 6]; + const transportOffset = offset + 40; + if (protocol === 6) { + if (bytes.length < transportOffset + 20) return null; + const tcpHeaderLength = ((bytes[transportOffset + 12] >> 4) & 0x0f) * 4; + return bytes.slice(Math.min(transportOffset + tcpHeaderLength, bytes.length)); + } + if (protocol === 17) { + if (bytes.length < transportOffset + 8) return null; + return bytes.slice(Math.min(transportOffset + 8, bytes.length)); + } + return bytes.slice(Math.min(transportOffset, bytes.length)); + } + return null; + } catch { + return null; + } +} + +function getDecodedPayload(packet: PacketRow | null) { + const payload = extractTransportPayload(packet); + if (!payload || payload.length === 0) return null; + const text = decodePayloadText(payload); + const textPayload = text && isLikelyText(payload) ? text : null; + const http = packet ? getDpiDict(packet, 'http') : null; + if (http && textPayload) { + const separator = textPayload.includes('\r\n\r\n') ? '\r\n\r\n' : textPayload.includes('\n\n') ? '\n\n' : null; + if (separator) { + const [headerPart, bodyPart = ''] = textPayload.split(separator, 2); + return { payloadText: textPayload, headersText: headerPart.trim(), bodyText: bodyPart.trim() || null }; + } + } + return { payloadText: textPayload, headersText: null, bodyText: null }; +} + +function parseHttpParts(http: Record) { + const rawUri = getStringValue(http.uri); + const host = getStringValue(http.host); + if (!rawUri) return { path: null, queryEntries: [] as Array<[string, string]> }; + try { + const base = rawUri.startsWith('http://') || rawUri.startsWith('https://') ? undefined : `http://${host ?? 'packet.local'}`; + const url = new URL(rawUri, base); + return { path: `${url.pathname}${url.hash}`, queryEntries: Array.from(url.searchParams.entries()) }; + } catch { + const [path, query = ''] = rawUri.split('?', 2); + return { + path: path || rawUri, + queryEntries: query + .split('&') + .filter(Boolean) + .map((item) => { + const [key, value = ''] = item.split('=', 2); + return [decodeURIComponent(key), decodeURIComponent(value)] as [string, string]; + }), + }; + } +} + +function renderKeyValueBlock(title: string, values: Array<[string, ReactNode]>) { + const filtered = values.filter(([, value]) => value !== null && value !== undefined && value !== ''); + if (filtered.length === 0) return null; + return ( +
+ {title} + + {filtered.map(([label, value]) => ( + + {value} + + ))} + +
+ ); +} + +function renderTextBlock(title: string, content: string | null | undefined) { + if (!content) return null; + return ( +
+ {title} +
{content}
+
+ ); +} + +function renderProtocolSummary(packet: PacketRow | null): ReactNode { + if (!packet) return No packet selected.; + const http = getDpiDict(packet, 'http'); + const tls = getDpiDict(packet, 'tls'); + const dns = getDpiDict(packet, 'dns'); + const tcp = getDpiDict(packet, 'tcp'); + const udp = getDpiDict(packet, 'udp'); + const icmp = getDpiDict(packet, 'icmp'); + const arp = getDpiDict(packet, 'arp'); + const tshark = getDpiDict(packet, 'tshark'); + const httpParts = http ? parseHttpParts(http) : null; + const decodedPayload = getDecodedPayload(packet); + const sections: ReactNode[] = []; + + sections.push( + renderKeyValueBlock('Packet', [ + ['Timestamp', formatTimestamp(packet.timestamp)], + ['Flow ID', packet.flow_id ?? getFlowId(packet) ?? '-'], + ['Protocol', formatProtocolLabel(packet)], + ['Application', packet.app_protocol ?? packet.app_master_protocol ?? '-'], + ['Source', packet.src_ip ? `${packet.src_ip}${packet.src_port ? `:${packet.src_port}` : ''}` : '-'], + ['Destination', packet.dst_ip ? `${packet.dst_ip}${packet.dst_port ? `:${packet.dst_port}` : ''}` : '-'], + ['Path', [packet.ingress_if, packet.egress_if].filter(Boolean).join(' -> ') || '-'], + ]), + ); + + if (http) { + sections.push( + renderKeyValueBlock('HTTP', [ + ['Kind', getNumberValue(http.response_code) != null ? 'Response' : getStringValue(http.method) ? 'Request' : null], + ['Method', getStringValue(http.method)], + ['Host', getStringValue(http.host)], + ['URL / URI', getStringValue(http.uri)], + ['Path', httpParts?.path ?? null], + ['Status Code', getNumberValue(http.response_code) ?? null], + ['Reason', getStringValue(http.response_phrase)], + ['Content Type', getStringValue(http.content_type)], + ['User Agent', getStringValue(http.user_agent)], + ['Server', getStringValue(http.server)], + ]), + ); + if (httpParts && httpParts.queryEntries.length > 0) { + sections.push(renderKeyValueBlock('HTTP Query Parameters', httpParts.queryEntries.map(([key, value]) => [key, value]))); + } + } + + if (decodedPayload?.headersText) sections.push(renderTextBlock('Decoded Headers', decodedPayload.headersText)); + if (decodedPayload?.bodyText) { + const httpKind = getNumberValue(http?.response_code) != null ? 'Response Body' : getStringValue(http?.method) ? 'Request Body' : 'Decoded Body'; + sections.push(renderTextBlock(httpKind, decodedPayload.bodyText)); + } else if (decodedPayload?.payloadText) { + sections.push(renderTextBlock('Decoded Payload', decodedPayload.payloadText)); + } + + if (dns) { + sections.push( + renderKeyValueBlock('DNS', [ + ['Kind', dns.is_response === true ? 'Response' : dns.is_response === false ? 'Query' : null], + ['Query Name', getStringValue(dns.query_name)], + ['Query Type', formatDnsQueryType(dns.query_type)], + ['Response Name', getStringValue(dns.response_name)], + ['A Record', Array.isArray(dns.a) ? dns.a.join(', ') : getStringValue(dns.a)], + ['AAAA Record', Array.isArray(dns.aaaa) ? dns.aaaa.join(', ') : getStringValue(dns.aaaa)], + ['CNAME', Array.isArray(dns.cname) ? dns.cname.join(', ') : getStringValue(dns.cname)], + ]), + ); + } + + if (tls) { + sections.push(renderKeyValueBlock('TLS', [ + ['Server Name', getStringValue(tls.server_name)], + ['Version', getStringValue(tls.handshake_version)], + ['ALPN', getStringValue(tls.alpn)], + ])); + } + + if (tcp) { + sections.push(renderKeyValueBlock('TCP', [ + ['Packet Type', getStringValue(tcp.packet_type)], + ['Flags', Array.isArray(tcp.flag_names) ? tcp.flag_names.join(', ') : getStringValue(tcp.flag_names)], + ['Stream', getStringValue(tcp.stream)], + ['Seq', getNumberValue(tcp.seq_raw) ?? null], + ['Ack', getNumberValue(tcp.ack_raw) ?? null], + ['Payload Length', getNumberValue(tcp.payload_len) ?? null], + ['Retransmission', tcp.retransmission === true ? 'yes' : null], + ['Duplicate ACK', tcp.duplicate_ack === true ? 'yes' : null], + ['Keep Alive', tcp.keep_alive === true ? 'yes' : null], + ])); + } else if (udp) { + sections.push(renderKeyValueBlock('UDP', [['Stream', getStringValue(udp.stream)]])); + } + + if (icmp) { + const icmpType = getNumberValue(icmp.type); + sections.push(renderKeyValueBlock('ICMP', [ + ['Type', icmpType ?? null], + ['Name', formatIcmpType(icmpType)], + ['Code', getNumberValue(icmp.code) ?? null], + ])); + } + + if (arp) { + const opcode = getNumberValue(arp.opcode); + sections.push(renderKeyValueBlock('ARP', [ + ['Opcode', opcode ?? null], + ['Operation', formatArpOpcode(opcode)], + ])); + } + + if (tshark) { + sections.push(renderKeyValueBlock('Dissector', [ + ['Wireshark Protocol', getStringValue(tshark.protocol)], + ['Info', getStringValue(tshark.info)], + ['Protocol Stack', Array.isArray(tshark.protocol_stack) ? tshark.protocol_stack.join(' -> ') : getStringValue(tshark.protocol_stack)], + ])); + } + + const content = sections.filter(Boolean); + return content.length > 0 ? {content} : No decoded summary available for this packet.; +} + +export default function PacketInspectModal({ + packet, + open, + onClose, +}: { + packet: PacketRow | null; + open: boolean; + onClose: () => void; +}) { + const rawHex = useMemo(() => (packet?.raw_b64 ? base64ToHex(packet.raw_b64) : '(no raw bytes available)'), [packet]); + const reducedMetadata = useMemo(() => buildReducedMetadata(packet), [packet]); + const fullPacketJson = useMemo(() => formatJson(packet), [packet]); + const decodedPayload = useMemo(() => getDecodedPayload(packet), [packet]); + + const downloadRaw = () => { + if (!packet?.raw_b64) return; + const blob = base64ToBlob(packet.raw_b64); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `packet_${packet.id ?? 'pkt'}.bin`; + anchor.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + + } + width={1100} + > + + + {formatTimestamp(packet?.timestamp)} + {packet?.flow_id ?? getFlowId(packet ?? {}) ?? '-'} + {packet ? formatProtocolLabel(packet) : '-'} + {packet?.app_protocol ?? packet?.app_master_protocol ?? '-'} + {packet?.app_hostname ?? '-'} + {packet?.verdict ?? '-'} + {packet?.src_ip ? `${packet.src_ip}${packet?.src_port ? `:${packet.src_port}` : ''}` : '-'} + {packet?.dst_ip ? `${packet.dst_ip}${packet?.dst_port ? `:${packet.dst_port}` : ''}` : '-'} + {[packet?.ingress_if, packet?.egress_if].filter(Boolean).join(' -> ') || '-'} + {packet?.capture_sources?.join(', ') || '-'} + +
+ Reduced metadata +
{formatJson(reducedMetadata)}
+
+ {decodedPayload?.headersText ? ( +
+ Decoded headers +
{decodedPayload.headersText}
+
+ ) : null} + {decodedPayload?.bodyText ? ( +
+ Decoded body +
{decodedPayload.bodyText}
+
+ ) : decodedPayload?.payloadText ? ( +
+ Decoded payload +
{decodedPayload.payloadText}
+
+ ) : null} + + ), + }, + { key: 'raw', label: 'Raw', children:
{rawHex}
}, + { key: 'full', label: 'Full JSON', children:
{fullPacketJson}
}, + ]} + /> +
+ ); +} diff --git a/frontend/src/components/PacketViewer.tsx b/frontend/src/components/PacketViewer.tsx index 44b6474..6e43421 100644 --- a/frontend/src/components/PacketViewer.tsx +++ b/frontend/src/components/PacketViewer.tsx @@ -4,8 +4,6 @@ import { Button, Card, Col, - Descriptions, - Modal, Row, Segmented, Select, @@ -21,6 +19,7 @@ import { } from 'antd'; import { ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { clearPackets, fetchPackets, getPacketsWebSocketUrl } from '../api/apiClient'; +import PacketInspectModal from './PacketInspectModal'; import type { PacketRow } from '../types/packets'; const { Text, Title } = Typography; @@ -927,8 +926,6 @@ export default function PacketViewer(): ReactElement { const [paused, setPaused] = useState(false); const wsRef = useRef(null); const [hexModalOpen, setHexModalOpen] = useState(false); - const [rawHex, setRawHex] = useState(null); - const [rawB64ForDownload, setRawB64ForDownload] = useState(null); const [selectedPacket, setSelectedPacket] = useState(null); const queuedDuringPause = useRef([]); @@ -1070,27 +1067,10 @@ export default function PacketViewer(): ReactElement { message.info('No inspect data available for this packet'); return; } - setRawHex(row.raw_b64 ? base64ToHex(row.raw_b64) : '(no raw bytes available)'); - setRawB64ForDownload(row.raw_b64 ?? null); setSelectedPacket(row); setHexModalOpen(true); }; - const downloadRaw = () => { - if (!rawB64ForDownload) return; - const blob = base64ToBlob(rawB64ForDownload); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `packet_${selectedPacket?.id ?? 'pkt'}.bin`; - a.click(); - URL.revokeObjectURL(url); - }; - - const reducedMetadata = useMemo(() => buildReducedMetadata(selectedPacket), [selectedPacket]); - const fullPacketJson = useMemo(() => formatJson(selectedPacket), [selectedPacket]); - const decodedPayload = useMemo(() => getDecodedPayload(selectedPacket), [selectedPacket]); - const tableRows = useMemo(() => { const grouped = new Map(); const standalone: PacketTableRow[] = []; @@ -1489,113 +1469,14 @@ export default function PacketViewer(): ReactElement { )} - { + onClose={() => { setHexModalOpen(false); + setSelectedPacket(null); }} - footer={ - - - - - } - width={1100} - > - - - {formatTimestamp(selectedPacket?.timestamp)} - {selectedPacket?.flow_id ?? getFlowId(selectedPacket ?? {}) ?? '-'} - {selectedPacket ? formatProtocolLabel(selectedPacket) : '-'} - {selectedPacket?.app_protocol ?? selectedPacket?.app_master_protocol ?? '-'} - {selectedPacket?.app_hostname ?? '-'} - {selectedPacket?.verdict ?? '-'} - - {selectedPacket?.src_ip ? `${selectedPacket.src_ip}${selectedPacket?.src_port ? `:${selectedPacket.src_port}` : ''}` : '-'} - - - {selectedPacket?.dst_ip ? `${selectedPacket.dst_ip}${selectedPacket?.dst_port ? `:${selectedPacket.dst_port}` : ''}` : '-'} - - - {[selectedPacket?.ingress_if, selectedPacket?.egress_if].filter(Boolean).join(' -> ') || '-'} - - - {selectedPacket?.capture_sources?.join(', ') || '-'} - - -
- Reduced metadata -
-                      {formatJson(reducedMetadata)}
-                    
-
- {decodedPayload?.headersText ? ( -
- Decoded headers -
-                        {decodedPayload.headersText}
-                      
-
- ) : null} - {decodedPayload?.bodyText ? ( -
- Decoded body -
-                        {decodedPayload.bodyText}
-                      
-
- ) : decodedPayload?.payloadText ? ( -
- Decoded payload -
-                        {decodedPayload.payloadText}
-                      
-
- ) : null} - - ), - }, - { - key: 'raw', - label: 'Raw', - children: ( -
-                  {rawHex}
-                
- ), - }, - { - key: 'full', - label: 'Full JSON', - children: ( -
-                  {fullPacketJson}
-                
- ), - }, - ]} - /> -
+ /> ); } diff --git a/frontend/src/components/analysis/CommunicationViews.tsx b/frontend/src/components/analysis/CommunicationViews.tsx new file mode 100644 index 0000000..2a741fc --- /dev/null +++ b/frontend/src/components/analysis/CommunicationViews.tsx @@ -0,0 +1,1064 @@ +import { EyeOutlined } from '@ant-design/icons'; +import { Button, Card, Drawer, Empty, Space, Spin, Table, Tag, Tooltip, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import * as d3 from 'd3'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import type { ConversationEvidence, ConversationFlowDetailResponse } from '../../types/analysis'; +import type { PacketRow } from '../../types/packets'; +import PacketInspectModal from '../PacketInspectModal'; +import { + clamp, + conversationRowKey, + endpointText, + formatBytes, + formatDurationMs, + formatTimestamp, + packetActivityText, + packetArpDetailText, + packetDirection, + packetDnsDetailText, + packetEventLabel, + packetHttpDetailText, + packetIcmpDetailText, + packetTcpSummary, + packetTlsDetailText, + protocolColor, + renderLabelTags, + renderPacketTcpBadges, + scaleVisualFor, + useResponsiveChartSize, +} from './shared.tsx'; + +const { Paragraph, Text } = Typography; + +function formatTimeOnly(value?: string | null) { + if (!value) return '-'; + try { + const date = new Date(value); + return ( + date.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + `.${String(date.getMilliseconds()).padStart(3, '0')}` + ); + } catch { + return value; + } +} + +function parsePreciseTimestamp(value?: string | null) { + if (!value) return null; + const normalized = value.includes(' ') ? value.replace(' ', 'T') : value; + const fractionMatch = normalized.match(/\.(\d+)(?=(Z|[+-]\d{2}:\d{2})$)/); + if (!fractionMatch) { + const parsed = Date.parse(normalized); + return Number.isNaN(parsed) ? null : parsed; + } + + const fraction = fractionMatch[1]; + const microseconds = Number.parseInt(fraction.padEnd(6, '0').slice(0, 6), 10); + const milliseconds = Math.floor(microseconds / 1000); + const fractionalMilliseconds = (microseconds % 1000) / 1000; + const truncated = normalized.replace(`.${fraction}`, `.${String(milliseconds).padStart(3, '0')}`); + const parsed = Date.parse(truncated); + if (Number.isNaN(parsed)) return null; + return parsed + fractionalMilliseconds; +} + +function formatTimeValue(value: number) { + const totalMicroseconds = Math.round(value * 1000); + const wholeMilliseconds = Math.floor(totalMicroseconds / 1000); + const date = new Date(wholeMilliseconds); + const fractionalMicroseconds = totalMicroseconds % 1_000_000; + return ( + date.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + `.${String(fractionalMicroseconds).padStart(6, '0')}` + ); +} + +export function ConversationMatrix({ conversations }: { conversations: ConversationEvidence[] }) { + const scaleVisual = (value: number) => scaleVisualFor('matrix', value); + const svgRef = useRef(null); + const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('matrix'); + + const matrixData = useMemo(() => { + const sourceTotals = new Map(); + const destinationTotals = new Map(); + const cellMap = new Map< + string, + { + source: string; + destination: string; + packetCount: number; + byteCount: number; + protocols: Set; + lastSeen: string; + } + >(); + + for (const conversation of conversations) { + const source = endpointText(conversation.src_ip_address, conversation.src_mac_address); + const destination = endpointText(conversation.dst_ip_address, conversation.dst_mac_address); + sourceTotals.set(source, (sourceTotals.get(source) ?? 0) + conversation.packet_count); + destinationTotals.set(destination, (destinationTotals.get(destination) ?? 0) + conversation.packet_count); + + const key = `${source}->${destination}`; + const existing = cellMap.get(key); + if (existing) { + existing.packetCount += conversation.packet_count; + existing.byteCount += conversation.byte_count; + existing.protocols.add(conversation.protocol); + if (conversation.last_seen > existing.lastSeen) { + existing.lastSeen = conversation.last_seen; + } + } else { + cellMap.set(key, { + source, + destination, + packetCount: conversation.packet_count, + byteCount: conversation.byte_count, + protocols: new Set([conversation.protocol]), + lastSeen: conversation.last_seen, + }); + } + } + + const sources = Array.from(sourceTotals.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 18) + .map(([label]) => label); + const destinations = Array.from(destinationTotals.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 18) + .map(([label]) => label); + + const cells = Array.from(cellMap.values()).filter( + (cell) => sources.includes(cell.source) && destinations.includes(cell.destination), + ); + + return { sources, destinations, cells }; + }, [conversations]); + const maxSourceLabelLength = useMemo( + () => d3.max(matrixData.sources, (source) => source.length) ?? 0, + [matrixData.sources], + ); + const maxDestinationLabelLength = useMemo( + () => d3.max(matrixData.destinations, (destination) => destination.length) ?? 0, + [matrixData.destinations], + ); + const matrixTopMargin = clamp(scaleVisual(maxDestinationLabelLength * 3.4 + 22), scaleVisual(56), scaleVisual(104)); + const matrixLeftMargin = clamp(scaleVisual(maxSourceLabelLength * 6.2 + 18), scaleVisual(120), scaleVisual(220)); + const svgHeight = fitHeight( + Math.max(scaleVisual(320), matrixTopMargin + scaleVisual(30 + matrixData.sources.length * 36)), + ); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + if (matrixData.sources.length === 0 || matrixData.destinations.length === 0) { + return; + } + + const margin = { + top: matrixTopMargin, + right: scaleVisual(22), + bottom: scaleVisual(26), + left: matrixLeftMargin, + }; + const usableWidth = chartWidth; + const width = usableWidth; + const height = svgHeight; + svg.attr('width', width).attr('height', height); + svg.attr('viewBox', `0 0 ${width} ${height}`); + + const x = d3 + .scaleBand() + .domain(matrixData.destinations) + .range([margin.left, width - margin.right]) + .paddingInner(0.08); + const y = d3 + .scaleBand() + .domain(matrixData.sources) + .range([margin.top, height - margin.bottom]) + .paddingInner(0.08); + const maxPackets = d3.max(matrixData.cells, (cell) => cell.packetCount) ?? 1; + const color = d3.scaleSequential(d3.interpolateOrRd).domain([0, maxPackets]); + + svg + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', width) + .attr('height', height) + .attr('rx', scaleVisual(18)) + .attr('fill', '#fbfcfe'); + + const cellLayer = svg.append('g'); + for (const source of matrixData.sources) { + for (const destination of matrixData.destinations) { + const cell = matrixData.cells.find((entry) => entry.source === source && entry.destination === destination); + const packetCount = cell?.packetCount ?? 0; + const byteCount = cell?.byteCount ?? 0; + const group = cellLayer.append('g').attr('transform', `translate(${x(destination) ?? 0},${y(source) ?? 0})`); + + group + .append('rect') + .attr('width', x.bandwidth()) + .attr('height', y.bandwidth()) + .attr('rx', scaleVisual(8)) + .attr('fill', packetCount > 0 ? color(packetCount) : '#eef3f8') + .attr('stroke', '#dce5ef'); + + if (packetCount > 0) { + group + .append('text') + .attr('x', x.bandwidth() / 2) + .attr('y', y.bandwidth() / 2 + scaleVisual(4)) + .attr('text-anchor', 'middle') + .attr('font-size', scaleVisual(10)) + .attr('font-weight', 700) + .attr('fill', packetCount > maxPackets * 0.45 ? '#ffffff' : '#7f2704') + .text(packetCount); + } + + group + .append('title') + .text( + [ + `${source} -> ${destination}`, + `Packets: ${packetCount}`, + `Bytes: ${formatBytes(byteCount)}`, + `Protocols: ${cell ? Array.from(cell.protocols).sort().join(', ') : '—'}`, + `Last seen: ${cell ? formatTimestamp(cell.lastSeen) : '—'}`, + ].join('\n'), + ); + } + } + + svg + .append('g') + .selectAll('text.destination-label') + .data(matrixData.destinations) + .join('text') + .attr('class', 'destination-label') + .attr('x', (destination) => (x(destination) ?? 0) + x.bandwidth() / 2) + .attr('y', margin.top - scaleVisual(12)) + .attr( + 'transform', + (destination) => `rotate(-40, ${(x(destination) ?? 0) + x.bandwidth() / 2}, ${margin.top - scaleVisual(12)})`, + ) + .attr('text-anchor', 'start') + .attr('font-size', scaleVisual(11)) + .attr('font-weight', 600) + .attr('fill', '#29445d') + .text((destination) => destination); + + svg + .append('g') + .selectAll('text.source-label') + .data(matrixData.sources) + .join('text') + .attr('class', 'source-label') + .attr('x', margin.left - scaleVisual(12)) + .attr('y', (source) => (y(source) ?? 0) + y.bandwidth() / 2 + scaleVisual(4)) + .attr('text-anchor', 'end') + .attr('font-size', scaleVisual(11)) + .attr('fill', '#29445d') + .text((source) => source); + }, [chartWidth, matrixData, matrixLeftMargin, matrixTopMargin, svgHeight]); + + if (conversations.length === 0) { + return ; + } + + return ( +
+ +
+ ); +} + +export function ConversationTimeline({ + conversations, + selectedKey, + onSelect, +}: { + conversations: ConversationEvidence[]; + selectedKey?: string | null; + onSelect: (conversation: ConversationEvidence) => void; +}) { + const scaleVisual = (value: number) => scaleVisualFor('timeline', value); + const svgRef = useRef(null); + const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('timeline'); + + const timelineRows = useMemo( + () => + [...conversations] + .sort((left, right) => { + const leftPackets = left.packet_count ?? 0; + const rightPackets = right.packet_count ?? 0; + if (rightPackets !== leftPackets) return rightPackets - leftPackets; + return String(left.first_seen).localeCompare(String(right.first_seen)); + }) + .slice(0, 28), + [conversations], + ); + const timelineRowHeight = scaleVisual(22); + const timelineRowGap = scaleVisual(10); + const timelineInnerHeight = + timelineRows.length * timelineRowHeight + Math.max(0, timelineRows.length - 1) * timelineRowGap; + const svgHeight = fitHeight(Math.max(scaleVisual(220), timelineInnerHeight + scaleVisual(74))); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + if (timelineRows.length === 0) return; + + const parsedTimes = timelineRows + .flatMap((conversation) => [new Date(conversation.first_seen), new Date(conversation.last_seen)]) + .filter((value) => !Number.isNaN(value.getTime())); + if (parsedTimes.length === 0) return; + + const minTime = d3.min(parsedTimes) ?? new Date(); + const maxTime = d3.max(parsedTimes) ?? new Date(minTime.getTime() + 1000); + const adjustedMaxTime = maxTime.getTime() === minTime.getTime() ? new Date(maxTime.getTime() + 1000) : maxTime; + + const width = chartWidth; + const rowLabelFor = (conversation: ConversationEvidence) => + `${endpointText(conversation.src_ip_address, conversation.src_mac_address)} -> ${endpointText(conversation.dst_ip_address, conversation.dst_mac_address)}`; + const visibleRowLabel = (conversation: ConversationEvidence) => { + const label = rowLabelFor(conversation); + return label.length > 34 ? `${label.slice(0, 31)}...` : label; + }; + const maxVisibleLabelLength = d3.max(timelineRows, (conversation) => visibleRowLabel(conversation).length) ?? 0; + const maxProtocolLabelLength = d3.max(timelineRows, (conversation) => conversation.protocol.length) ?? 0; + const margin = { + top: scaleVisual(28), + right: clamp(scaleVisual(maxProtocolLabelLength * 6.5 + 20), scaleVisual(56), scaleVisual(96)), + bottom: scaleVisual(40), + left: clamp(scaleVisual(maxVisibleLabelLength * 6.1 + 18), scaleVisual(128), scaleVisual(200)), + }; + const rowPaddingInner = timelineRowGap / Math.max(timelineRowHeight + timelineRowGap, 1); + const axisTickCount = Math.max(4, Math.round(width / scaleVisual(180))); + const height = svgHeight; + svg.attr('width', width).attr('height', height); + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', width) + .attr('height', height) + .attr('rx', scaleVisual(18)) + .attr('fill', '#fbfcfe'); + + const x = d3 + .scaleTime() + .domain([minTime, adjustedMaxTime]) + .nice(axisTickCount) + .range([margin.left, width - margin.right]); + const [niceMinTime, niceMaxTime] = x.domain() as [Date, Date]; + const tickValues = Array.from( + new Map([niceMinTime, ...x.ticks(axisTickCount), niceMaxTime].map((tick) => [tick.getTime(), tick])).values(), + ).sort((left, right) => left.getTime() - right.getTime()); + const y = d3 + .scaleBand() + .domain(timelineRows.map(conversationRowKey)) + .range([margin.top, height - margin.bottom]) + .paddingInner(rowPaddingInner); + + svg + .append('g') + .attr('transform', `translate(0,${height - margin.bottom})`) + .call(d3.axisBottom(x).tickValues(tickValues).tickPadding(scaleVisual(8)).tickSizeOuter(0)) + .call((group) => group.selectAll('text').attr('fill', '#41566d').attr('font-size', scaleVisual(10.5))) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2').attr('stroke-width', scaleVisual(1))); + + svg + .append('g') + .selectAll('line.row-guide') + .data(timelineRows) + .join('line') + .attr('x1', margin.left) + .attr('x2', width - margin.right) + .attr('y1', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2) + .attr('y2', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2) + .attr('stroke', '#edf2f7') + .attr('stroke-width', scaleVisual(1)); + + const bars = svg + .append('g') + .selectAll('g.timeline-row') + .data(timelineRows) + .join('g') + .attr('class', 'timeline-row') + .style('cursor', 'pointer') + .on('click', (_, conversation) => onSelect(conversation)); + + bars + .append('rect') + .attr('x', (conversation) => x(new Date(conversation.first_seen))) + .attr('y', (conversation) => y(conversationRowKey(conversation)) ?? 0) + .attr('width', (conversation) => + Math.max(scaleVisual(6), x(new Date(conversation.last_seen)) - x(new Date(conversation.first_seen))), + ) + .attr('height', y.bandwidth()) + .attr('rx', scaleVisual(8)) + .attr('fill', (conversation) => protocolColor(conversation.protocol)) + .attr('fill-opacity', (conversation) => + conversationRowKey(conversation) === selectedKey + ? 0.9 + : clamp(0.4 + Math.log10(conversation.packet_count + 1) * 0.18, 0.4, 0.85), + ) + .attr('stroke', (conversation) => + conversationRowKey(conversation) === selectedKey ? '#10243a' : 'rgba(16, 36, 58, 0.15)', + ) + .attr('stroke-width', (conversation) => + conversationRowKey(conversation) === selectedKey ? scaleVisual(2.4) : scaleVisual(1), + ); + + bars + .append('text') + .attr('x', (conversation) => x(new Date(conversation.first_seen)) + scaleVisual(10)) + .attr('y', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2 + scaleVisual(4)) + .attr('text-anchor', 'start') + .attr('font-size', scaleVisual(10)) + .attr('font-weight', 700) + .attr('fill', '#ffffff') + .text((conversation) => conversation.protocol) + .each(function applyVisibility(conversation) { + const barWidth = Math.max( + scaleVisual(6), + x(new Date(conversation.last_seen)) - x(new Date(conversation.first_seen)), + ); + const textLength = (this as SVGTextElement).getComputedTextLength?.() ?? 0; + if (barWidth < textLength + scaleVisual(18)) { + const outsideX = x(new Date(conversation.last_seen)) + scaleVisual(10); + const overflow = outsideX + textLength > width - scaleVisual(8); + d3.select(this) + .attr('x', overflow ? width - scaleVisual(8) : outsideX) + .attr('text-anchor', overflow ? 'end' : 'start') + .attr('fill', protocolColor(conversation.protocol)); + } + }); + + bars + .append('circle') + .attr('cx', (conversation) => x(new Date(conversation.first_seen))) + .attr('cy', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2) + .attr('r', scaleVisual(4.5)) + .attr('fill', '#ffffff') + .attr('stroke', (conversation) => protocolColor(conversation.protocol)) + .attr('stroke-width', scaleVisual(2)); + + bars + .append('text') + .attr('x', margin.left - scaleVisual(12)) + .attr('y', (conversation) => (y(conversationRowKey(conversation)) ?? 0) + y.bandwidth() / 2 + scaleVisual(4)) + .attr('text-anchor', 'end') + .attr('font-size', scaleVisual(11)) + .attr('fill', '#29445d') + .text((conversation) => visibleRowLabel(conversation)); + + bars + .append('title') + .text((conversation) => + [ + `${endpointText(conversation.src_ip_address, conversation.src_mac_address)} -> ${endpointText(conversation.dst_ip_address, conversation.dst_mac_address)}`, + `Ports: ${conversation.src_port ?? '—'} -> ${conversation.dst_port ?? '—'}`, + `Protocol: ${conversation.protocol}`, + `Packets: ${conversation.packet_count}`, + `Bytes: ${formatBytes(conversation.byte_count)}`, + `Duration: ${formatDurationMs(conversation.duration_ms)}`, + `Flows: ${conversation.flow_count}`, + `First seen: ${formatTimestamp(conversation.first_seen)}`, + `Last seen: ${formatTimestamp(conversation.last_seen)}`, + ].join('\n'), + ); + }, [chartWidth, onSelect, selectedKey, svgHeight, timelineRows]); + + if (conversations.length === 0) { + return ; + } + + return ( +
+ +
+ ); +} + +export function ConversationSequenceDiagram({ + conversation, + packets, +}: { + conversation: ConversationEvidence; + packets: PacketRow[]; +}) { + const scaleVisual = (value: number) => scaleVisualFor('sequence', value); + const svgRef = useRef(null); + const { containerRef, chartWidth, minChartHeight } = useResponsiveChartSize('sequence'); + + const sequencePacketEntries = useMemo(() => { + const rawEntries = packets + .map((packet, index) => { + const timestampValue = parsePreciseTimestamp(packet.timestamp); + if (timestampValue == null || Number.isNaN(timestampValue)) return null; + const tcpLabel = packetTcpSummary(packet); + const appLabel = packetActivityText(packet); + const labelChipCount = [tcpLabel, appLabel].filter((label) => label != null && label.trim() !== '').length; + const chipCollisionHeight = scaleVisual(4); + const labelSafetyBuffer = scaleVisual(6); + const requiredGap = + labelChipCount >= 2 + ? chipCollisionHeight + labelSafetyBuffer * 2 + scaleVisual(6) + : labelChipCount === 1 + ? chipCollisionHeight + labelSafetyBuffer * 2 + : scaleVisual(18); + return { packet, timestampValue, index, tcpLabel, appLabel, labelChipCount, requiredGap }; + }) + .filter( + ( + entry, + ): entry is { + packet: PacketRow; + timestampValue: number; + index: number; + tcpLabel: string | null | undefined; + appLabel: string | null | undefined; + labelChipCount: number; + requiredGap: number; + } => entry != null, + ) + .sort((left, right) => left.timestampValue - right.timestampValue || left.index - right.index); + + if (rawEntries.length === 0) return rawEntries; + + const positiveDeltas = rawEntries + .slice(1) + .map((entry, index) => entry.timestampValue - rawEntries[index].timestampValue) + .filter((delta) => delta > 0); + const epsilonStep = positiveDeltas.length > 0 ? Math.max((d3.min(positiveDeltas) ?? 0.001) / 20, 0.001) : 0.001; + + let previousLayoutTimestamp = rawEntries[0].timestampValue - epsilonStep; + return rawEntries.map((entry) => { + const layoutTimestampValue = Math.max(entry.timestampValue, previousLayoutTimestamp + epsilonStep); + previousLayoutTimestamp = layoutTimestampValue; + return { ...entry, layoutTimestampValue }; + }); + }, [packets]); + + useEffect(() => { + if (!svgRef.current) return; + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + if (sequencePacketEntries.length === 0) return; + + const width = chartWidth; + const margin = { + top: scaleVisual(40), + right: scaleVisual(20), + bottom: scaleVisual(26), + left: clamp(width * 0.1, scaleVisual(96), scaleVisual(150)), + }; + const laneScale = d3 + .scalePoint() + .domain(['source', 'ingress', 'egress', 'destination']) + .range([margin.left + scaleVisual(70), width - margin.right - scaleVisual(60)]); + const lanes = [ + { + id: 'source', + x: laneScale('source') ?? margin.left + scaleVisual(70), + label: endpointText(conversation.src_ip_address, conversation.src_mac_address), + }, + { + id: 'ingress', + x: laneScale('ingress') ?? margin.left + scaleVisual(230), + label: conversation.ingress_interface ?? 'Unknown ingress', + }, + { + id: 'egress', + x: laneScale('egress') ?? width - margin.right - scaleVisual(230), + label: conversation.egress_interface ?? 'Unknown egress', + }, + { + id: 'destination', + x: laneScale('destination') ?? width - margin.right - scaleVisual(60), + label: endpointText(conversation.dst_ip_address, conversation.dst_mac_address), + }, + ]; + + const packetEntries = sequencePacketEntries; + if (packetEntries.length === 0) return; + + const parsedTimes = packetEntries.map((entry) => entry.layoutTimestampValue); + const minTime = d3.min(parsedTimes) ?? Date.now(); + const maxTime = d3.max(parsedTimes) ?? minTime + 1000; + const adjustedMaxTime = maxTime === minTime ? maxTime + 1000 : maxTime; + const axisDomainScale = d3.scaleLinear().domain([minTime, adjustedMaxTime]).nice(6); + const [niceMinTime, niceMaxTime] = axisDomainScale.domain() as [number, number]; + const baseHeight = scaleVisual(Math.max(320, packets.length * 18 + 170)); + const requiredRangeHeight = packetEntries.slice(1).reduce((maxHeightNeeded, entry, index) => { + const previousEntry = packetEntries[index]; + const delta = entry.layoutTimestampValue - previousEntry.layoutTimestampValue; + if (delta <= 0) return maxHeightNeeded; + const pairGap = Math.max(previousEntry.requiredGap, entry.requiredGap); + const heightNeeded = ((niceMaxTime - niceMinTime) / delta) * pairGap; + return Math.max(maxHeightNeeded, heightNeeded); + }, 0); + const desiredHeight = Math.ceil( + Math.max(baseHeight, requiredRangeHeight + margin.top + margin.bottom + scaleVisual(56)), + ); + const height = Math.max(minChartHeight, desiredHeight); + const y = d3 + .scaleLinear() + .domain([niceMinTime, niceMaxTime]) + .range([margin.top + scaleVisual(28), height - margin.bottom]); + + svg.attr('width', width).attr('height', height); + svg.attr('viewBox', `0 0 ${width} ${height}`); + + svg + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', width) + .attr('height', height) + .attr('rx', scaleVisual(18)) + .attr('fill', '#fbfcfe'); + + svg + .append('defs') + .append('marker') + .attr('id', 'conversation-arrow') + .attr('viewBox', '0 -5 10 10') + .attr('refX', 9) + .attr('refY', 0) + .attr('markerWidth', scaleVisual(7)) + .attr('markerHeight', scaleVisual(7)) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('fill', '#3f6282'); + + lanes.forEach((lane) => { + svg + .append('line') + .attr('x1', lane.x) + .attr('x2', lane.x) + .attr('y1', margin.top) + .attr('y2', height - margin.bottom) + .attr('stroke', '#d9e3ec') + .attr('stroke-width', scaleVisual(2)); + svg + .append('text') + .attr('x', lane.x) + .attr('y', margin.top - scaleVisual(14)) + .attr('text-anchor', 'middle') + .attr('font-size', scaleVisual(12)) + .attr('font-weight', 700) + .attr('fill', '#42586f') + .text(lane.label); + }); + + svg + .append('g') + .attr('transform', `translate(${margin.left},0)`) + .call( + d3 + .axisLeft(y) + .ticks(6) + .tickFormat((value) => formatTimeValue(Number(value))), + ) + .call((group) => group.selectAll('text').attr('fill', '#41566d').attr('font-size', scaleVisual(10.5))) + .call((group) => group.selectAll('line,path').attr('stroke', '#c8d5e2')); + + const packetLayer = svg.append('g'); + for (const packetEntry of packetEntries) { + const { packet, layoutTimestampValue, tcpLabel, appLabel } = packetEntry; + const direction = packetDirection(packet, conversation); + const yValue = y(layoutTimestampValue); + const pathPoints = + direction === 'reverse' + ? [lanes[3], lanes[2], lanes[1], lanes[0]] + : direction === 'forward' + ? [lanes[0], lanes[1], lanes[2], lanes[3]] + : [lanes[0], lanes[3]]; + const lineGenerator = d3 + .line<{ x: number; y: number }>() + .x((point) => point.x) + .y((point) => point.y) + .curve(d3.curveLinear); + const color = direction === 'reverse' ? '#d95f0e' : direction === 'forward' ? '#2f6fed' : '#7a8ea4'; + const labelChips = [ + tcpLabel ? { text: tcpLabel, fill: '#eef4ff', stroke: '#9db7f5', color: '#224d9b' } : null, + appLabel ? { text: appLabel, fill: '#eef8ee', stroke: '#9fd1a8', color: '#25643a' } : null, + ].filter( + (chip): chip is { text: string; fill: string; stroke: string; color: string } => + chip != null && chip.text.trim() !== '', + ); + + packetLayer + .append('path') + .attr('d', lineGenerator(pathPoints.map((point) => ({ x: point.x, y: yValue }))) ?? '') + .attr('fill', 'none') + .attr('stroke', color) + .attr('stroke-width', scaleVisual(2.4)) + .attr('stroke-opacity', 0.75) + .attr('marker-end', 'url(#conversation-arrow)') + .append('title') + .text( + [ + formatTimestamp(packet.timestamp), + `${endpointText(packet.src_ip, packet.src_mac)}:${packet.src_port ?? '—'} -> ${endpointText(packet.dst_ip, packet.dst_mac)}:${packet.dst_port ?? '—'}`, + packetEventLabel(packet), + `Length: ${packet.length ?? 0} B`, + `Verdict: ${packet.verdict ?? 'unknown'}`, + `Flow: ${packet.flow_id ?? '—'}`, + ].join('\n'), + ); + + packetLayer + .append('circle') + .attr('cx', pathPoints[pathPoints.length - 1].x) + .attr('cy', yValue) + .attr('r', scaleVisual(3.5)) + .attr('fill', color) + .attr('opacity', 0.95); + + if (labelChips.length > 0) { + const chipGroup = packetLayer.append('g'); + const chipWidths = labelChips.map((chip) => + Math.min( + Math.max(scaleVisual(110), width * 0.24), + Math.max(scaleVisual(42), scaleVisual(chip.text.length * 6.6 + 14)), + ), + ); + const totalWidth = + chipWidths.reduce((sum, chipWidth) => sum + chipWidth, 0) + + Math.max(0, labelChips.length - 1) * scaleVisual(8); + const lineMidpointX = (pathPoints[0].x + pathPoints[pathPoints.length - 1].x) / 2; + let currentX = Math.max( + margin.left + scaleVisual(8), + Math.min(lineMidpointX - totalWidth / 2, width - margin.right - totalWidth - scaleVisual(8)), + ); + const labelY = yValue; + + for (const [index, chip] of labelChips.entries()) { + const estimatedWidth = chipWidths[index]; + chipGroup + .append('rect') + .attr('x', currentX) + .attr('y', labelY - scaleVisual(9)) + .attr('width', estimatedWidth) + .attr('height', scaleVisual(18)) + .attr('rx', scaleVisual(9)) + .attr('fill', chip.fill) + .attr('stroke', chip.stroke) + .attr('stroke-width', scaleVisual(1)); + + chipGroup + .append('text') + .attr('x', currentX + scaleVisual(7)) + .attr('y', labelY + scaleVisual(3.5)) + .attr('font-size', scaleVisual(10.5)) + .attr('font-weight', 600) + .attr('fill', chip.color) + .text(chip.text.length > 34 ? `${chip.text.slice(0, 31)}...` : chip.text); + + currentX += estimatedWidth + scaleVisual(8); + } + } + } + }, [chartWidth, conversation, minChartHeight, sequencePacketEntries]); + + if (sequencePacketEntries.length === 0) { + return ; + } + const packetEntries = sequencePacketEntries; + const parsedTimes = packetEntries.map((entry) => entry.layoutTimestampValue); + const minTime = d3.min(parsedTimes) ?? Date.now(); + const maxTime = d3.max(parsedTimes) ?? minTime + 1000; + const adjustedMaxTime = maxTime === minTime ? maxTime + 1000 : maxTime; + const axisDomainScale = d3.scaleLinear().domain([minTime, adjustedMaxTime]).nice(6); + const [niceMinTime, niceMaxTime] = axisDomainScale.domain() as [number, number]; + const baseHeight = scaleVisual(Math.max(320, packets.length * 18 + 170)); + const requiredRangeHeight = packetEntries.slice(1).reduce((maxHeightNeeded, entry, index) => { + const previousEntry = packetEntries[index]; + const delta = entry.layoutTimestampValue - previousEntry.layoutTimestampValue; + if (delta <= 0) return maxHeightNeeded; + const pairGap = Math.max(previousEntry.requiredGap, entry.requiredGap); + const heightNeeded = ((niceMaxTime - niceMinTime) / delta) * pairGap; + return Math.max(maxHeightNeeded, heightNeeded); + }, 0); + const desiredHeight = Math.ceil(Math.max(baseHeight, requiredRangeHeight + scaleVisual(44 + 30 + 56))); + const svgHeight = Math.max(minChartHeight, desiredHeight); + + return ( +
+ +
+ ); +} + +export function ConversationFlowDrawer({ + conversation, + detail, + open, + loading, + onClose, +}: { + conversation: ConversationEvidence | null; + detail: ConversationFlowDetailResponse | null; + open: boolean; + loading: boolean; + onClose: () => void; +}) { + const detailPackets = detail?.packets ?? []; + const [inspectOpen, setInspectOpen] = useState(false); + const [selectedPacket, setSelectedPacket] = useState(null); + + const activityColumns = useMemo>(() => { + const nowrapCell = { style: { whiteSpace: 'nowrap' as const } }; + const hasTcp = detailPackets.some((packet) => renderPacketTcpBadges(packet).length > 0); + const hasHttp = detailPackets.some((packet) => packetHttpDetailText(packet) !== ''); + const hasDns = detailPackets.some((packet) => packetDnsDetailText(packet) !== ''); + const hasTls = detailPackets.some((packet) => packetTlsDetailText(packet) !== ''); + const hasArp = detailPackets.some((packet) => packetArpDetailText(packet) !== ''); + const hasIcmp = detailPackets.some((packet) => packetIcmpDetailText(packet) !== ''); + + const columns: ColumnsType = [ + { + title: 'Timestamp', + dataIndex: 'timestamp', + key: 'timestamp', + onCell: () => nowrapCell, + render: (value?: string) => {formatTimeOnly(value)}, + }, + { + title: 'Direction', + key: 'direction', + onCell: () => nowrapCell, + render: (_, row) => ( + + {`${endpointText(row.src_ip, row.src_mac)}:${row.src_port ?? '—'} -> ${endpointText(row.dst_ip, row.dst_mac)}:${row.dst_port ?? '—'}`} + + ), + }, + ]; + + if (hasTcp) { + columns.push({ + title: 'TCP', + key: 'tcp', + onCell: () => nowrapCell, + render: (_, row) => { + const badges = renderPacketTcpBadges(row); + if (badges.length === 0) return '—'; + return ( + + {badges} + + ); + }, + }); + } + + if (hasHttp) { + columns.push({ + title: 'HTTP', + key: 'http', + onCell: () => nowrapCell, + render: (_, row) => {packetHttpDetailText(row) || '—'}, + }); + } + + if (hasDns) { + columns.push({ + title: 'DNS', + key: 'dns', + onCell: () => nowrapCell, + render: (_, row) => {packetDnsDetailText(row) || '—'}, + }); + } + + if (hasTls) { + columns.push({ + title: 'TLS', + key: 'tls', + onCell: () => nowrapCell, + render: (_, row) => {packetTlsDetailText(row) || '—'}, + }); + } + + if (hasArp) { + columns.push({ + title: 'ARP', + key: 'arp', + onCell: () => nowrapCell, + render: (_, row) => {packetArpDetailText(row) || '—'}, + }); + } + + if (hasIcmp) { + columns.push({ + title: 'ICMP', + key: 'icmp', + onCell: () => nowrapCell, + render: (_, row) => {packetIcmpDetailText(row) || '—'}, + }); + } + + columns.push( + { + title: 'Length', + dataIndex: 'length', + key: 'length', + onCell: () => nowrapCell, + render: (value?: number | null) => {`${value ?? 0} B`}, + }, + { + title: 'Verdict', + dataIndex: 'verdict', + key: 'verdict', + onCell: () => nowrapCell, + render: (value?: string | null) => {value ?? 'unknown'}, + }, + { + title: '', + key: 'actions', + onCell: () => nowrapCell, + render: (_, row) => ( + +