// src/components/PacketViewer.tsx import { CaretDownOutlined, CaretRightOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, SyncOutlined } from '@ant-design/icons'; import { Button, Card, Col, Row, Segmented, Select, Space, Spin, Table, Tag, Tabs, Tooltip, Typography, message, theme, } 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; const { Option } = Select; const DEFAULT_LIMIT = 200; const DEFAULT_PAGE_SIZE = 25; const MAX_PACKETS = 2000; // in-memory cap 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', 17: 'RP', 18: 'AFSDB', 24: 'SIG', 25: 'KEY', 28: 'AAAA', 29: 'LOC', 33: 'SRV', 35: 'NAPTR', 36: 'KX', 37: 'CERT', 39: 'DNAME', 41: 'OPT', 42: 'APL', 43: 'DS', 44: 'SSHFP', 45: 'IPSECKEY', 46: 'RRSIG', 47: 'NSEC', 48: 'DNSKEY', 49: 'DHCID', 50: 'NSEC3', 51: 'NSEC3PARAM', 52: 'TLSA', 55: 'HIP', 59: 'CDS', 60: 'CDNSKEY', 61: 'OPENPGPKEY', 62: 'CSYNC', 63: 'ZONEMD', 64: 'SVCB', 65: 'HTTPS', 99: 'SPF', 108: 'EUI48', 109: 'EUI64', 249: 'TKEY', 250: 'TSIG', 251: 'IXFR', 252: 'AXFR', 255: 'ANY', 256: 'URI', 257: 'CAA', 32768: 'TA', 32769: 'DLV', }; type PacketTableRow = PacketRow & { key: string; __kind: 'packet' | 'flow'; flow_packet_count?: number; children?: PacketTableRow[]; }; function base64ToHex(b64: string) { try { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(' '); } catch { return '(invalid base64)'; } } function base64ToBlob(b64: string) { const bin = atob(b64); const len = bin.length; const arr = new Uint8Array(len); for (let i = 0; i < len; i++) arr[i] = bin.charCodeAt(i); return new Blob([arr.buffer], { type: 'application/octet-stream' }); } function base64ToBytes(b64: string) { const bin = atob(b64); const len = bin.length; const arr = new Uint8Array(len); for (let i = 0; i < len; i++) 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, }; } /** * Improved deterministic color generation * * - returns { background: hsl(...), color: '#000' | '#fff' } * - uses cached Map so results are stable/fast */ const _colorCache = new Map(); function colorForName(name: string) { const key = String(name); const cached = _colorCache.get(key); if (cached) return cached; // simple hash to produce a hue in [0, 359] let h = 2166136261 >>> 0; // FNV offset basis for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; // FNV prime } const hue = h % 360; // Choose saturation and lightness to ensure good contrast // We vary lightness slightly by name so colors differ more const sat = 68; // % // map part of hash to lightness in range [38, 58] const light = 38 + (h % 21); // 38..58 // Use comma syntax for compatibility const background = `hsl(${hue}, ${sat}%, ${light}%)`; // Choose text color for contrast: use white for darker backgrounds const textColor = light < 50 ? '#ffffff' : '#0b0b0b'; const result = { background, color: textColor }; _colorCache.set(key, result); return result; } function packetKey(packet: PacketRow) { return String(packet.correlation_key ?? packet.packet_id ?? packet.packet_uid ?? packet.id ?? `${packet.ingress_if ?? 'if'}:${packet.timestamp ?? ''}`); } function packetTimestampMs(packet: PacketRow) { const time = packet.timestamp ? new Date(packet.timestamp).getTime() : 0; return Number.isNaN(time) ? 0 : time; } function sortPacketsByTimestampDesc(left: PacketRow, right: PacketRow) { return packetTimestampMs(right) - packetTimestampMs(left); } function sortPacketsByTimestampAsc(left: PacketRow, right: PacketRow) { return packetTimestampMs(left) - packetTimestampMs(right); } 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 getStringList(value: unknown): string[] { if (Array.isArray(value)) { return value .map((entry) => getStringValue(entry)) .filter((entry): entry is string => Boolean(entry)); } const text = getStringValue(value); if (!text) return []; return text .split(',') .map((entry) => entry.trim()) .filter(Boolean); } 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; const payloadOffset = transportOffset + tcpHeaderLength; return bytes.slice(Math.min(payloadOffset, 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; const payloadOffset = transportOffset + tcpHeaderLength; return bytes.slice(Math.min(payloadOffset, 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); const queryEntries = query .split('&') .filter(Boolean) .map((item) => { const [key, value = ''] = item.split('=', 2); return [decodeURIComponent(key), decodeURIComponent(value)] as [string, string]; }); return { path: path || rawUri, queryEntries }; } } 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) { const queryType = formatDnsQueryType(dns.query_type); sections.push( renderKeyValueBlock('DNS', [ ['Kind', dns.is_response === true ? 'Response' : dns.is_response === false ? 'Query' : null], ['Query Name', getStringValue(dns.query_name)], ['Query Type', queryType], ['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. ); } 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 formatEthType(packet: PacketRow) { if (packet.eth_type) return String(packet.eth_type); if (typeof packet.eth_type_raw === 'number') return `0x${packet.eth_type_raw.toString(16)}`; return '-'; } 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 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 formatAppDetails(packet: PacketRow) { const http = getDpiDict(packet, 'http'); if (http) { const method = getStringValue(http.method); const statusCode = getNumberValue(http.response_code); const reason = getStringValue(http.response_phrase); if (method) return `Request ${method}`; if (statusCode != null) return `Response ${statusCode}${reason ? ` ${reason}` : ''}`; } const dns = getDpiDict(packet, 'dns'); if (dns) { const queryName = getStringValue(dns.query_name); const queryType = formatDnsQueryType(dns.query_type); if (dns.is_response === false) return `Query${queryType ? ` ${queryType}` : ''}${queryName ? ` ${queryName}` : ''}`; if (dns.is_response === true) return `Response${queryName ? ` ${queryName}` : ''}`; } const tls = getDpiDict(packet, 'tls'); if (tls) { const serverName = getStringValue(tls.server_name); const version = getStringValue(tls.handshake_version); if (serverName) return `SNI ${serverName}`; if (version) return `Handshake ${version}`; } return packet.app_confidence ?? packet.app_category ?? ''; } function buildProtocolBadges(packet: PacketRow): ReactNode[] { const badges: ReactNode[] = []; const tcp = getDpiDict(packet, 'tcp'); const tshark = getDpiDict(packet, 'tshark'); const packetType = getStringValue(tcp?.packet_type ?? tshark?.tcp_packet_type); const flagNames = Array.from( new Set([ ...getStringList(tcp?.flag_names), ...getStringList(tshark?.tcp_flag_names), ]), ); for (const flagName of flagNames) { const normalized = flagName.toUpperCase(); let color = 'default'; if (normalized === 'SYN') color = 'blue'; else if (normalized === 'ACK') color = 'cyan'; else if (normalized === 'PSH') color = 'green'; else if (normalized === 'FIN') color = 'orange'; else if (normalized === 'RST') color = 'red'; else if (normalized === 'URG') color = 'volcano'; else if (normalized === 'ECE' || normalized === 'CWR') color = 'purple'; badges.push( {flagName} , ); } if (packetType && flagNames.length === 0) { badges.push( {packetType} , ); } if (tcp?.retransmission === true) { badges.push( Retransmission , ); } if (tcp?.duplicate_ack === true) { badges.push( Dup ACK , ); } if (tcp?.keep_alive === true) { badges.push( Keep-Alive , ); } return badges; } function buildAppBadges(packet: PacketRow): ReactNode[] { const badges: ReactNode[] = []; const http = getDpiDict(packet, 'http'); const dns = getDpiDict(packet, 'dns'); const tls = getDpiDict(packet, 'tls'); const arp = getDpiDict(packet, 'arp'); const icmp = getDpiDict(packet, 'icmp'); if (http) { const method = getStringValue(http.method); const statusCode = getNumberValue(http.response_code); if (method) { badges.push( Request , ); badges.push( {method} , ); } else if (statusCode != null) { badges.push( Response , ); badges.push( {statusCode} , ); } } if (dns) { const queryType = formatDnsQueryType(dns.query_type); if (dns.is_response === false) { badges.push( DNS Query , ); } else if (dns.is_response === true) { badges.push( DNS Response , ); } if (queryType) { badges.push( {queryType} , ); } } if (tls) { const version = getStringValue(tls.handshake_version); const alpn = getStringValue(tls.alpn); if (version) { badges.push( {version} , ); } if (alpn) { badges.push( {alpn} , ); } } if (arp) { const opcode = getNumberValue(arp.opcode); const label = formatArpOpcode(opcode); if (opcode != null && label) { const color = opcode === 1 ? 'geekblue' : opcode === 2 ? 'cyan' : opcode === 10 || opcode === 16 ? 'volcano' : opcode === 24 || opcode === 25 ? 'purple' : 'default'; badges.push( {label} , ); } } if (icmp) { const icmpType = getNumberValue(icmp.type); const label = formatIcmpType(icmpType); if (icmpType != null && label) { const color = icmpType === 0 || icmpType === 8 ? 'blue' : icmpType === 3 || icmpType === 11 || icmpType === 12 ? 'volcano' : icmpType === 5 ? 'gold' : icmpType === 42 || icmpType === 43 ? 'cyan' : 'default'; badges.push( {label} , ); } } if (badges.length === 0) { const fallback = formatAppDetails(packet); if (fallback) { badges.push( {fallback} , ); } } return badges; } /** Format timestamp to European locale with ms */ 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); } } /** * PacketViewer component */ export default function PacketViewer(): ReactElement { const { token } = theme.useToken(); const [packets, setPackets] = useState([]); const [loading, setLoading] = useState(true); const [statusLoading, setStatusLoading] = useState(false); const [limit, setLimit] = useState(DEFAULT_LIMIT); const [mode, setMode] = useState<'live' | 'history'>('live'); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [paused, setPaused] = useState(false); const wsRef = useRef(null); const [hexModalOpen, setHexModalOpen] = useState(false); const [selectedPacket, setSelectedPacket] = useState(null); const queuedDuringPause = useRef([]); const pushNew = useCallback((newPkts: PacketRow[] | PacketRow) => { setPackets((prev) => { const arr = Array.isArray(newPkts) ? newPkts : [newPkts]; const next = [...prev]; for (const packet of arr) { const key = packetKey(packet); const index = next.findIndex((item) => packetKey(item) === key); if (index >= 0) { next[index] = { ...next[index], ...packet }; } else { next.unshift(packet); } } next.sort((left, right) => { return sortPacketsByTimestampDesc(left, right); }); if (next.length > MAX_PACKETS) return next.slice(0, MAX_PACKETS); return next; }); }, []); // fetch historical packets const fetchHistory = useCallback(async (limitVal: number) => { setStatusLoading(true); try { const res = await fetchPackets(limitVal); const list: PacketRow[] = res.packets ?? []; setPackets([...list].sort(sortPacketsByTimestampDesc)); } catch (err: any) { console.error('fetchHistory error', err); message.error(err?.message ?? 'Failed to fetch packet history'); } finally { setStatusLoading(false); setLoading(false); } }, []); const closeWs = useCallback(() => { if (wsRef.current) { try { wsRef.current.close(); } catch {} wsRef.current = null; } }, []); // open websocket const openWs = useCallback((subscribeRecent: number) => { closeWs(); const wsUrl = getPacketsWebSocketUrl(subscribeRecent); const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => {}; ws.onmessage = (ev) => { try { const data = JSON.parse(ev.data); if (data && data.type === 'recent' && Array.isArray(data.packets)) { if (paused) { queuedDuringPause.current.unshift(...(data.packets as PacketRow[])); } else { pushNew(data.packets as PacketRow[]); } } else if (data && (data.packet_uid || data.raw_b64 || data.id || data.timestamp)) { if (paused) { queuedDuringPause.current.unshift(data as PacketRow); } else { pushNew(data as PacketRow); } } else { console.debug('ws unknown message', data); } } catch (err) { console.error('ws message parse error', err); } }; ws.onerror = (e) => { console.error('ws error', e); }; ws.onclose = () => { wsRef.current = null; }; }, [closeWs, paused, pushNew]); // pause handling: when unpausing, flush queuedDuringPause into list useEffect(() => { if (!paused) { const queued = queuedDuringPause.current; if (queued.length > 0) { pushNew(queued); queuedDuringPause.current = []; } } }, [paused, pushNew]); // start up / mode change: fetch history, optionally attach to live websocket useEffect(() => { setLoading(true); fetchHistory(limit).then(() => { if (mode === 'live') { openWs(limit); } else { closeWs(); } }); return () => { closeWs(); }; }, [closeWs, fetchHistory, limit, mode, openWs]); const handleRefresh = async () => { setLoading(true); try { await fetchHistory(limit); if (mode === 'live') { openWs(limit); } } finally { setLoading(false); } }; const handlePauseToggle = (val: boolean) => { setPaused(val); }; const openRawModal = (row: PacketRow) => { if (!row.raw_b64 && !row.dpi_metadata) { message.info('No inspect data available for this packet'); return; } setSelectedPacket(row); setHexModalOpen(true); }; const tableRows = useMemo(() => { const grouped = new Map(); const standalone: PacketTableRow[] = []; for (const packet of packets) { const flowId = getFlowId(packet); if (!flowId) { standalone.push({ ...packet, key: packetKey(packet), __kind: 'packet' }); continue; } const items = grouped.get(flowId); if (items) { items.push(packet); } else { grouped.set(flowId, [packet]); } } const flowRows: PacketTableRow[] = []; for (const [flowId, flowPackets] of grouped.entries()) { const sortedPackets = [...flowPackets].sort(sortPacketsByTimestampDesc); if (sortedPackets.length === 1) { flowRows.push({ ...sortedPackets[0], flow_id: flowId, key: packetKey(sortedPackets[0]), __kind: 'packet', }); continue; } const latestPacket = [...flowPackets].sort(sortPacketsByTimestampDesc)[0]; const children = sortedPackets.map((packet) => ({ ...packet, flow_id: flowId, key: packetKey(packet), __kind: 'packet' as const, })); flowRows.push({ ...latestPacket, flow_id: flowId, key: `flow:${flowId}`, __kind: 'flow', flow_packet_count: sortedPackets.length, children, }); } return [...flowRows, ...standalone].sort(sortPacketsByTimestampDesc); }, [packets]); const columns = useMemo( () => [ { title: 'Group', key: 'group', width: 130, render: (_: any, rec: PacketTableRow) => rec.__kind === 'flow' ? ( Flow {rec.flow_packet_count ?? 0} packets ) : ( Packet ), }, { title: 'Time', dataIndex: 'timestamp', key: 'timestamp', width: 120, render: (val: any) => {formatTimestamp(val)}, }, { title: 'Path', key: 'path', width: 150, render: (_: any, rec: PacketRow) => ( {[rec.ingress_if, rec.egress_if].filter(Boolean).length > 0 ? ( <> {rec.ingress_if && ( {rec.ingress_if} )} {rec.egress_if && ( {rec.egress_if} )} ) : ( - )} ), }, { title: 'Verdict', key: 'verdict', width: 120, render: (_: any, rec: PacketRow) => { const verdict = rec.verdict ?? '-'; const confidence = rec.verdict_confidence ? ` (${rec.verdict_confidence})` : ''; return ( {verdict} {(rec.verdict_reason ?? '') + confidence} ); }, }, { title: 'MAC (src → dst)', key: 'macs', width: 180, render: (_: any, rec: PacketRow) => (
{rec.src_mac ?? '—'}
{rec.dst_mac ?? '—'}
), }, { title: 'L2', key: 'eth_type', width: 100, render: (_: any, rec: PacketRow) => {formatEthType(rec)}, }, { title: 'Protocol', dataIndex: 'ip_proto', key: 'ip_proto', width: 130, render: (_: any, rec: PacketTableRow) => ( {rec.__kind === 'flow' ? `${formatIpProto(rec)} flow` : formatProtocolLabel(rec)} {rec.__kind === 'packet' ? {buildProtocolBadges(rec)} : null} ), }, { title: 'App', key: 'app', width: 250, render: (_: any, rec: PacketRow) => { const app = rec.app_protocol ?? rec.app_master_protocol ?? '-'; return ( {app} {buildAppBadges(rec)} ); }, }, { title: 'Src IP:Port', key: 'src', width: 180, render: (_: any, rec: PacketRow) => rec.src_ip ? ( {rec.src_ip} {rec.src_port ? `:${rec.src_port}` : ''} ) : ( - ), }, { title: 'Dst IP:Port', key: 'dst', width: 180, render: (_: any, rec: PacketRow) => rec.dst_ip ? ( {rec.dst_ip} {rec.dst_port ? `:${rec.dst_port}` : ''} ) : ( - ), }, { title: 'Len/Pkts', dataIndex: 'length', key: 'length', width: 90, render: (_: any, rec: PacketTableRow) => rec.__kind === 'flow' ? `${rec.flow_packet_count ?? 0} pkts` : typeof rec.length === 'number' ? rec.length.toLocaleString('de-DE') : '-', }, { title: 'Actions', key: 'actions', width: 96, render: (_: any, rec: PacketTableRow) => rec.__kind === 'flow' ? null : ( ) : null}