add visualizations, better scaling
This commit is contained in:
586
frontend/src/components/PacketInspectModal.tsx
Normal file
586
frontend/src/components/PacketInspectModal.tsx
Normal file
@@ -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<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',
|
||||||
|
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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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<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);
|
||||||
|
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 (
|
||||||
|
<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) {
|
||||||
|
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 ? <Space direction="vertical" size="middle" style={{ width: '100%' }}>{content}</Space> : <Text type="secondary">No decoded summary available for this packet.</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
title={`Inspect packet ${packet?.id ?? ''}`}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
maxHeight: '75vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={downloadRaw} type="primary" disabled={!packet?.raw_b64}>
|
||||||
|
Download raw
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
width={1100}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ key: 'summary', label: 'Summary', children: renderProtocolSummary(packet) },
|
||||||
|
{
|
||||||
|
key: 'metadata',
|
||||||
|
label: 'Metadata',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Descriptions bordered size="small" column={2}>
|
||||||
|
<Descriptions.Item label="Timestamp">{formatTimestamp(packet?.timestamp)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Flow ID">{packet?.flow_id ?? getFlowId(packet ?? {}) ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Protocol">{packet ? formatProtocolLabel(packet) : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Application">{packet?.app_protocol ?? packet?.app_master_protocol ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Host">{packet?.app_hostname ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Verdict">{packet?.verdict ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Source">{packet?.src_ip ? `${packet.src_ip}${packet?.src_port ? `:${packet.src_port}` : ''}` : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Destination">{packet?.dst_ip ? `${packet.dst_ip}${packet?.dst_port ? `:${packet.dst_port}` : ''}` : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Path">{[packet?.ingress_if, packet?.egress_if].filter(Boolean).join(' -> ') || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Capture Sources">{packet?.capture_sources?.join(', ') || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div>
|
||||||
|
<Text strong>Reduced metadata</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{formatJson(reducedMetadata)}</pre>
|
||||||
|
</div>
|
||||||
|
{decodedPayload?.headersText ? (
|
||||||
|
<div>
|
||||||
|
<Text strong>Decoded headers</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{decodedPayload.headersText}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{decodedPayload?.bodyText ? (
|
||||||
|
<div>
|
||||||
|
<Text strong>Decoded body</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{decodedPayload.bodyText}</pre>
|
||||||
|
</div>
|
||||||
|
) : decodedPayload?.payloadText ? (
|
||||||
|
<div>
|
||||||
|
<Text strong>Decoded payload</Text>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{decodedPayload.payloadText}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'raw', label: 'Raw', children: <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{rawHex}</pre> },
|
||||||
|
{ key: 'full', label: 'Full JSON', children: <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{fullPacketJson}</pre> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
Descriptions,
|
|
||||||
Modal,
|
|
||||||
Row,
|
Row,
|
||||||
Segmented,
|
Segmented,
|
||||||
Select,
|
Select,
|
||||||
@@ -21,6 +19,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { clearPackets, fetchPackets, getPacketsWebSocketUrl } from '../api/apiClient';
|
import { clearPackets, fetchPackets, getPacketsWebSocketUrl } from '../api/apiClient';
|
||||||
|
import PacketInspectModal from './PacketInspectModal';
|
||||||
import type { PacketRow } from '../types/packets';
|
import type { PacketRow } from '../types/packets';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
@@ -927,8 +926,6 @@ export default function PacketViewer(): ReactElement {
|
|||||||
const [paused, setPaused] = useState<boolean>(false);
|
const [paused, setPaused] = useState<boolean>(false);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [hexModalOpen, setHexModalOpen] = useState(false);
|
const [hexModalOpen, setHexModalOpen] = useState(false);
|
||||||
const [rawHex, setRawHex] = useState<string | null>(null);
|
|
||||||
const [rawB64ForDownload, setRawB64ForDownload] = useState<string | null>(null);
|
|
||||||
const [selectedPacket, setSelectedPacket] = useState<PacketRow | null>(null);
|
const [selectedPacket, setSelectedPacket] = useState<PacketRow | null>(null);
|
||||||
const queuedDuringPause = useRef<PacketRow[]>([]);
|
const queuedDuringPause = useRef<PacketRow[]>([]);
|
||||||
|
|
||||||
@@ -1070,27 +1067,10 @@ export default function PacketViewer(): ReactElement {
|
|||||||
message.info('No inspect data available for this packet');
|
message.info('No inspect data available for this packet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRawHex(row.raw_b64 ? base64ToHex(row.raw_b64) : '(no raw bytes available)');
|
|
||||||
setRawB64ForDownload(row.raw_b64 ?? null);
|
|
||||||
setSelectedPacket(row);
|
setSelectedPacket(row);
|
||||||
setHexModalOpen(true);
|
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<PacketTableRow[]>(() => {
|
const tableRows = useMemo<PacketTableRow[]>(() => {
|
||||||
const grouped = new Map<string, PacketRow[]>();
|
const grouped = new Map<string, PacketRow[]>();
|
||||||
const standalone: PacketTableRow[] = [];
|
const standalone: PacketTableRow[] = [];
|
||||||
@@ -1489,113 +1469,14 @@ export default function PacketViewer(): ReactElement {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
<PacketInspectModal
|
||||||
title={`Inspect packet ${selectedPacket?.id ?? ''}`}
|
packet={selectedPacket}
|
||||||
open={hexModalOpen}
|
open={hexModalOpen}
|
||||||
onCancel={() => {
|
onClose={() => {
|
||||||
setHexModalOpen(false);
|
setHexModalOpen(false);
|
||||||
|
setSelectedPacket(null);
|
||||||
}}
|
}}
|
||||||
footer={
|
/>
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setHexModalOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button icon={<DownloadOutlined />} onClick={downloadRaw} type="primary">
|
|
||||||
Download raw
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
width={1100}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'summary',
|
|
||||||
label: 'Summary',
|
|
||||||
children: renderProtocolSummary(selectedPacket),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'metadata',
|
|
||||||
label: 'Metadata',
|
|
||||||
children: (
|
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
||||||
<Descriptions bordered size="small" column={2}>
|
|
||||||
<Descriptions.Item label="Timestamp">{formatTimestamp(selectedPacket?.timestamp)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Flow ID">{selectedPacket?.flow_id ?? getFlowId(selectedPacket ?? {}) ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Protocol">{selectedPacket ? formatProtocolLabel(selectedPacket) : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Application">{selectedPacket?.app_protocol ?? selectedPacket?.app_master_protocol ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Host">{selectedPacket?.app_hostname ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Verdict">{selectedPacket?.verdict ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Source">
|
|
||||||
{selectedPacket?.src_ip ? `${selectedPacket.src_ip}${selectedPacket?.src_port ? `:${selectedPacket.src_port}` : ''}` : '-'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Destination">
|
|
||||||
{selectedPacket?.dst_ip ? `${selectedPacket.dst_ip}${selectedPacket?.dst_port ? `:${selectedPacket.dst_port}` : ''}` : '-'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Path">
|
|
||||||
{[selectedPacket?.ingress_if, selectedPacket?.egress_if].filter(Boolean).join(' -> ') || '-'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Capture Sources">
|
|
||||||
{selectedPacket?.capture_sources?.join(', ') || '-'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
<div>
|
|
||||||
<Text strong>Reduced metadata</Text>
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>
|
|
||||||
{formatJson(reducedMetadata)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
{decodedPayload?.headersText ? (
|
|
||||||
<div>
|
|
||||||
<Text strong>Decoded headers</Text>
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>
|
|
||||||
{decodedPayload.headersText}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{decodedPayload?.bodyText ? (
|
|
||||||
<div>
|
|
||||||
<Text strong>Decoded body</Text>
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>
|
|
||||||
{decodedPayload.bodyText}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : decodedPayload?.payloadText ? (
|
|
||||||
<div>
|
|
||||||
<Text strong>Decoded payload</Text>
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>
|
|
||||||
{decodedPayload.payloadText}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'raw',
|
|
||||||
label: 'Raw',
|
|
||||||
children: (
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>
|
|
||||||
{rawHex}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'full',
|
|
||||||
label: 'Full JSON',
|
|
||||||
children: (
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>
|
|
||||||
{fullPacketJson}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1064
frontend/src/components/analysis/CommunicationViews.tsx
Normal file
1064
frontend/src/components/analysis/CommunicationViews.tsx
Normal file
File diff suppressed because it is too large
Load Diff
104
frontend/src/components/analysis/IntelligenceRiskViews.tsx
Normal file
104
frontend/src/components/analysis/IntelligenceRiskViews.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Card, Drawer, Space, Tag, Typography } from 'antd';
|
||||||
|
|
||||||
|
import type { HostIntelligenceEvidence } from '../../types/analysis';
|
||||||
|
import {
|
||||||
|
endpointText,
|
||||||
|
formatBytes,
|
||||||
|
formatTimestamp,
|
||||||
|
protocolColor,
|
||||||
|
renderLabelTags,
|
||||||
|
} from './shared.tsx';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export function HostDetailDrawer({
|
||||||
|
host,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
host: HostIntelligenceEvidence | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Drawer title={host ? endpointText(host.ip_address, host.mac_address) : 'Host details'} placement="right" width={520} open={open} onClose={onClose}>
|
||||||
|
{host == null ? null : (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card size="small" title="Identity">
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Text>IP: {host.ip_address ?? '—'}</Text>
|
||||||
|
<Text>MAC: {host.mac_address ?? '—'}</Text>
|
||||||
|
<Text>Interfaces: {host.interfaces.join(', ') || '—'}</Text>
|
||||||
|
<Text>First seen: {formatTimestamp(host.first_seen)} | Last seen: {formatTimestamp(host.last_seen)}</Text>
|
||||||
|
<div>{renderLabelTags(host.hostnames, 'geekblue')}</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="Protocol Profile">
|
||||||
|
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||||
|
{host.top_protocols.length === 0 ? (
|
||||||
|
<Text type="secondary">No protocol profile available yet.</Text>
|
||||||
|
) : (
|
||||||
|
host.top_protocols.map((protocol) => {
|
||||||
|
const maxCount = Math.max(...host.top_protocols.map((item) => item.packet_count), 1);
|
||||||
|
const widthPct = (protocol.packet_count / maxCount) * 100;
|
||||||
|
return (
|
||||||
|
<div key={protocol.label}>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Tag color={protocolColor(protocol.label)}>{protocol.label}</Tag>
|
||||||
|
<Text>{protocol.packet_count}</Text>
|
||||||
|
</Space>
|
||||||
|
<div style={{ height: 8, background: '#eef3f8', borderRadius: 999, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${widthPct}%`, height: '100%', background: protocolColor(protocol.label) }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="Top Peers">
|
||||||
|
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||||
|
{host.peers.length === 0 ? (
|
||||||
|
<Text type="secondary">No peer details available yet.</Text>
|
||||||
|
) : (
|
||||||
|
host.peers.map((peer) => {
|
||||||
|
const maxCount = Math.max(...host.peers.map((item) => item.packet_count), 1);
|
||||||
|
const widthPct = (peer.packet_count / maxCount) * 100;
|
||||||
|
return (
|
||||||
|
<div key={`${peer.ip_address ?? 'no-ip'}|${peer.mac_address ?? 'no-mac'}`}>
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<Text>{endpointText(peer.ip_address, peer.mac_address)}</Text>
|
||||||
|
<Text type="secondary">{peer.packet_count} packets · {formatBytes(peer.byte_count)} · {formatTimestamp(peer.last_seen)}</Text>
|
||||||
|
<div style={{ height: 8, background: '#eef3f8', borderRadius: 999, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${widthPct}%`, height: '100%', background: '#5b8ff9' }} />
|
||||||
|
</div>
|
||||||
|
<div>{renderLabelTags(peer.protocols, 'purple')}</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="Likely Services">
|
||||||
|
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||||
|
{host.services.length === 0 ? (
|
||||||
|
<Text type="secondary">No service evidence inferred yet.</Text>
|
||||||
|
) : (
|
||||||
|
host.services.map((service) => (
|
||||||
|
<div key={`${service.port ?? 'no-port'}|${service.protocol}`}>
|
||||||
|
<Text>Port {service.port ?? '—'} · {service.protocol} · {service.packet_count} packets · {formatBytes(service.byte_count)}</Text>
|
||||||
|
<div style={{ marginTop: 4 }}>{renderLabelTags(service.hostnames, 'geekblue')}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
769
frontend/src/components/analysis/TopologyViews.tsx
Normal file
769
frontend/src/components/analysis/TopologyViews.tsx
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
import { Empty } from 'antd';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import {
|
||||||
|
sankey as d3Sankey,
|
||||||
|
sankeyLinkHorizontal,
|
||||||
|
type SankeyGraph,
|
||||||
|
type SankeyLink,
|
||||||
|
type SankeyNode,
|
||||||
|
} from 'd3-sankey';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { InterfaceProtocolPathEvidence } from '../../types/analysis';
|
||||||
|
import {
|
||||||
|
buildDirectionalSankeyData,
|
||||||
|
clamp,
|
||||||
|
endpointText,
|
||||||
|
formatTimestamp,
|
||||||
|
protocolColor,
|
||||||
|
scaleVisualFor,
|
||||||
|
sankeyVisualWeight,
|
||||||
|
useResponsiveChartSize,
|
||||||
|
type TopologyData,
|
||||||
|
type TopologyLink,
|
||||||
|
type TopologyNode,
|
||||||
|
} from './shared.tsx';
|
||||||
|
|
||||||
|
type ForceNode = d3.SimulationNodeDatum & TopologyNode;
|
||||||
|
type ForceLink = d3.SimulationLinkDatum<ForceNode> & TopologyLink;
|
||||||
|
type SankeyNodeDatum = SankeyNode<TopologyNode, TopologyLink> & TopologyNode;
|
||||||
|
type SankeyLinkDatum = SankeyLink<TopologyNode, TopologyLink> & TopologyLink;
|
||||||
|
|
||||||
|
export function SankeyTopology({ data }: { data: TopologyData }) {
|
||||||
|
const scaleVisual = (value: number) => scaleVisualFor('sankey', value);
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('sankey');
|
||||||
|
const maxNodesInLayer = useMemo(
|
||||||
|
() =>
|
||||||
|
Math.max(
|
||||||
|
data.nodes.filter((node) => node.kind === 'interface').length,
|
||||||
|
data.nodes.filter((node) => node.kind === 'host').length,
|
||||||
|
data.nodes.filter((node) => node.kind === 'ethernet').length,
|
||||||
|
data.nodes.filter((node) => node.kind === 'ip').length,
|
||||||
|
data.nodes.filter((node) => node.kind === 'protocol').length,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
const svgHeight = fitHeight(scaleVisual(maxNodesInLayer * 60 + 72));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
const measuredWidth = svgRef.current.parentElement?.getBoundingClientRect().width ?? chartWidth;
|
||||||
|
if (measuredWidth <= 0) return;
|
||||||
|
|
||||||
|
const width = Math.floor(measuredWidth);
|
||||||
|
const height = svgHeight;
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const graph: SankeyGraph<TopologyNode, TopologyLink> = {
|
||||||
|
nodes: data.nodes.map((node) => ({ ...node })),
|
||||||
|
links: data.links.map((link) => ({ ...link })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>()
|
||||||
|
.nodeId((node) => node.id)
|
||||||
|
.nodeWidth(scaleVisual(14))
|
||||||
|
.nodePadding(scaleVisual(maxNodesInLayer <= 4 ? 18 : maxNodesInLayer <= 8 ? 14 : 10))
|
||||||
|
.extent([
|
||||||
|
[scaleVisual(18), scaleVisual(20)],
|
||||||
|
[width - scaleVisual(18), height - scaleVisual(20)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const layout = sankeyLayout(graph);
|
||||||
|
|
||||||
|
const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.4);
|
||||||
|
linkLayer
|
||||||
|
.selectAll('path')
|
||||||
|
.data(layout.links as SankeyLinkDatum[])
|
||||||
|
.join('path')
|
||||||
|
.attr('d', sankeyLinkHorizontal())
|
||||||
|
.attr('stroke', (link) => {
|
||||||
|
const target = link.target as SankeyNodeDatum;
|
||||||
|
if ((target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol) {
|
||||||
|
return protocolColor(target.protocol);
|
||||||
|
}
|
||||||
|
return '#9aa7b5';
|
||||||
|
})
|
||||||
|
.attr('stroke-width', (link) => Math.max(scaleVisual(1), link.width || scaleVisual(1)))
|
||||||
|
.append('title')
|
||||||
|
.text((link) => `${link.label}\nPackets: ${link.packetCount}`);
|
||||||
|
|
||||||
|
const nodeLayer = svg.append('g');
|
||||||
|
const node = nodeLayer
|
||||||
|
.selectAll('g')
|
||||||
|
.data(layout.nodes as SankeyNodeDatum[])
|
||||||
|
.join('g');
|
||||||
|
|
||||||
|
node
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', (d) => d.x0 ?? 0)
|
||||||
|
.attr('y', (d) => d.y0 ?? 0)
|
||||||
|
.attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
|
||||||
|
.attr('height', (d) => Math.max(scaleVisual(8), (d.y1 ?? 0) - (d.y0 ?? 0)))
|
||||||
|
.attr('fill', (d) => {
|
||||||
|
if (d.kind === 'interface') return '#20405d';
|
||||||
|
if (d.kind === 'host') return '#d7e7f5';
|
||||||
|
if (d.kind === 'ethernet') return '#d7c09c';
|
||||||
|
if (d.kind === 'ip') return '#a8c8df';
|
||||||
|
return d.protocol ? protocolColor(d.protocol) : '#d8d8d8';
|
||||||
|
})
|
||||||
|
.attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none'))
|
||||||
|
.append('title')
|
||||||
|
.text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
||||||
|
|
||||||
|
node
|
||||||
|
.append('text')
|
||||||
|
.attr('x', (d) => ((d.x0 ?? 0) < width / 2 ? (d.x1 ?? 0) + scaleVisual(6) : (d.x0 ?? 0) - scaleVisual(6)))
|
||||||
|
.attr('y', (d) => ((d.y0 ?? 0) + (d.y1 ?? 0)) / 2)
|
||||||
|
.attr('dy', '0.35em')
|
||||||
|
.attr('text-anchor', (d) => ((d.x0 ?? 0) < width / 2 ? 'start' : 'end'))
|
||||||
|
.attr('font-size', scaleVisual(11))
|
||||||
|
.attr('font-weight', (d) => (d.kind === 'interface' ? 700 : 500))
|
||||||
|
.attr('fill', '#22374f')
|
||||||
|
.text((d) => (d.kind === 'host' ? (d.ipAddress ?? d.macAddress ?? d.label) : d.label));
|
||||||
|
}, [chartWidth, data, maxNodesInLayer, svgHeight]);
|
||||||
|
|
||||||
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||||
|
return <Empty description="No interface, host, and protocol relationships found yet" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||||
|
<svg ref={svgRef} style={{ width: '100%', height: 'auto', display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PacketPathLanes({
|
||||||
|
paths,
|
||||||
|
includeEthernetLayer,
|
||||||
|
includeIpLayer,
|
||||||
|
}: {
|
||||||
|
paths: InterfaceProtocolPathEvidence[];
|
||||||
|
includeEthernetLayer: boolean;
|
||||||
|
includeIpLayer: boolean;
|
||||||
|
}) {
|
||||||
|
const scaleVisual = (value: number) => scaleVisualFor('parallel', value);
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('parallel');
|
||||||
|
|
||||||
|
const axisDefinitions = useMemo(() => {
|
||||||
|
const stages = [
|
||||||
|
{
|
||||||
|
id: 'src',
|
||||||
|
label: 'Source IP/MAC',
|
||||||
|
kind: 'endpoint' as const,
|
||||||
|
value: (path: InterfaceProtocolPathEvidence) => endpointText(path.src_ip_address, path.src_mac_address),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ingress',
|
||||||
|
label: 'Ingress',
|
||||||
|
kind: 'interface' as const,
|
||||||
|
value: (path: InterfaceProtocolPathEvidence) => path.ingress_interface ?? 'Unknown ingress',
|
||||||
|
},
|
||||||
|
...(includeEthernetLayer
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'ethernet',
|
||||||
|
label: 'Ethernet',
|
||||||
|
kind: 'ethernet' as const,
|
||||||
|
value: (path: InterfaceProtocolPathEvidence) => path.ethernet_protocol ?? 'Unknown ethernet',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(includeIpLayer
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
label: 'Internet Protocol',
|
||||||
|
kind: 'ip' as const,
|
||||||
|
value: (path: InterfaceProtocolPathEvidence) => path.ip_protocol ?? 'Unknown ip',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: 'protocol',
|
||||||
|
label: 'App Protocol',
|
||||||
|
kind: 'protocol' as const,
|
||||||
|
value: (path: InterfaceProtocolPathEvidence) => path.protocol,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'egress',
|
||||||
|
label: 'Egress',
|
||||||
|
kind: 'interface' as const,
|
||||||
|
value: (path: InterfaceProtocolPathEvidence) => path.egress_interface ?? 'Unknown egress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dst',
|
||||||
|
label: 'Destination IP/MAC',
|
||||||
|
kind: 'endpoint' as const,
|
||||||
|
value: (path: InterfaceProtocolPathEvidence) => endpointText(path.dst_ip_address, path.dst_mac_address),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return stages.map((stage) => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const path of paths) {
|
||||||
|
const label = stage.value(path);
|
||||||
|
counts.set(label, (counts.get(label) ?? 0) + path.packet_count);
|
||||||
|
}
|
||||||
|
const categories = Array.from(counts.entries())
|
||||||
|
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||||
|
.map(([label, packetCount]) => ({ label, packetCount }));
|
||||||
|
return { ...stage, categories };
|
||||||
|
});
|
||||||
|
}, [paths, includeEthernetLayer, includeIpLayer]);
|
||||||
|
const maxCategories = useMemo(
|
||||||
|
() => Math.max(...axisDefinitions.map((axis) => axis.categories.length), 1),
|
||||||
|
[axisDefinitions],
|
||||||
|
);
|
||||||
|
const svgHeight = fitHeight(scaleVisual(maxCategories * 24 + 128));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
|
||||||
|
if (paths.length === 0 || axisDefinitions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = chartWidth;
|
||||||
|
const height = svgHeight;
|
||||||
|
const sideMargin = clamp(width * 0.1, scaleVisual(48), scaleVisual(110));
|
||||||
|
const margin = { top: scaleVisual(40), right: sideMargin, bottom: scaleVisual(24), left: sideMargin };
|
||||||
|
svg.attr('width', width).attr('height', height);
|
||||||
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
const x = d3
|
||||||
|
.scalePoint<string>()
|
||||||
|
.domain(axisDefinitions.map((axis) => axis.id))
|
||||||
|
.range([margin.left, width - margin.right])
|
||||||
|
.padding(0.35);
|
||||||
|
|
||||||
|
const yByAxis = new Map<string, d3.ScalePoint<string>>();
|
||||||
|
for (const axis of axisDefinitions) {
|
||||||
|
yByAxis.set(
|
||||||
|
axis.id,
|
||||||
|
d3
|
||||||
|
.scalePoint<string>()
|
||||||
|
.domain(axis.categories.map((category) => category.label))
|
||||||
|
.range([margin.top + scaleVisual(18), height - margin.bottom - scaleVisual(18)])
|
||||||
|
.padding(0.45),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', 0)
|
||||||
|
.attr('width', width)
|
||||||
|
.attr('height', height)
|
||||||
|
.attr('rx', scaleVisual(18))
|
||||||
|
.attr('fill', '#fbfcfe');
|
||||||
|
const lineGenerator = d3
|
||||||
|
.line<{ x: number; y: number }>()
|
||||||
|
.x((point) => point.x)
|
||||||
|
.y((point) => point.y)
|
||||||
|
.curve(d3.curveMonotoneX);
|
||||||
|
|
||||||
|
const lineLayer = svg.append('g').attr('fill', 'none');
|
||||||
|
const lineSelection = lineLayer
|
||||||
|
.selectAll('path.path-line')
|
||||||
|
.data(paths)
|
||||||
|
.join('path')
|
||||||
|
.attr('class', 'path-line')
|
||||||
|
.attr('d', (path) => {
|
||||||
|
const points = axisDefinitions
|
||||||
|
.map((axis) => {
|
||||||
|
const axisX = x(axis.id);
|
||||||
|
const axisY = yByAxis.get(axis.id)?.(axis.value(path));
|
||||||
|
if (axisX == null || axisY == null) return null;
|
||||||
|
return { x: axisX, y: axisY };
|
||||||
|
})
|
||||||
|
.filter((point): point is { x: number; y: number } => point !== null);
|
||||||
|
return lineGenerator(points) ?? '';
|
||||||
|
})
|
||||||
|
.attr('stroke', (path) => protocolColor(path.protocol))
|
||||||
|
.attr('stroke-opacity', (path) => clamp(0.14 + Math.log10(Math.max(path.packet_count, 1)) * 0.08, 0.14, 0.5))
|
||||||
|
.attr('stroke-width', (path) =>
|
||||||
|
clamp(Math.sqrt(Math.max(path.packet_count, 1)) * scaleVisual(1.1), scaleVisual(2.2), scaleVisual(8)),
|
||||||
|
);
|
||||||
|
|
||||||
|
lineSelection
|
||||||
|
.append('title')
|
||||||
|
.text((path) =>
|
||||||
|
[
|
||||||
|
`${endpointText(path.src_ip_address, path.src_mac_address)} -> ${path.ingress_interface ?? 'Unknown ingress'}`,
|
||||||
|
`${path.protocol} -> ${path.egress_interface ?? 'Unknown egress'}`,
|
||||||
|
`${endpointText(path.dst_ip_address, path.dst_mac_address)}`,
|
||||||
|
`Packets: ${path.packet_count}`,
|
||||||
|
`Last seen: ${formatTimestamp(path.last_seen)}`,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const axisLayer = svg.append('g');
|
||||||
|
const activeFilters = new Map<string, Set<string>>();
|
||||||
|
const pathMatchesFilters = (path: InterfaceProtocolPathEvidence) =>
|
||||||
|
axisDefinitions.every((axis) => {
|
||||||
|
const allowed = activeFilters.get(axis.id);
|
||||||
|
if (allowed == null || allowed.size === 0) return true;
|
||||||
|
return allowed.has(axis.value(path));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLineStyles = () => {
|
||||||
|
lineSelection
|
||||||
|
.attr('stroke-opacity', (path) => {
|
||||||
|
const matches = pathMatchesFilters(path);
|
||||||
|
if (matches) return clamp(0.2 + Math.log10(Math.max(path.packet_count, 1)) * 0.1, 0.2, 0.7);
|
||||||
|
return 0.035;
|
||||||
|
})
|
||||||
|
.attr('stroke-width', (path) => {
|
||||||
|
if (!pathMatchesFilters(path)) return scaleVisual(1.2);
|
||||||
|
return clamp(
|
||||||
|
Math.sqrt(Math.max(path.packet_count, 1)) * scaleVisual(1.25),
|
||||||
|
scaleVisual(2.4),
|
||||||
|
scaleVisual(9),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [axisIndex, axis] of axisDefinitions.entries()) {
|
||||||
|
const axisX = x(axis.id);
|
||||||
|
const yScale = yByAxis.get(axis.id);
|
||||||
|
if (axisX == null || yScale == null) continue;
|
||||||
|
|
||||||
|
axisLayer
|
||||||
|
.append('line')
|
||||||
|
.attr('x1', axisX)
|
||||||
|
.attr('x2', axisX)
|
||||||
|
.attr('y1', margin.top)
|
||||||
|
.attr('y2', height - margin.bottom)
|
||||||
|
.attr('stroke', '#b8c6d5')
|
||||||
|
.attr('stroke-width', scaleVisual(2));
|
||||||
|
axisLayer
|
||||||
|
.append('text')
|
||||||
|
.attr('x', axisX)
|
||||||
|
.attr('y', margin.top - scaleVisual(14))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', scaleVisual(12))
|
||||||
|
.attr('font-weight', 700)
|
||||||
|
.attr('fill', '#42586f')
|
||||||
|
.text(axis.label);
|
||||||
|
|
||||||
|
const labelAnchor = axisIndex < axisDefinitions.length / 2 ? 'end' : 'start';
|
||||||
|
const labelOffset = labelAnchor === 'end' ? -scaleVisual(10) : scaleVisual(10);
|
||||||
|
|
||||||
|
axisLayer
|
||||||
|
.selectAll(`circle.axis-${axis.id}`)
|
||||||
|
.data(axis.categories)
|
||||||
|
.join('circle')
|
||||||
|
.attr('cx', axisX)
|
||||||
|
.attr('cy', (category) => yScale(category.label) ?? height / 2)
|
||||||
|
.attr('r', (category) =>
|
||||||
|
clamp(Math.sqrt(Math.max(category.packetCount, 1)) * scaleVisual(0.28), scaleVisual(3), scaleVisual(7)),
|
||||||
|
)
|
||||||
|
.attr('fill', axis.kind === 'protocol' ? '#35566f' : axis.kind === 'interface' ? '#20405d' : '#8eaac4')
|
||||||
|
.attr('opacity', 0.95);
|
||||||
|
|
||||||
|
axisLayer
|
||||||
|
.selectAll(`text.axis-label-${axis.id}`)
|
||||||
|
.data(axis.categories)
|
||||||
|
.join('text')
|
||||||
|
.attr('x', axisX + labelOffset)
|
||||||
|
.attr('y', (category) => (yScale(category.label) ?? height / 2) + scaleVisual(4))
|
||||||
|
.attr('text-anchor', labelAnchor)
|
||||||
|
.attr('font-size', scaleVisual(11))
|
||||||
|
.attr('fill', '#41566d')
|
||||||
|
.text((category) => category.label);
|
||||||
|
|
||||||
|
const brush = d3
|
||||||
|
.brushY()
|
||||||
|
.extent([
|
||||||
|
[axisX - scaleVisual(22), margin.top],
|
||||||
|
[axisX + scaleVisual(22), height - margin.bottom],
|
||||||
|
])
|
||||||
|
.on('brush end', (event) => {
|
||||||
|
const selection = event.selection as [number, number] | null;
|
||||||
|
if (selection == null) {
|
||||||
|
activeFilters.delete(axis.id);
|
||||||
|
updateLineStyles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [y0, y1] = selection[0] <= selection[1] ? selection : [selection[1], selection[0]];
|
||||||
|
const labels = axis.categories
|
||||||
|
.filter((category) => {
|
||||||
|
const yValue = yScale(category.label);
|
||||||
|
return yValue != null && yValue >= y0 && yValue <= y1;
|
||||||
|
})
|
||||||
|
.map((category) => category.label);
|
||||||
|
activeFilters.set(axis.id, new Set(labels));
|
||||||
|
updateLineStyles();
|
||||||
|
});
|
||||||
|
|
||||||
|
const brushGroup = axisLayer.append('g').attr('class', `brush brush-${axis.id}`).call(brush);
|
||||||
|
brushGroup.selectAll('.selection').attr('fill', '#8fb7d8').attr('fill-opacity', 0.18).attr('stroke', '#4f7ba3');
|
||||||
|
brushGroup.selectAll('.handle').attr('fill', '#4f7ba3').attr('fill-opacity', 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLineStyles();
|
||||||
|
}, [axisDefinitions, chartWidth, paths, svgHeight]);
|
||||||
|
|
||||||
|
if (paths.length === 0) {
|
||||||
|
return <Empty description="No packet path view available yet" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||||
|
<svg ref={svgRef} style={{ width: '100%', height: `${svgHeight}px`, display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForceTopology({ data }: { data: TopologyData }) {
|
||||||
|
const scaleVisual = (value: number) => scaleVisualFor('force', value);
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('force');
|
||||||
|
const svgHeight = fitHeight(scaleVisual(Math.max(300, data.nodes.length * 18 + 180)));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
const width = chartWidth;
|
||||||
|
const height = svgHeight;
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
svg.attr('width', width).attr('height', height);
|
||||||
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node }));
|
||||||
|
const links: ForceLink[] = data.links.map((link) => ({ ...link }));
|
||||||
|
const groupedNodes = {
|
||||||
|
interface: nodes
|
||||||
|
.filter((node) => node.kind === 'interface')
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||||
|
host: nodes
|
||||||
|
.filter((node) => node.kind === 'host')
|
||||||
|
.sort((left, right) =>
|
||||||
|
(left.ipAddress ?? left.macAddress ?? left.label).localeCompare(
|
||||||
|
right.ipAddress ?? right.macAddress ?? right.label,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ethernet: nodes
|
||||||
|
.filter((node) => node.kind === 'ethernet')
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||||
|
ip: nodes.filter((node) => node.kind === 'ip').sort((left, right) => left.label.localeCompare(right.label)),
|
||||||
|
protocol: nodes
|
||||||
|
.filter((node) => node.kind === 'protocol')
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const distributedY = (group: ForceNode[], top: number, bottom: number) => {
|
||||||
|
const targets = new Map<string, number>();
|
||||||
|
if (group.length === 0) {
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
if (group.length === 1) {
|
||||||
|
targets.set(group[0].id, (top + bottom) / 2);
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
const step = (bottom - top) / (group.length - 1);
|
||||||
|
group.forEach((node, index) => targets.set(node.id, top + step * index));
|
||||||
|
return targets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const interfaceY = distributedY(groupedNodes.interface, scaleVisual(120), height - scaleVisual(120));
|
||||||
|
const hostY = distributedY(groupedNodes.host, scaleVisual(90), height - scaleVisual(90));
|
||||||
|
const ethernetY = distributedY(groupedNodes.ethernet, scaleVisual(120), height - scaleVisual(120));
|
||||||
|
const ipY = distributedY(groupedNodes.ip, scaleVisual(120), height - scaleVisual(120));
|
||||||
|
const protocolY = distributedY(groupedNodes.protocol, scaleVisual(120), height - scaleVisual(120));
|
||||||
|
const targetY = (node: ForceNode) =>
|
||||||
|
interfaceY.get(node.id) ??
|
||||||
|
hostY.get(node.id) ??
|
||||||
|
ethernetY.get(node.id) ??
|
||||||
|
ipY.get(node.id) ??
|
||||||
|
protocolY.get(node.id) ??
|
||||||
|
height / 2;
|
||||||
|
|
||||||
|
const simulation = d3
|
||||||
|
.forceSimulation<ForceNode>(nodes)
|
||||||
|
.force(
|
||||||
|
'link',
|
||||||
|
d3
|
||||||
|
.forceLink<ForceNode, ForceLink>(links)
|
||||||
|
.id((node) => node.id)
|
||||||
|
.distance((link) => ((link.source as ForceNode).kind === 'interface' ? scaleVisual(250) : scaleVisual(200)))
|
||||||
|
.strength((link) => ((link.source as ForceNode).kind === 'interface' ? 0.45 : 0.35)),
|
||||||
|
)
|
||||||
|
.force('charge', d3.forceManyBody().strength(-scaleVisual(720)))
|
||||||
|
.force(
|
||||||
|
'collision',
|
||||||
|
d3.forceCollide<ForceNode>().radius((node) => {
|
||||||
|
if (node.kind === 'interface') return scaleVisual(52);
|
||||||
|
if (node.kind === 'host') return scaleVisual(44);
|
||||||
|
if (node.kind === 'ethernet') return scaleVisual(36);
|
||||||
|
if (node.kind === 'ip') return scaleVisual(35);
|
||||||
|
return scaleVisual(34);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.force(
|
||||||
|
'x',
|
||||||
|
d3
|
||||||
|
.forceX<ForceNode>()
|
||||||
|
.x((node) => {
|
||||||
|
if (node.kind === 'interface') return width * 0.14;
|
||||||
|
if (node.kind === 'host') return width * 0.34;
|
||||||
|
if (node.kind === 'ethernet') return width * 0.54;
|
||||||
|
if (node.kind === 'ip') return width * 0.72;
|
||||||
|
return width * 0.88;
|
||||||
|
})
|
||||||
|
.strength(0.42),
|
||||||
|
)
|
||||||
|
.force(
|
||||||
|
'y',
|
||||||
|
d3
|
||||||
|
.forceY<ForceNode>()
|
||||||
|
.y((node) => targetY(node))
|
||||||
|
.strength(0.22),
|
||||||
|
)
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.06));
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', 0)
|
||||||
|
.attr('width', width)
|
||||||
|
.attr('height', height)
|
||||||
|
.attr('rx', scaleVisual(18))
|
||||||
|
.attr('fill', '#fbfcfe');
|
||||||
|
|
||||||
|
const link = svg
|
||||||
|
.append('g')
|
||||||
|
.attr('stroke-opacity', 0.45)
|
||||||
|
.selectAll('line')
|
||||||
|
.data(links)
|
||||||
|
.join('line')
|
||||||
|
.attr('stroke', (d) => {
|
||||||
|
const target = d.target as ForceNode;
|
||||||
|
return (target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol
|
||||||
|
? protocolColor(target.protocol)
|
||||||
|
: '#92a1b2';
|
||||||
|
})
|
||||||
|
.attr('stroke-width', (d) => scaleVisual(sankeyVisualWeight(d.packetCount)));
|
||||||
|
|
||||||
|
link.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
||||||
|
|
||||||
|
const node = svg.append('g').selectAll('g').data(nodes).join('g');
|
||||||
|
|
||||||
|
node
|
||||||
|
.append('circle')
|
||||||
|
.attr('r', (d) => {
|
||||||
|
if (d.kind === 'interface') return scaleVisual(26);
|
||||||
|
if (d.kind === 'host') return scaleVisual(22);
|
||||||
|
if (d.kind === 'ethernet') return scaleVisual(19);
|
||||||
|
if (d.kind === 'ip') return scaleVisual(18);
|
||||||
|
return scaleVisual(18);
|
||||||
|
})
|
||||||
|
.attr('fill', (d) => {
|
||||||
|
if (d.kind === 'interface') return '#20405d';
|
||||||
|
if (d.kind === 'host') return '#d7e7f5';
|
||||||
|
if (d.kind === 'ethernet') return '#d7c09c';
|
||||||
|
if (d.kind === 'ip') return '#a8c8df';
|
||||||
|
return d.protocol ? protocolColor(d.protocol) : '#cfd7df';
|
||||||
|
})
|
||||||
|
.attr('stroke', (d) => (d.kind === 'host' ? '#8aa8c6' : '#ffffff'))
|
||||||
|
.attr('stroke-width', scaleVisual(2));
|
||||||
|
|
||||||
|
node
|
||||||
|
.append('text')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', scaleVisual(40))
|
||||||
|
.attr('font-size', scaleVisual(11))
|
||||||
|
.attr('font-weight', 600)
|
||||||
|
.attr('fill', '#29445d')
|
||||||
|
.text((d) => (d.kind === 'host' ? (d.ipAddress ?? d.macAddress ?? 'host') : d.label));
|
||||||
|
|
||||||
|
node.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
||||||
|
|
||||||
|
simulation.on('tick', () => {
|
||||||
|
link
|
||||||
|
.attr('x1', (d) => (d.source as ForceNode).x ?? 0)
|
||||||
|
.attr('y1', (d) => (d.source as ForceNode).y ?? 0)
|
||||||
|
.attr('x2', (d) => (d.target as ForceNode).x ?? 0)
|
||||||
|
.attr('y2', (d) => (d.target as ForceNode).y ?? 0);
|
||||||
|
|
||||||
|
node.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => simulation.stop();
|
||||||
|
}, [chartWidth, data, svgHeight]);
|
||||||
|
|
||||||
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||||
|
return <Empty description="No graph data available yet" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||||
|
<svg ref={svgRef} style={{ width: '100%', height: `${svgHeight}px`, display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtocolHeatmap({ data }: { data: TopologyData }) {
|
||||||
|
const scaleVisual = (value: number) => scaleVisualFor('heatmap', value);
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('heatmap');
|
||||||
|
const maxProtocolLabelLength = useMemo(
|
||||||
|
() => d3.max(data.protocols, (protocol) => protocol.length) ?? 0,
|
||||||
|
[data.protocols],
|
||||||
|
);
|
||||||
|
const topMargin = clamp(
|
||||||
|
scaleVisual(maxProtocolLabelLength * 3.1 + 24),
|
||||||
|
scaleVisual(56),
|
||||||
|
scaleVisual(96),
|
||||||
|
);
|
||||||
|
const svgHeight = fitHeight(Math.max(scaleVisual(320), topMargin + scaleVisual(54 + data.heatmapRows.length * 34)));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
|
||||||
|
const maxHostLabelLength = d3.max(data.heatmapRows, (row) => row.hostLabel.length) ?? 0;
|
||||||
|
const margin = {
|
||||||
|
top: topMargin,
|
||||||
|
right: scaleVisual(22),
|
||||||
|
bottom: scaleVisual(24),
|
||||||
|
left: clamp(scaleVisual(maxHostLabelLength * 6.4 + 18), scaleVisual(150), scaleVisual(280)),
|
||||||
|
};
|
||||||
|
const usableWidth = chartWidth;
|
||||||
|
const cellHeight = scaleVisual(34);
|
||||||
|
const width = usableWidth;
|
||||||
|
const height = svgHeight;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
svg.attr('width', width).attr('height', height);
|
||||||
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
if (data.protocols.length === 0 || data.heatmapRows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = d3
|
||||||
|
.scaleBand<string>()
|
||||||
|
.domain(data.protocols)
|
||||||
|
.range([margin.left, width - margin.right])
|
||||||
|
.paddingInner(0.08);
|
||||||
|
const y = d3
|
||||||
|
.scaleBand<string>()
|
||||||
|
.domain(data.heatmapRows.map((row) => row.hostId))
|
||||||
|
.range([margin.top, height - margin.bottom])
|
||||||
|
.paddingInner(0.08);
|
||||||
|
const maxValue =
|
||||||
|
d3.max(data.heatmapRows.flatMap((row) => data.protocols.map((protocol) => row.values[protocol] || 0))) ?? 1;
|
||||||
|
const color = d3.scaleSequential(d3.interpolateYlGnBu).domain([0, maxValue]);
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append('rect')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', 0)
|
||||||
|
.attr('width', width)
|
||||||
|
.attr('height', height)
|
||||||
|
.attr('rx', scaleVisual(18))
|
||||||
|
.attr('fill', '#fbfcfe');
|
||||||
|
|
||||||
|
const cells = svg.append('g');
|
||||||
|
for (const row of data.heatmapRows) {
|
||||||
|
for (const protocol of data.protocols) {
|
||||||
|
const value = row.values[protocol] || 0;
|
||||||
|
const cell = cells.append('g').attr('transform', `translate(${x(protocol) ?? 0},${y(row.hostId) ?? 0})`);
|
||||||
|
|
||||||
|
cell
|
||||||
|
.append('rect')
|
||||||
|
.attr('width', x.bandwidth())
|
||||||
|
.attr('height', y.bandwidth())
|
||||||
|
.attr('rx', scaleVisual(8))
|
||||||
|
.attr('fill', value > 0 ? color(value) : '#eef3f8')
|
||||||
|
.attr('stroke', '#dce5ef');
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
cell
|
||||||
|
.append('text')
|
||||||
|
.attr('x', x.bandwidth() / 2)
|
||||||
|
.attr('y', y.bandwidth() / 2 + scaleVisual(4))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', scaleVisual(11))
|
||||||
|
.attr('font-weight', 700)
|
||||||
|
.attr('fill', value > maxValue * 0.45 ? '#ffffff' : '#23415c')
|
||||||
|
.text(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.append('title').text(`${row.hostLabel}\n${protocol}: ${value} packets`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.selectAll('text.protocol-label')
|
||||||
|
.data(data.protocols)
|
||||||
|
.join('text')
|
||||||
|
.attr('class', 'protocol-label')
|
||||||
|
.attr('x', (protocol) => (x(protocol) ?? 0) + x.bandwidth() / 2)
|
||||||
|
.attr('y', margin.top - scaleVisual(12))
|
||||||
|
.attr('transform', (protocol) => `rotate(-35, ${(x(protocol) ?? 0) + x.bandwidth() / 2}, ${margin.top - scaleVisual(12)})`)
|
||||||
|
.attr('text-anchor', 'start')
|
||||||
|
.attr('font-size', scaleVisual(12))
|
||||||
|
.attr('font-weight', 600)
|
||||||
|
.attr('fill', '#29445d')
|
||||||
|
.text((protocol) => protocol);
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append('g')
|
||||||
|
.selectAll('text.host-label')
|
||||||
|
.data(data.heatmapRows)
|
||||||
|
.join('text')
|
||||||
|
.attr('class', 'host-label')
|
||||||
|
.attr('x', margin.left - scaleVisual(12))
|
||||||
|
.attr('y', (row) => (y(row.hostId) ?? 0) + y.bandwidth() / 2 + scaleVisual(4))
|
||||||
|
.attr('text-anchor', 'end')
|
||||||
|
.attr('font-size', scaleVisual(12))
|
||||||
|
.attr('fill', '#29445d')
|
||||||
|
.text((row) => row.hostLabel);
|
||||||
|
}, [chartWidth, data, svgHeight, topMargin]);
|
||||||
|
|
||||||
|
if (data.protocols.length === 0 || data.heatmapRows.length === 0) {
|
||||||
|
return <Empty description="No protocol heatmap data available yet" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||||
|
<svg ref={svgRef} style={{ width: '100%', height: 'auto', display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectionalSankeyTopology({
|
||||||
|
paths,
|
||||||
|
includeEthernetLayer,
|
||||||
|
includeIpLayer,
|
||||||
|
}: {
|
||||||
|
paths: InterfaceProtocolPathEvidence[];
|
||||||
|
includeEthernetLayer: boolean;
|
||||||
|
includeIpLayer: boolean;
|
||||||
|
}) {
|
||||||
|
const topologyData = useMemo(
|
||||||
|
() => buildDirectionalSankeyData(paths, { includeEthernetLayer, includeIpLayer }),
|
||||||
|
[paths, includeEthernetLayer, includeIpLayer],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SankeyTopology data={topologyData} />;
|
||||||
|
}
|
||||||
926
frontend/src/components/analysis/shared.tsx
Normal file
926
frontend/src/components/analysis/shared.tsx
Normal file
@@ -0,0 +1,926 @@
|
|||||||
|
import { Space, Tag } from 'antd';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConversationEvidence,
|
||||||
|
InterfaceHostProtocolEvidence,
|
||||||
|
InterfaceProtocolAttachment,
|
||||||
|
InterfaceProtocolPathEvidence,
|
||||||
|
LabelCountEvidence,
|
||||||
|
} from '../../types/analysis';
|
||||||
|
import type { PacketRow } from '../../types/packets';
|
||||||
|
|
||||||
|
export type GraphNodeKind = 'interface' | 'host' | 'protocol';
|
||||||
|
export type TopologyLayerKind = GraphNodeKind | 'ethernet' | 'ip';
|
||||||
|
|
||||||
|
export type TopologyNode = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: TopologyLayerKind;
|
||||||
|
packetCount: number;
|
||||||
|
interfaceName?: string;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
macAddress?: string | null;
|
||||||
|
protocol?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyLink = {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
value: number;
|
||||||
|
packetCount: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeatmapRow = {
|
||||||
|
hostId: string;
|
||||||
|
hostLabel: string;
|
||||||
|
interfaceName: string;
|
||||||
|
values: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtocolTableRow = {
|
||||||
|
key: string;
|
||||||
|
interface: string;
|
||||||
|
ip_address?: string | null;
|
||||||
|
mac_address?: string | null;
|
||||||
|
host_packet_count: number;
|
||||||
|
protocol: string;
|
||||||
|
protocol_packet_count: number;
|
||||||
|
accept_count: number;
|
||||||
|
drop_count: number;
|
||||||
|
reject_count: number;
|
||||||
|
unknown_count: number;
|
||||||
|
last_seen: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyData = {
|
||||||
|
nodes: TopologyNode[];
|
||||||
|
links: TopologyLink[];
|
||||||
|
heatmapRows: HeatmapRow[];
|
||||||
|
protocols: string[];
|
||||||
|
tableRows: ProtocolTableRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyOptions = {
|
||||||
|
includeEthernetLayer: boolean;
|
||||||
|
includeIpLayer: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatTimestamp(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(value);
|
||||||
|
return (
|
||||||
|
date.toLocaleString('de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}) + `.${String(date.getMilliseconds()).padStart(3, '0')}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(value?: number | null) {
|
||||||
|
const amount = Number(value ?? 0);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return '0 B';
|
||||||
|
if (amount < 1024) return `${amount} B`;
|
||||||
|
if (amount < 1024 ** 2) return `${(amount / 1024).toFixed(1)} KB`;
|
||||||
|
if (amount < 1024 ** 3) return `${(amount / 1024 ** 2).toFixed(1)} MB`;
|
||||||
|
return `${(amount / 1024 ** 3).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDurationMs(value?: number | null) {
|
||||||
|
const duration = Number(value ?? 0);
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return '0 ms';
|
||||||
|
if (duration < 1000) return `${duration} ms`;
|
||||||
|
const seconds = duration / 1000;
|
||||||
|
if (seconds < 60) return `${seconds.toFixed(2)} s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
if (minutes < 60) return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return `${hours}h ${remainingMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endpointText(ipAddress?: string | null, macAddress?: string | null) {
|
||||||
|
return ipAddress ?? macAddress ?? 'unknown endpoint';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIpAddress(value?: string | null) {
|
||||||
|
if (value == null) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
const slashIndex = trimmed.indexOf('/');
|
||||||
|
return slashIndex >= 0 ? trimmed.slice(0, slashIndex) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conversationRowKey(row: ConversationEvidence) {
|
||||||
|
return [
|
||||||
|
row.src_ip_address,
|
||||||
|
row.src_mac_address,
|
||||||
|
row.src_port,
|
||||||
|
row.dst_ip_address,
|
||||||
|
row.dst_mac_address,
|
||||||
|
row.dst_port,
|
||||||
|
row.protocol,
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetEventLabel(packet: PacketRow) {
|
||||||
|
const tcpLabel = packetTcpSummary(packet);
|
||||||
|
const activityLabel = packetActivityText(packet);
|
||||||
|
if (tcpLabel && activityLabel && tcpLabel !== activityLabel) {
|
||||||
|
return `${tcpLabel} · ${activityLabel}`;
|
||||||
|
}
|
||||||
|
if (activityLabel) {
|
||||||
|
return activityLabel;
|
||||||
|
}
|
||||||
|
if (tcpLabel) {
|
||||||
|
return tcpLabel;
|
||||||
|
}
|
||||||
|
return String(packet.app_protocol ?? packet.ip_proto ?? packet.eth_type ?? 'Packet');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetTcpSummary(packet: PacketRow) {
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const tcpMeta = asRecord(dpiMetadata?.tcp);
|
||||||
|
const tcpFlags = Array.isArray(tcpMeta?.flag_names)
|
||||||
|
? tcpMeta.flag_names.filter((flag): flag is string => typeof flag === 'string')
|
||||||
|
: [];
|
||||||
|
const tcpPacketType = typeof tcpMeta?.packet_type === 'string' ? tcpMeta.packet_type : null;
|
||||||
|
return tcpFlags.length > 0 ? tcpFlags.join('-') : tcpPacketType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetActivityText(packet: PacketRow) {
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const httpMeta = asRecord(dpiMetadata?.http);
|
||||||
|
const dnsMeta = asRecord(dpiMetadata?.dns);
|
||||||
|
const tlsMeta = asRecord(dpiMetadata?.tls);
|
||||||
|
const arpMeta = asRecord(dpiMetadata?.arp);
|
||||||
|
const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null;
|
||||||
|
const uri =
|
||||||
|
typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null;
|
||||||
|
if (method) {
|
||||||
|
return `${method} ${uri ?? ''}`.trim();
|
||||||
|
}
|
||||||
|
const responseCode = httpMeta?.response_code;
|
||||||
|
const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : '';
|
||||||
|
if (typeof responseCode === 'number' || typeof responseCode === 'string') {
|
||||||
|
return `${responseCode} ${responsePhrase}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dnsName =
|
||||||
|
typeof dnsMeta?.query_name === 'string'
|
||||||
|
? dnsMeta.query_name
|
||||||
|
: typeof dnsMeta?.response_name === 'string'
|
||||||
|
? dnsMeta.response_name
|
||||||
|
: null;
|
||||||
|
if (dnsName) {
|
||||||
|
return dnsName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverName =
|
||||||
|
typeof tlsMeta?.server_name === 'string'
|
||||||
|
? tlsMeta.server_name
|
||||||
|
: typeof tlsMeta?.sni === 'string'
|
||||||
|
? tlsMeta.sni
|
||||||
|
: null;
|
||||||
|
if (serverName) {
|
||||||
|
return serverName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof arpMeta?.target_proto_ipv4 === 'string') {
|
||||||
|
return arpMeta.target_proto_ipv4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appProtocol = typeof packet.app_protocol === 'string' ? packet.app_protocol : null;
|
||||||
|
const ipProtocol = typeof packet.ip_proto === 'string' ? packet.ip_proto : null;
|
||||||
|
const ethernetProtocol = typeof packet.eth_type === 'string' ? packet.eth_type : null;
|
||||||
|
if (appProtocol && appProtocol !== ipProtocol && appProtocol !== ethernetProtocol) {
|
||||||
|
return appProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetHttpDetailText(packet: PacketRow) {
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const httpMeta = asRecord(dpiMetadata?.http);
|
||||||
|
const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null;
|
||||||
|
const uri =
|
||||||
|
typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null;
|
||||||
|
if (method) {
|
||||||
|
return `${method} ${uri ?? ''}`.trim();
|
||||||
|
}
|
||||||
|
const responseCode = httpMeta?.response_code;
|
||||||
|
const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : '';
|
||||||
|
if (typeof responseCode === 'number' || typeof responseCode === 'string') {
|
||||||
|
return `${responseCode} ${responsePhrase}`.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetDnsDetailText(packet: PacketRow) {
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const dnsMeta = asRecord(dpiMetadata?.dns);
|
||||||
|
if (dnsMeta == null) return '';
|
||||||
|
const queryType = typeof dnsMeta.query_type === 'string' ? dnsMeta.query_type : null;
|
||||||
|
const queryName = typeof dnsMeta.query_name === 'string' ? dnsMeta.query_name : null;
|
||||||
|
const responseName = typeof dnsMeta.response_name === 'string' ? dnsMeta.response_name : null;
|
||||||
|
if (dnsMeta.is_response === false) {
|
||||||
|
return `Query${queryType ? ` ${queryType}` : ''}${queryName ? ` ${queryName}` : ''}`.trim();
|
||||||
|
}
|
||||||
|
if (dnsMeta.is_response === true) {
|
||||||
|
return `Response${responseName ? ` ${responseName}` : queryName ? ` ${queryName}` : ''}`.trim();
|
||||||
|
}
|
||||||
|
return queryName ?? responseName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetTlsDetailText(packet: PacketRow) {
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const tlsMeta = asRecord(dpiMetadata?.tls);
|
||||||
|
if (tlsMeta == null) return '';
|
||||||
|
const version = typeof tlsMeta.handshake_version === 'string' ? tlsMeta.handshake_version : null;
|
||||||
|
const serverName =
|
||||||
|
typeof tlsMeta.server_name === 'string'
|
||||||
|
? tlsMeta.server_name
|
||||||
|
: typeof tlsMeta.sni === 'string'
|
||||||
|
? tlsMeta.sni
|
||||||
|
: null;
|
||||||
|
const alpn = typeof tlsMeta.alpn === 'string' ? tlsMeta.alpn : null;
|
||||||
|
return [version, serverName, alpn].filter((value): value is string => Boolean(value)).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetArpDetailText(packet: PacketRow) {
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const arpMeta = asRecord(dpiMetadata?.arp);
|
||||||
|
if (arpMeta == null) return '';
|
||||||
|
const opcode = typeof arpMeta.opcode === 'number' ? arpMeta.opcode : null;
|
||||||
|
const opcodeLabel =
|
||||||
|
opcode === 1
|
||||||
|
? 'Request'
|
||||||
|
: opcode === 2
|
||||||
|
? 'Reply'
|
||||||
|
: opcode === 10
|
||||||
|
? 'NAK'
|
||||||
|
: opcode === 16
|
||||||
|
? 'InARP'
|
||||||
|
: opcode === 24
|
||||||
|
? 'NAK Reply'
|
||||||
|
: opcode === 25
|
||||||
|
? 'Peer Request'
|
||||||
|
: opcode != null
|
||||||
|
? `Op ${opcode}`
|
||||||
|
: null;
|
||||||
|
const targetIp = typeof arpMeta.target_proto_ipv4 === 'string' ? arpMeta.target_proto_ipv4 : null;
|
||||||
|
return [opcodeLabel, targetIp].filter((value): value is string => Boolean(value)).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetIcmpDetailText(packet: PacketRow) {
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const icmpMeta = asRecord(dpiMetadata?.icmp);
|
||||||
|
if (icmpMeta == null) return '';
|
||||||
|
const icmpType =
|
||||||
|
typeof icmpMeta.type_label === 'string'
|
||||||
|
? icmpMeta.type_label
|
||||||
|
: typeof icmpMeta.type === 'string'
|
||||||
|
? icmpMeta.type
|
||||||
|
: typeof icmpMeta.icmp_type === 'string'
|
||||||
|
? icmpMeta.icmp_type
|
||||||
|
: null;
|
||||||
|
const code =
|
||||||
|
typeof icmpMeta.code === 'number'
|
||||||
|
? String(icmpMeta.code)
|
||||||
|
: typeof icmpMeta.icmp_code === 'string'
|
||||||
|
? icmpMeta.icmp_code
|
||||||
|
: null;
|
||||||
|
return [icmpType, code ? `code ${code}` : null].filter((value): value is string => Boolean(value)).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringList(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '');
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tcpFlagColor(flagName: string) {
|
||||||
|
const normalized = flagName.toUpperCase();
|
||||||
|
if (normalized === 'SYN') return 'blue';
|
||||||
|
if (normalized === 'ACK') return 'cyan';
|
||||||
|
if (normalized === 'PSH') return 'green';
|
||||||
|
if (normalized === 'FIN') return 'orange';
|
||||||
|
if (normalized === 'RST') return 'red';
|
||||||
|
if (normalized === 'URG') return 'volcano';
|
||||||
|
if (normalized === 'ECE' || normalized === 'CWR') return 'purple';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPacketBadges(packet: PacketRow): ReactNode[] {
|
||||||
|
const badges: ReactNode[] = [];
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
return badges.concat(renderPacketTcpBadges(packet));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPacketTcpBadges(packet: PacketRow): ReactNode[] {
|
||||||
|
const badges: ReactNode[] = [];
|
||||||
|
const dpiMetadata = asRecord(packet.dpi_metadata);
|
||||||
|
const tcpMeta = asRecord(dpiMetadata?.tcp);
|
||||||
|
const tsharkMeta = asRecord(dpiMetadata?.tshark);
|
||||||
|
|
||||||
|
const flagNames = Array.from(
|
||||||
|
new Set([...stringList(tcpMeta?.flag_names), ...stringList(tsharkMeta?.tcp_flag_names)]),
|
||||||
|
);
|
||||||
|
for (const flagName of flagNames) {
|
||||||
|
badges.push(
|
||||||
|
<Tag key={`protocol-flag-${flagName}`} color={tcpFlagColor(flagName)} style={{ marginInlineEnd: 0 }}>
|
||||||
|
{flagName}
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tcpPacketType =
|
||||||
|
typeof tcpMeta?.packet_type === 'string'
|
||||||
|
? tcpMeta.packet_type
|
||||||
|
: typeof tsharkMeta?.tcp_packet_type === 'string'
|
||||||
|
? tsharkMeta.tcp_packet_type
|
||||||
|
: null;
|
||||||
|
if (tcpPacketType && flagNames.length === 0) {
|
||||||
|
badges.push(
|
||||||
|
<Tag key={`protocol-type-${tcpPacketType}`} color="default" style={{ marginInlineEnd: 0 }}>
|
||||||
|
{tcpPacketType}
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tcpMeta?.retransmission === true) {
|
||||||
|
badges.push(
|
||||||
|
<Tag key="protocol-retransmission" color="red" style={{ marginInlineEnd: 0 }}>
|
||||||
|
Retransmission
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tcpMeta?.duplicate_ack === true) {
|
||||||
|
badges.push(
|
||||||
|
<Tag key="protocol-dup-ack" color="volcano" style={{ marginInlineEnd: 0 }}>
|
||||||
|
Dup ACK
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tcpMeta?.keep_alive === true) {
|
||||||
|
badges.push(
|
||||||
|
<Tag key="protocol-keepalive" color="lime" style={{ marginInlineEnd: 0 }}>
|
||||||
|
Keep-Alive
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endpointMatches(
|
||||||
|
packetIp: string | null | undefined,
|
||||||
|
packetMac: string | null | undefined,
|
||||||
|
targetIp: string | null | undefined,
|
||||||
|
targetMac: string | null | undefined,
|
||||||
|
) {
|
||||||
|
if (targetIp == null && targetMac == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ipMatches = targetIp == null || normalizeIpAddress(packetIp) === normalizeIpAddress(targetIp);
|
||||||
|
const macMatches = targetMac == null || packetMac === targetMac;
|
||||||
|
return ipMatches && macMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packetDirection(packet: PacketRow, conversation: ConversationEvidence) {
|
||||||
|
const forward =
|
||||||
|
endpointMatches(packet.src_ip, packet.src_mac, conversation.src_ip_address, conversation.src_mac_address) &&
|
||||||
|
endpointMatches(packet.dst_ip, packet.dst_mac, conversation.dst_ip_address, conversation.dst_mac_address) &&
|
||||||
|
(conversation.src_port == null || packet.src_port === conversation.src_port) &&
|
||||||
|
(conversation.dst_port == null || packet.dst_port === conversation.dst_port);
|
||||||
|
if (forward) return 'forward';
|
||||||
|
|
||||||
|
const reverse =
|
||||||
|
endpointMatches(packet.src_ip, packet.src_mac, conversation.dst_ip_address, conversation.dst_mac_address) &&
|
||||||
|
endpointMatches(packet.dst_ip, packet.dst_mac, conversation.src_ip_address, conversation.src_mac_address) &&
|
||||||
|
(conversation.src_port == null || packet.dst_port === conversation.src_port) &&
|
||||||
|
(conversation.dst_port == null || packet.src_port === conversation.dst_port);
|
||||||
|
if (reverse) return 'reverse';
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLabelTags(values: string[], color = 'default') {
|
||||||
|
if (values.length === 0) return '—';
|
||||||
|
return (
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{values.map((value) => (
|
||||||
|
<Tag key={value} color={color}>
|
||||||
|
{value}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLabelCountTags(values: LabelCountEvidence[]) {
|
||||||
|
if (values.length === 0) return '—';
|
||||||
|
return (
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{values.map((value) => (
|
||||||
|
<Tag key={value.label} color={protocolColor(value.label)}>
|
||||||
|
{value.label}: {value.packet_count}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostIdentity(host: InterfaceHostProtocolEvidence) {
|
||||||
|
return `${host.ip_address ?? 'no-ip'}|${host.mac_address ?? 'no-mac'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostLabel(interfaceName: string, host: InterfaceHostProtocolEvidence) {
|
||||||
|
const ip = host.ip_address ?? 'unknown ip';
|
||||||
|
const mac = host.mac_address ?? 'unknown mac';
|
||||||
|
return `${interfaceName} • ${ip}\n${mac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protocolColor(protocol: string) {
|
||||||
|
const palette = d3.schemeTableau10;
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < protocol.length; index += 1) {
|
||||||
|
hash = (hash * 31 + protocol.charCodeAt(index)) >>> 0;
|
||||||
|
}
|
||||||
|
return palette[hash % palette.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartKind = 'sankey' | 'parallel' | 'force' | 'heatmap' | 'matrix' | 'timeline' | 'sequence';
|
||||||
|
|
||||||
|
// Single place to tune overall Analysis visualization sizing.
|
||||||
|
export const ANALYSIS_VISUAL_SCALE = 1;
|
||||||
|
|
||||||
|
// Per-visualization tuning layered on top of the global Analysis scale.
|
||||||
|
export const ANALYSIS_VISUAL_SCALE_RATES: Record<ChartKind, number> = {
|
||||||
|
sankey: 1.5,
|
||||||
|
parallel: 1.5,
|
||||||
|
force: 1.5,
|
||||||
|
heatmap: 1,
|
||||||
|
matrix: 1.2,
|
||||||
|
timeline: 1.5,
|
||||||
|
sequence: 1.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scaleVisual(value: number) {
|
||||||
|
return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleVisualFor(kind: ChartKind, value: number) {
|
||||||
|
return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE * ANALYSIS_VISUAL_SCALE_RATES[kind]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResponsiveChartWidth() {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const measureWidth = () => {
|
||||||
|
let width = container.getBoundingClientRect().width;
|
||||||
|
let ancestor = container.parentElement;
|
||||||
|
let depth = 0;
|
||||||
|
while (ancestor != null && depth < 3) {
|
||||||
|
width = Math.max(width, ancestor.getBoundingClientRect().width);
|
||||||
|
ancestor = ancestor.parentElement;
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSize = () => {
|
||||||
|
const width = measureWidth();
|
||||||
|
setViewportWidth(width > 0 ? Math.floor(width) : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => updateSize());
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { containerRef, viewportWidth };
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartPolicy = {
|
||||||
|
minWidth: number;
|
||||||
|
minHeight: number;
|
||||||
|
maxHeightPx: number;
|
||||||
|
maxHeightVh: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartPolicies: Record<ChartKind, ChartPolicy> = {
|
||||||
|
sankey: {
|
||||||
|
minWidth: scaleVisualFor('sankey', 560),
|
||||||
|
minHeight: scaleVisualFor('sankey', 230),
|
||||||
|
maxHeightPx: scaleVisualFor('sankey', 600),
|
||||||
|
maxHeightVh: 0.62,
|
||||||
|
},
|
||||||
|
parallel: {
|
||||||
|
minWidth: scaleVisualFor('parallel', 660),
|
||||||
|
minHeight: scaleVisualFor('parallel', 380),
|
||||||
|
maxHeightPx: scaleVisualFor('parallel', 740),
|
||||||
|
maxHeightVh: 0.72,
|
||||||
|
},
|
||||||
|
force: {
|
||||||
|
minWidth: scaleVisualFor('force', 660),
|
||||||
|
minHeight: scaleVisualFor('force', 340),
|
||||||
|
maxHeightPx: scaleVisualFor('force', 580),
|
||||||
|
maxHeightVh: 0.62,
|
||||||
|
},
|
||||||
|
heatmap: {
|
||||||
|
minWidth: scaleVisualFor('heatmap', 660),
|
||||||
|
minHeight: scaleVisualFor('heatmap', 320),
|
||||||
|
maxHeightPx: scaleVisualFor('heatmap', 600),
|
||||||
|
maxHeightVh: 0.62,
|
||||||
|
},
|
||||||
|
matrix: {
|
||||||
|
minWidth: scaleVisualFor('matrix', 660),
|
||||||
|
minHeight: scaleVisualFor('matrix', 320),
|
||||||
|
maxHeightPx: scaleVisualFor('matrix', 600),
|
||||||
|
maxHeightVh: 0.62,
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
minWidth: scaleVisualFor('timeline', 740),
|
||||||
|
minHeight: scaleVisualFor('timeline', 300),
|
||||||
|
maxHeightPx: scaleVisualFor('timeline', 620),
|
||||||
|
maxHeightVh: 0.66,
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
minWidth: scaleVisualFor('sequence', 740),
|
||||||
|
minHeight: scaleVisualFor('sequence', 340),
|
||||||
|
maxHeightPx: scaleVisualFor('sequence', 780),
|
||||||
|
maxHeightVh: 0.76,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartSizeOverrides = Partial<ChartPolicy>;
|
||||||
|
|
||||||
|
function resolveChartMaxHeight(viewportHeight: number, minHeight: number, maxHeightPx: number, maxHeightVh: number) {
|
||||||
|
const viewportCap = viewportHeight > 0 ? Math.floor(viewportHeight * maxHeightVh) : maxHeightPx;
|
||||||
|
return Math.min(maxHeightPx, Math.max(minHeight, viewportCap));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResponsiveChartSize(kind: ChartKind, overrides: ChartSizeOverrides = {}) {
|
||||||
|
const { containerRef, viewportWidth } = useResponsiveChartWidth();
|
||||||
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateViewportHeight = () => {
|
||||||
|
const height = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||||
|
setViewportHeight(height > 0 ? Math.floor(height) : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateViewportHeight();
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateViewportHeight);
|
||||||
|
return () => window.removeEventListener('resize', updateViewportHeight);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const policy = { ...chartPolicies[kind], ...overrides };
|
||||||
|
const chartWidth = viewportWidth > 0 ? Math.floor(viewportWidth) : policy.minWidth;
|
||||||
|
const maxChartHeight = resolveChartMaxHeight(
|
||||||
|
viewportHeight,
|
||||||
|
policy.minHeight,
|
||||||
|
policy.maxHeightPx,
|
||||||
|
policy.maxHeightVh,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fitHeight = (desiredHeight: number, localOverrides: ChartSizeOverrides = {}) => {
|
||||||
|
const localMinHeight = localOverrides.minHeight ?? policy.minHeight;
|
||||||
|
const localMaxHeight = resolveChartMaxHeight(
|
||||||
|
viewportHeight,
|
||||||
|
localMinHeight,
|
||||||
|
localOverrides.maxHeightPx ?? policy.maxHeightPx,
|
||||||
|
localOverrides.maxHeightVh ?? policy.maxHeightVh,
|
||||||
|
);
|
||||||
|
return clamp(desiredHeight, localMinHeight, localMaxHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
chartWidth,
|
||||||
|
minChartHeight: policy.minHeight,
|
||||||
|
maxChartHeight,
|
||||||
|
fitHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sankeyVisualWeight(packetCount: number) {
|
||||||
|
return Math.max(1, Math.sqrt(Math.max(0, packetCount)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrUpdateLink(
|
||||||
|
links: Map<string, TopologyLink>,
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
packetCount: number,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
const linkId = `${source}->${target}`;
|
||||||
|
const existing = links.get(linkId);
|
||||||
|
if (existing) {
|
||||||
|
existing.packetCount += packetCount;
|
||||||
|
existing.value = existing.packetCount;
|
||||||
|
existing.label = `${existing.label.split(' (')[0]} (${existing.packetCount})`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
links.set(linkId, {
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
value: packetCount,
|
||||||
|
packetCount,
|
||||||
|
label: `${label} (${packetCount})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProtocolNode(nodes: Map<string, TopologyNode>, id: string, label: string, kind: TopologyLayerKind) {
|
||||||
|
if (!nodes.has(id)) {
|
||||||
|
nodes.set(id, {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
packetCount: 0,
|
||||||
|
protocol: label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return nodes.get(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTopologyData(interfaces: InterfaceProtocolAttachment[], options: TopologyOptions): TopologyData {
|
||||||
|
const nodes = new Map<string, TopologyNode>();
|
||||||
|
const links = new Map<string, TopologyLink>();
|
||||||
|
const heatmapByHost = new Map<string, HeatmapRow>();
|
||||||
|
const protocols = new Set<string>();
|
||||||
|
const tableRows: ProtocolTableRow[] = [];
|
||||||
|
|
||||||
|
for (const entry of interfaces) {
|
||||||
|
const interfaceNodeId = `iface:${entry.interface}`;
|
||||||
|
nodes.set(interfaceNodeId, {
|
||||||
|
id: interfaceNodeId,
|
||||||
|
label: entry.interface,
|
||||||
|
kind: 'interface',
|
||||||
|
packetCount: entry.hosts.reduce((sum, host) => sum + host.packet_count, 0),
|
||||||
|
interfaceName: entry.interface,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const host of entry.hosts) {
|
||||||
|
const hostId = `host:${entry.interface}:${hostIdentity(host)}`;
|
||||||
|
nodes.set(hostId, {
|
||||||
|
id: hostId,
|
||||||
|
label: hostLabel(entry.interface, host),
|
||||||
|
kind: 'host',
|
||||||
|
packetCount: host.packet_count,
|
||||||
|
interfaceName: entry.interface,
|
||||||
|
ipAddress: host.ip_address,
|
||||||
|
macAddress: host.mac_address,
|
||||||
|
});
|
||||||
|
|
||||||
|
const interfaceHostLinkId = `${interfaceNodeId}->${hostId}`;
|
||||||
|
links.set(interfaceHostLinkId, {
|
||||||
|
source: interfaceNodeId,
|
||||||
|
target: hostId,
|
||||||
|
value: host.packet_count,
|
||||||
|
packetCount: host.packet_count,
|
||||||
|
label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const heatmapRow: HeatmapRow = {
|
||||||
|
hostId,
|
||||||
|
hostLabel: `${entry.interface} • ${host.ip_address ?? 'unknown ip'}`,
|
||||||
|
interfaceName: entry.interface,
|
||||||
|
values: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const protocol of host.protocols) {
|
||||||
|
protocols.add(protocol.protocol);
|
||||||
|
const layerPaths =
|
||||||
|
protocol.layer_paths.length > 0
|
||||||
|
? protocol.layer_paths
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
ethernet_protocol: protocol.ethernet_protocol ?? null,
|
||||||
|
ip_protocol: protocol.ip_protocol ?? null,
|
||||||
|
packet_count: protocol.packet_count,
|
||||||
|
last_seen: protocol.last_seen,
|
||||||
|
accept_count: protocol.accept_count,
|
||||||
|
drop_count: protocol.drop_count,
|
||||||
|
reject_count: protocol.reject_count,
|
||||||
|
unknown_count: protocol.unknown_count,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const layerPath of layerPaths) {
|
||||||
|
let currentNodeId = hostId;
|
||||||
|
let currentLabel = host.ip_address ?? host.mac_address ?? 'host';
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.includeEthernetLayer &&
|
||||||
|
layerPath.ethernet_protocol &&
|
||||||
|
layerPath.ethernet_protocol !== protocol.protocol
|
||||||
|
) {
|
||||||
|
const ethernetId = `ethernet:${layerPath.ethernet_protocol}`;
|
||||||
|
const ethernetNode = ensureProtocolNode(nodes, ethernetId, layerPath.ethernet_protocol, 'ethernet');
|
||||||
|
ethernetNode.packetCount += layerPath.packet_count;
|
||||||
|
addOrUpdateLink(
|
||||||
|
links,
|
||||||
|
currentNodeId,
|
||||||
|
ethernetId,
|
||||||
|
layerPath.packet_count,
|
||||||
|
`${currentLabel} -> ${layerPath.ethernet_protocol}`,
|
||||||
|
);
|
||||||
|
currentNodeId = ethernetId;
|
||||||
|
currentLabel = layerPath.ethernet_protocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.includeIpLayer &&
|
||||||
|
layerPath.ip_protocol &&
|
||||||
|
layerPath.ip_protocol !== currentLabel &&
|
||||||
|
layerPath.ip_protocol !== protocol.protocol
|
||||||
|
) {
|
||||||
|
const ipId = `ip:${layerPath.ip_protocol}`;
|
||||||
|
const ipNode = ensureProtocolNode(nodes, ipId, layerPath.ip_protocol, 'ip');
|
||||||
|
ipNode.packetCount += layerPath.packet_count;
|
||||||
|
addOrUpdateLink(
|
||||||
|
links,
|
||||||
|
currentNodeId,
|
||||||
|
ipId,
|
||||||
|
layerPath.packet_count,
|
||||||
|
`${currentLabel} -> ${layerPath.ip_protocol}`,
|
||||||
|
);
|
||||||
|
currentNodeId = ipId;
|
||||||
|
currentLabel = layerPath.ip_protocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLabel !== protocol.protocol || currentNodeId === hostId) {
|
||||||
|
const protocolId = `protocol:${protocol.protocol}`;
|
||||||
|
const protocolNode = ensureProtocolNode(nodes, protocolId, protocol.protocol, 'protocol');
|
||||||
|
protocolNode.packetCount += layerPath.packet_count;
|
||||||
|
addOrUpdateLink(
|
||||||
|
links,
|
||||||
|
currentNodeId,
|
||||||
|
protocolId,
|
||||||
|
layerPath.packet_count,
|
||||||
|
`${currentLabel} -> ${protocol.protocol}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heatmapRow.values[protocol.protocol] = protocol.packet_count;
|
||||||
|
tableRows.push({
|
||||||
|
key: `${entry.interface}-${hostIdentity(host)}-${protocol.protocol}`,
|
||||||
|
interface: entry.interface,
|
||||||
|
ip_address: host.ip_address,
|
||||||
|
mac_address: host.mac_address,
|
||||||
|
host_packet_count: host.packet_count,
|
||||||
|
protocol: protocol.protocol,
|
||||||
|
protocol_packet_count: protocol.packet_count,
|
||||||
|
accept_count: protocol.accept_count,
|
||||||
|
drop_count: protocol.drop_count,
|
||||||
|
reject_count: protocol.reject_count,
|
||||||
|
unknown_count: protocol.unknown_count,
|
||||||
|
last_seen: protocol.last_seen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
heatmapByHost.set(hostId, heatmapRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: Array.from(nodes.values()),
|
||||||
|
links: Array.from(links.values()),
|
||||||
|
heatmapRows: Array.from(heatmapByHost.values()).sort((left, right) =>
|
||||||
|
left.hostLabel.localeCompare(right.hostLabel),
|
||||||
|
),
|
||||||
|
protocols: Array.from(protocols).sort(),
|
||||||
|
tableRows: tableRows.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.protocol_packet_count - left.protocol_packet_count || left.interface.localeCompare(right.interface),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDirectionalSankeyData(
|
||||||
|
paths: InterfaceProtocolPathEvidence[],
|
||||||
|
options: TopologyOptions,
|
||||||
|
): TopologyData {
|
||||||
|
const nodes = new Map<string, TopologyNode>();
|
||||||
|
const links = new Map<string, TopologyLink>();
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const packetCount = path.packet_count;
|
||||||
|
const ingressLabel = path.ingress_interface ? `${path.ingress_interface} (ingress)` : 'Unknown ingress';
|
||||||
|
const ingressId = `ingress:${path.ingress_interface ?? 'unknown'}`;
|
||||||
|
const sourceLabel = endpointText(path.src_ip_address, path.src_mac_address);
|
||||||
|
const sourceId = `source:${path.src_ip_address ?? 'no-ip'}|${path.src_mac_address ?? 'no-mac'}`;
|
||||||
|
const protocolLabel = path.protocol;
|
||||||
|
const protocolId = `protocol:${protocolLabel}`;
|
||||||
|
const destinationLabel = endpointText(path.dst_ip_address, path.dst_mac_address);
|
||||||
|
const destinationId = `destination:${path.dst_ip_address ?? 'no-ip'}|${path.dst_mac_address ?? 'no-mac'}`;
|
||||||
|
const egressLabel = path.egress_interface ? `${path.egress_interface} (egress)` : 'Unknown egress';
|
||||||
|
const egressId = `egress:${path.egress_interface ?? 'unknown'}`;
|
||||||
|
|
||||||
|
ensureProtocolNode(nodes, ingressId, ingressLabel, 'interface').packetCount += packetCount;
|
||||||
|
nodes.set(sourceId, {
|
||||||
|
...(nodes.get(sourceId) ?? {
|
||||||
|
id: sourceId,
|
||||||
|
label: sourceLabel,
|
||||||
|
kind: 'host' as const,
|
||||||
|
packetCount: 0,
|
||||||
|
ipAddress: path.src_ip_address,
|
||||||
|
macAddress: path.src_mac_address,
|
||||||
|
}),
|
||||||
|
packetCount: (nodes.get(sourceId)?.packetCount ?? 0) + packetCount,
|
||||||
|
});
|
||||||
|
ensureProtocolNode(nodes, protocolId, protocolLabel, 'protocol').packetCount += packetCount;
|
||||||
|
nodes.set(destinationId, {
|
||||||
|
...(nodes.get(destinationId) ?? {
|
||||||
|
id: destinationId,
|
||||||
|
label: destinationLabel,
|
||||||
|
kind: 'host' as const,
|
||||||
|
packetCount: 0,
|
||||||
|
ipAddress: path.dst_ip_address,
|
||||||
|
macAddress: path.dst_mac_address,
|
||||||
|
}),
|
||||||
|
packetCount: (nodes.get(destinationId)?.packetCount ?? 0) + packetCount,
|
||||||
|
});
|
||||||
|
ensureProtocolNode(nodes, egressId, egressLabel, 'interface').packetCount += packetCount;
|
||||||
|
|
||||||
|
addOrUpdateLink(links, sourceId, ingressId, packetCount, `${sourceLabel} -> ${ingressLabel}`);
|
||||||
|
|
||||||
|
let currentNodeId = ingressId;
|
||||||
|
let currentLabel = ingressLabel;
|
||||||
|
|
||||||
|
if (options.includeEthernetLayer && path.ethernet_protocol && path.ethernet_protocol !== protocolLabel) {
|
||||||
|
const ethernetId = `ethernet:${path.ethernet_protocol}`;
|
||||||
|
ensureProtocolNode(nodes, ethernetId, path.ethernet_protocol, 'ethernet').packetCount += packetCount;
|
||||||
|
addOrUpdateLink(links, currentNodeId, ethernetId, packetCount, `${currentLabel} -> ${path.ethernet_protocol}`);
|
||||||
|
currentNodeId = ethernetId;
|
||||||
|
currentLabel = path.ethernet_protocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.includeIpLayer &&
|
||||||
|
path.ip_protocol &&
|
||||||
|
path.ip_protocol !== currentLabel &&
|
||||||
|
path.ip_protocol !== protocolLabel
|
||||||
|
) {
|
||||||
|
const ipId = `ip:${path.ip_protocol}`;
|
||||||
|
ensureProtocolNode(nodes, ipId, path.ip_protocol, 'ip').packetCount += packetCount;
|
||||||
|
addOrUpdateLink(links, currentNodeId, ipId, packetCount, `${currentLabel} -> ${path.ip_protocol}`);
|
||||||
|
currentNodeId = ipId;
|
||||||
|
currentLabel = path.ip_protocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrUpdateLink(links, currentNodeId, protocolId, packetCount, `${currentLabel} -> ${protocolLabel}`);
|
||||||
|
addOrUpdateLink(links, protocolId, egressId, packetCount, `${protocolLabel} -> ${egressLabel}`);
|
||||||
|
addOrUpdateLink(links, egressId, destinationId, packetCount, `${egressLabel} -> ${destinationLabel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: Array.from(nodes.values()),
|
||||||
|
links: Array.from(links.values()),
|
||||||
|
heatmapRows: [],
|
||||||
|
protocols: [],
|
||||||
|
tableRows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user