1483 lines
44 KiB
TypeScript
1483 lines
44 KiB
TypeScript
// 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<number, string> = {
|
|
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<number, string> = {
|
|
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<number, string> = {
|
|
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<string, unknown> {
|
|
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<string, { background: string; color: string }>();
|
|
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<string, unknown> | 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<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown>) {
|
|
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 (
|
|
<div>
|
|
<Text strong>{title}</Text>
|
|
<Descriptions bordered size="small" column={2} style={{ marginTop: 8 }}>
|
|
{filtered.map(([label, value]) => (
|
|
<Descriptions.Item key={`${title}-${label}`} label={label}>
|
|
{value}
|
|
</Descriptions.Item>
|
|
))}
|
|
</Descriptions>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderTextBlock(title: string, content: string | null | undefined) {
|
|
if (!content) return null;
|
|
return (
|
|
<div>
|
|
<Text strong>{title}</Text>
|
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{content}</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderProtocolSummary(packet: PacketRow | null): ReactNode {
|
|
if (!packet) return <Text type="secondary">No packet selected.</Text>;
|
|
|
|
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 ? (
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
{content}
|
|
</Space>
|
|
) : (
|
|
<Text type="secondary">No decoded summary available for this packet.</Text>
|
|
);
|
|
}
|
|
|
|
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(
|
|
<Tag key={`protocol-flag-${flagName}`} color={color} style={{ marginInlineEnd: 4 }}>
|
|
{flagName}
|
|
</Tag>,
|
|
);
|
|
}
|
|
|
|
if (packetType && flagNames.length === 0) {
|
|
badges.push(
|
|
<Tag key={`protocol-type-${packetType}`} color="default" style={{ marginInlineEnd: 4 }}>
|
|
{packetType}
|
|
</Tag>,
|
|
);
|
|
}
|
|
if (tcp?.retransmission === true) {
|
|
badges.push(
|
|
<Tag key="protocol-retransmission" color="red" style={{ marginInlineEnd: 4 }}>
|
|
Retransmission
|
|
</Tag>,
|
|
);
|
|
}
|
|
if (tcp?.duplicate_ack === true) {
|
|
badges.push(
|
|
<Tag key="protocol-dup-ack" color="volcano" style={{ marginInlineEnd: 4 }}>
|
|
Dup ACK
|
|
</Tag>,
|
|
);
|
|
}
|
|
if (tcp?.keep_alive === true) {
|
|
badges.push(
|
|
<Tag key="protocol-keepalive" color="lime" style={{ marginInlineEnd: 4 }}>
|
|
Keep-Alive
|
|
</Tag>,
|
|
);
|
|
}
|
|
|
|
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(
|
|
<Tag key="http-request" color="green" style={{ marginInlineEnd: 4 }}>
|
|
Request
|
|
</Tag>,
|
|
);
|
|
badges.push(
|
|
<Tag key={`http-method-${method}`} color="blue" style={{ marginInlineEnd: 4 }}>
|
|
{method}
|
|
</Tag>,
|
|
);
|
|
} else if (statusCode != null) {
|
|
badges.push(
|
|
<Tag key="http-response" color="cyan" style={{ marginInlineEnd: 4 }}>
|
|
Response
|
|
</Tag>,
|
|
);
|
|
badges.push(
|
|
<Tag key={`http-status-${statusCode}`} color="geekblue" style={{ marginInlineEnd: 4 }}>
|
|
{statusCode}
|
|
</Tag>,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (dns) {
|
|
const queryType = formatDnsQueryType(dns.query_type);
|
|
if (dns.is_response === false) {
|
|
badges.push(
|
|
<Tag key="dns-query" color="purple" style={{ marginInlineEnd: 4 }}>
|
|
DNS Query
|
|
</Tag>,
|
|
);
|
|
} else if (dns.is_response === true) {
|
|
badges.push(
|
|
<Tag key="dns-response" color="magenta" style={{ marginInlineEnd: 4 }}>
|
|
DNS Response
|
|
</Tag>,
|
|
);
|
|
}
|
|
if (queryType) {
|
|
badges.push(
|
|
<Tag key={`dns-type-${queryType}`} color="processing" style={{ marginInlineEnd: 4 }}>
|
|
{queryType}
|
|
</Tag>,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tls) {
|
|
const version = getStringValue(tls.handshake_version);
|
|
const alpn = getStringValue(tls.alpn);
|
|
if (version) {
|
|
badges.push(
|
|
<Tag key={`tls-version-${version}`} color="gold" style={{ marginInlineEnd: 4 }}>
|
|
{version}
|
|
</Tag>,
|
|
);
|
|
}
|
|
if (alpn) {
|
|
badges.push(
|
|
<Tag key={`tls-alpn-${alpn}`} color="orange" style={{ marginInlineEnd: 4 }}>
|
|
{alpn}
|
|
</Tag>,
|
|
);
|
|
}
|
|
}
|
|
|
|
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(
|
|
<Tag key={`arp-op-${opcode}`} color={color} style={{ marginInlineEnd: 4 }}>
|
|
{label}
|
|
</Tag>,
|
|
);
|
|
}
|
|
}
|
|
|
|
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(
|
|
<Tag key={`icmp-type-${icmpType}`} color={color} style={{ marginInlineEnd: 4 }}>
|
|
{label}
|
|
</Tag>,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (badges.length === 0) {
|
|
const fallback = formatAppDetails(packet);
|
|
if (fallback) {
|
|
badges.push(
|
|
<Text key="fallback-detail" type="secondary" style={{ fontSize: 12 }}>
|
|
{fallback}
|
|
</Text>,
|
|
);
|
|
}
|
|
}
|
|
|
|
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<PacketRow[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [statusLoading, setStatusLoading] = useState<boolean>(false);
|
|
const [limit, setLimit] = useState<number>(DEFAULT_LIMIT);
|
|
const [mode, setMode] = useState<'live' | 'history'>('live');
|
|
const [pageSize, setPageSize] = useState<number>(DEFAULT_PAGE_SIZE);
|
|
const [paused, setPaused] = useState<boolean>(false);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const [hexModalOpen, setHexModalOpen] = useState(false);
|
|
const [selectedPacket, setSelectedPacket] = useState<PacketRow | null>(null);
|
|
const queuedDuringPause = useRef<PacketRow[]>([]);
|
|
|
|
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<PacketTableRow[]>(() => {
|
|
const grouped = new Map<string, PacketRow[]>();
|
|
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' ? (
|
|
<Space direction="vertical" size={0}>
|
|
<Tag color="blue" style={{ width: 'fit-content', marginInlineEnd: 0 }}>
|
|
Flow
|
|
</Tag>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{rec.flow_packet_count ?? 0} packets
|
|
</Text>
|
|
</Space>
|
|
) : (
|
|
<Text type="secondary" style={{ paddingLeft: 18 }}>
|
|
Packet
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
title: 'Time',
|
|
dataIndex: 'timestamp',
|
|
key: 'timestamp',
|
|
width: 120,
|
|
render: (val: any) => <Text>{formatTimestamp(val)}</Text>,
|
|
},
|
|
{
|
|
title: 'Path',
|
|
key: 'path',
|
|
width: 150,
|
|
render: (_: any, rec: PacketRow) => (
|
|
<Space wrap size={[6, 6]}>
|
|
{[rec.ingress_if, rec.egress_if].filter(Boolean).length > 0 ? (
|
|
<>
|
|
{rec.ingress_if && (
|
|
<Tooltip title={`ingress: ${rec.ingress_if}`}>
|
|
<Tag style={{ ...colorForName(rec.ingress_if), fontWeight: 600, borderRadius: 6, fontSize: 15 }}>
|
|
{rec.ingress_if}
|
|
</Tag>
|
|
</Tooltip>
|
|
)}
|
|
{rec.egress_if && (
|
|
<Tooltip title={`egress: ${rec.egress_if}`}>
|
|
<Tag style={{ ...colorForName(rec.egress_if), fontWeight: 600, borderRadius: 6, fontSize: 15 }}>
|
|
{rec.egress_if}
|
|
</Tag>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Text type="secondary">-</Text>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: 'Verdict',
|
|
key: 'verdict',
|
|
width: 120,
|
|
render: (_: any, rec: PacketRow) => {
|
|
const verdict = rec.verdict ?? '-';
|
|
const confidence = rec.verdict_confidence ? ` (${rec.verdict_confidence})` : '';
|
|
return (
|
|
<Space direction="vertical" size={0}>
|
|
<Text>{verdict}</Text>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{(rec.verdict_reason ?? '') + confidence}
|
|
</Text>
|
|
</Space>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'MAC (src → dst)',
|
|
key: 'macs',
|
|
width: 180,
|
|
render: (_: any, rec: PacketRow) => (
|
|
<div>
|
|
<div>
|
|
<Text>{rec.src_mac ?? '—'}</Text>
|
|
</div>
|
|
<div style={{ marginTop: 6 }}>
|
|
<Text>{rec.dst_mac ?? '—'}</Text>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'L2',
|
|
key: 'eth_type',
|
|
width: 100,
|
|
render: (_: any, rec: PacketRow) => <Text>{formatEthType(rec)}</Text>,
|
|
},
|
|
{
|
|
title: 'Protocol',
|
|
dataIndex: 'ip_proto',
|
|
key: 'ip_proto',
|
|
width: 130,
|
|
render: (_: any, rec: PacketTableRow) => (
|
|
<Space direction="vertical" size={0}>
|
|
<Text>{rec.__kind === 'flow' ? `${formatIpProto(rec)} flow` : formatProtocolLabel(rec)}</Text>
|
|
{rec.__kind === 'packet' ? <Space size={[0, 4]} wrap>{buildProtocolBadges(rec)}</Space> : null}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: 'App',
|
|
key: 'app',
|
|
width: 250,
|
|
render: (_: any, rec: PacketRow) => {
|
|
const app = rec.app_protocol ?? rec.app_master_protocol ?? '-';
|
|
return (
|
|
<Space direction="vertical" size={0}>
|
|
<Text>{app}</Text>
|
|
<Space size={[0, 4]} wrap>
|
|
{buildAppBadges(rec)}
|
|
</Space>
|
|
</Space>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: 'Src IP:Port',
|
|
key: 'src',
|
|
width: 180,
|
|
render: (_: any, rec: PacketRow) =>
|
|
rec.src_ip ? (
|
|
<Text copyable={{ text: `${rec.src_ip}${rec.src_port ? `:${rec.src_port}` : ''}` }}>
|
|
{rec.src_ip}
|
|
{rec.src_port ? `:${rec.src_port}` : ''}
|
|
</Text>
|
|
) : (
|
|
<Text type="secondary">-</Text>
|
|
),
|
|
},
|
|
{
|
|
title: 'Dst IP:Port',
|
|
key: 'dst',
|
|
width: 180,
|
|
render: (_: any, rec: PacketRow) =>
|
|
rec.dst_ip ? (
|
|
<Text copyable={{ text: `${rec.dst_ip}${rec.dst_port ? `:${rec.dst_port}` : ''}` }}>
|
|
{rec.dst_ip}
|
|
{rec.dst_port ? `:${rec.dst_port}` : ''}
|
|
</Text>
|
|
) : (
|
|
<Text type="secondary">-</Text>
|
|
),
|
|
},
|
|
{
|
|
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 : (
|
|
<Space>
|
|
<Tooltip title="View raw + tshark">
|
|
<Button
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => openRawModal(rec)}
|
|
aria-label="Inspect packet"
|
|
/>
|
|
</Tooltip>
|
|
{rec.raw_b64 && (
|
|
<Tooltip title="Download raw">
|
|
<Button
|
|
size="small"
|
|
icon={<DownloadOutlined />}
|
|
onClick={() => {
|
|
const blob = base64ToBlob(rec.raw_b64!);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `packet_${rec.id ?? 'pkt'}.bin`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}}
|
|
aria-label="Download raw packet"
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className="packet-viewer">
|
|
<style>
|
|
{`
|
|
.packet-viewer .ant-table-tbody > tr.packet-flow-row > td {
|
|
background: #eef6ff;
|
|
border-top: 1px solid #cfe2ff;
|
|
border-bottom: 1px solid #cfe2ff;
|
|
}
|
|
|
|
.packet-viewer .ant-table-tbody > tr.packet-child-row > td {
|
|
background: #fafcff;
|
|
}
|
|
|
|
.packet-viewer .ant-table-tbody > tr.packet-child-row > td:first-child {
|
|
border-left: 3px solid #d6e4ff;
|
|
}
|
|
|
|
.packet-viewer .ant-table-row-expand-icon {
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.packet-viewer .packet-flow-label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.packet-viewer .packet-mode-toggle.ant-segmented {
|
|
background: ${token.colorFillTertiary};
|
|
}
|
|
|
|
.packet-viewer .packet-mode-toggle .ant-segmented-item-selected {
|
|
background: ${token.colorPrimary};
|
|
color: ${token.colorTextLightSolid};
|
|
}
|
|
|
|
.packet-viewer .packet-mode-toggle .ant-segmented-item-selected:hover {
|
|
color: ${token.colorTextLightSolid};
|
|
}
|
|
`}
|
|
</style>
|
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
|
<Col>
|
|
<Title level={3}>Packets</Title>
|
|
<div>
|
|
<Text type="secondary">
|
|
{mode === 'live' ? 'Live packet viewer via websocket stream' : 'Historical packet viewer from database'}
|
|
</Text>
|
|
</div>
|
|
</Col>
|
|
|
|
<Col>
|
|
<Space>
|
|
<Segmented
|
|
className="packet-mode-toggle"
|
|
value={mode}
|
|
onChange={(value) => setMode(value as 'live' | 'history')}
|
|
options={[
|
|
{ label: 'Live packets', value: 'live' },
|
|
{ label: 'History', value: 'history' },
|
|
]}
|
|
/>
|
|
|
|
<Text>{mode === 'live' ? 'Recent buffer' : 'History size'}</Text>
|
|
<Select value={limit} onChange={(v) => setLimit(Number(v))} style={{ width: 120 }}>
|
|
<Option value={50}>50</Option>
|
|
<Option value={100}>100</Option>
|
|
<Option value={200}>200</Option>
|
|
<Option value={500}>500</Option>
|
|
</Select>
|
|
|
|
<Text>Page size</Text>
|
|
<Select value={pageSize} onChange={(v) => setPageSize(Number(v))} style={{ width: 120 }}>
|
|
<Option value={25}>25</Option>
|
|
<Option value={50}>50</Option>
|
|
<Option value={100}>100</Option>
|
|
<Option value={200}>200</Option>
|
|
</Select>
|
|
|
|
{mode === 'live' ? (
|
|
<Button onClick={() => handlePauseToggle(!paused)}>
|
|
{paused ? 'Resume live' : 'Pause live'}
|
|
</Button>
|
|
) : null}
|
|
|
|
<Button icon={<SyncOutlined />} onClick={handleRefresh} loading={statusLoading} />
|
|
<Button
|
|
onClick={async () => {
|
|
await clearPackets();
|
|
setPackets([]);
|
|
}}
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
/>
|
|
</Space>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Card>
|
|
{loading ? (
|
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
|
<Spin />
|
|
</div>
|
|
) : packets.length === 0 ? (
|
|
<div style={{ padding: 12 }}>
|
|
<Text type="secondary">No packets captured yet.</Text>
|
|
</div>
|
|
) : (
|
|
<Table
|
|
rowKey={(rec: PacketTableRow) => rec.key}
|
|
dataSource={tableRows}
|
|
columns={columns as any}
|
|
pagination={{
|
|
pageSize,
|
|
showSizeChanger: false,
|
|
}}
|
|
size="small"
|
|
bordered
|
|
expandable={{
|
|
expandIconColumnIndex: 0,
|
|
columnWidth: 42,
|
|
defaultExpandAllRows: true,
|
|
rowExpandable: (record: PacketTableRow) => Array.isArray(record.children) && record.children.length > 0,
|
|
expandIcon: ({ expanded, onExpand, record }) =>
|
|
Array.isArray((record as PacketTableRow).children) && (record as PacketTableRow).children!.length > 0 ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={expanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
|
onClick={(event) => onExpand(record, event)}
|
|
aria-label={expanded ? 'Collapse flow' : 'Expand flow'}
|
|
/>
|
|
) : (
|
|
<span style={{ display: 'inline-block', width: 24 }} />
|
|
),
|
|
}}
|
|
rowClassName={(record: PacketTableRow) => (record.__kind === 'flow' ? 'packet-flow-row' : 'packet-child-row')}
|
|
/>
|
|
)}
|
|
</Card>
|
|
|
|
<PacketInspectModal
|
|
packet={selectedPacket}
|
|
open={hexModalOpen}
|
|
onClose={() => {
|
|
setHexModalOpen(false);
|
|
setSelectedPacket(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|