test visuals
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Checkbox,
|
||||
Col,
|
||||
Empty,
|
||||
Drawer,
|
||||
InputNumber,
|
||||
Row,
|
||||
Space,
|
||||
@@ -1167,6 +1168,188 @@ function ProtocolHeatmap({ data }: { data: TopologyData }) {
|
||||
return <svg ref={svgRef} style={{ width: '100%', minWidth: 960 }} />;
|
||||
}
|
||||
|
||||
function ConversationMatrix({ conversations }: { conversations: ConversationEvidence[] }) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
const matrixData = useMemo(() => {
|
||||
const sourceTotals = new Map<string, number>();
|
||||
const destinationTotals = new Map<string, number>();
|
||||
const cellMap = new Map<
|
||||
string,
|
||||
{
|
||||
source: string;
|
||||
destination: string;
|
||||
packetCount: number;
|
||||
byteCount: number;
|
||||
protocols: Set<string>;
|
||||
lastSeen: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const conversation of conversations) {
|
||||
const source = endpointText(conversation.src_ip_address, conversation.src_mac_address);
|
||||
const destination = endpointText(conversation.dst_ip_address, conversation.dst_mac_address);
|
||||
sourceTotals.set(source, (sourceTotals.get(source) ?? 0) + conversation.packet_count);
|
||||
destinationTotals.set(destination, (destinationTotals.get(destination) ?? 0) + conversation.packet_count);
|
||||
|
||||
const key = `${source}->${destination}`;
|
||||
const existing = cellMap.get(key);
|
||||
if (existing) {
|
||||
existing.packetCount += conversation.packet_count;
|
||||
existing.byteCount += conversation.byte_count;
|
||||
existing.protocols.add(conversation.protocol);
|
||||
if (conversation.last_seen > existing.lastSeen) {
|
||||
existing.lastSeen = conversation.last_seen;
|
||||
}
|
||||
} else {
|
||||
cellMap.set(key, {
|
||||
source,
|
||||
destination,
|
||||
packetCount: conversation.packet_count,
|
||||
byteCount: conversation.byte_count,
|
||||
protocols: new Set([conversation.protocol]),
|
||||
lastSeen: conversation.last_seen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sources = Array.from(sourceTotals.entries())
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.slice(0, 18)
|
||||
.map(([label]) => label);
|
||||
const destinations = Array.from(destinationTotals.entries())
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.slice(0, 18)
|
||||
.map(([label]) => label);
|
||||
|
||||
const cells = Array.from(cellMap.values()).filter(
|
||||
(cell) => sources.includes(cell.source) && destinations.includes(cell.destination),
|
||||
);
|
||||
|
||||
return { sources, destinations, cells };
|
||||
}, [conversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
if (matrixData.sources.length === 0 || matrixData.destinations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const margin = { top: 170, right: 30, bottom: 30, left: 250 };
|
||||
const cellWidth = 42;
|
||||
const cellHeight = 34;
|
||||
const width = Math.max(980, margin.left + margin.right + matrixData.destinations.length * cellWidth);
|
||||
const height = Math.max(360, margin.top + margin.bottom + matrixData.sources.length * cellHeight);
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
const x = d3
|
||||
.scaleBand<string>()
|
||||
.domain(matrixData.destinations)
|
||||
.range([margin.left, width - margin.right])
|
||||
.paddingInner(0.08);
|
||||
const y = d3
|
||||
.scaleBand<string>()
|
||||
.domain(matrixData.sources)
|
||||
.range([margin.top, height - margin.bottom])
|
||||
.paddingInner(0.08);
|
||||
const maxPackets = d3.max(matrixData.cells, (cell) => cell.packetCount) ?? 1;
|
||||
const color = d3.scaleSequential(d3.interpolateOrRd).domain([0, maxPackets]);
|
||||
|
||||
svg
|
||||
.append('rect')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('rx', 18)
|
||||
.attr('fill', '#fbfcfe');
|
||||
|
||||
const cellLayer = svg.append('g');
|
||||
for (const source of matrixData.sources) {
|
||||
for (const destination of matrixData.destinations) {
|
||||
const cell = matrixData.cells.find((entry) => entry.source === source && entry.destination === destination);
|
||||
const packetCount = cell?.packetCount ?? 0;
|
||||
const byteCount = cell?.byteCount ?? 0;
|
||||
|
||||
const group = cellLayer
|
||||
.append('g')
|
||||
.attr('transform', `translate(${x(destination) ?? 0},${y(source) ?? 0})`);
|
||||
|
||||
group
|
||||
.append('rect')
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('height', y.bandwidth())
|
||||
.attr('rx', 8)
|
||||
.attr('fill', packetCount > 0 ? color(packetCount) : '#eef3f8')
|
||||
.attr('stroke', '#dce5ef');
|
||||
|
||||
if (packetCount > 0) {
|
||||
group
|
||||
.append('text')
|
||||
.attr('x', x.bandwidth() / 2)
|
||||
.attr('y', y.bandwidth() / 2 + 4)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', 10)
|
||||
.attr('font-weight', 700)
|
||||
.attr('fill', packetCount > maxPackets * 0.45 ? '#ffffff' : '#7f2704')
|
||||
.text(packetCount);
|
||||
}
|
||||
|
||||
group.append('title').text(
|
||||
[
|
||||
`${source} -> ${destination}`,
|
||||
`Packets: ${packetCount}`,
|
||||
`Bytes: ${formatBytes(byteCount)}`,
|
||||
`Protocols: ${cell ? Array.from(cell.protocols).sort().join(', ') : '—'}`,
|
||||
`Last seen: ${cell ? formatTimestamp(cell.lastSeen) : '—'}`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
svg
|
||||
.append('g')
|
||||
.selectAll('text.destination-label')
|
||||
.data(matrixData.destinations)
|
||||
.join('text')
|
||||
.attr('class', 'destination-label')
|
||||
.attr('x', (destination) => (x(destination) ?? 0) + x.bandwidth() / 2)
|
||||
.attr('y', margin.top - 12)
|
||||
.attr(
|
||||
'transform',
|
||||
(destination) => `rotate(-40, ${(x(destination) ?? 0) + x.bandwidth() / 2}, ${margin.top - 12})`,
|
||||
)
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('font-size', 11)
|
||||
.attr('font-weight', 600)
|
||||
.attr('fill', '#29445d')
|
||||
.text((destination) => destination);
|
||||
|
||||
svg
|
||||
.append('g')
|
||||
.selectAll('text.source-label')
|
||||
.data(matrixData.sources)
|
||||
.join('text')
|
||||
.attr('class', 'source-label')
|
||||
.attr('x', margin.left - 12)
|
||||
.attr('y', (source) => (y(source) ?? 0) + y.bandwidth() / 2 + 4)
|
||||
.attr('text-anchor', 'end')
|
||||
.attr('font-size', 11)
|
||||
.attr('fill', '#29445d')
|
||||
.text((source) => source);
|
||||
}, [matrixData]);
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return <Empty description="No conversation matrix data available yet" />;
|
||||
}
|
||||
|
||||
return <svg ref={svgRef} style={{ width: '100%', minWidth: 980 }} />;
|
||||
}
|
||||
|
||||
export default function Analysis(): ReactElement {
|
||||
const [sinceMinutes, setSinceMinutes] = useState<number | null>(null);
|
||||
const [limitPerInterface, setLimitPerInterface] = useState(50);
|
||||
@@ -1784,6 +1967,14 @@ export default function Analysis(): ReactElement {
|
||||
label: 'Communication',
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card title="Conversation Matrix">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
Source-to-destination adjacency matrix for the busiest conversations. Cell color and value reflect
|
||||
packet volume, which makes the dominant communication relationships stand out quickly.
|
||||
</Paragraph>
|
||||
<ConversationMatrix conversations={conversationData?.conversations ?? []} />
|
||||
</Card>
|
||||
|
||||
<Card title="Conversation Explorer">
|
||||
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||
Directional conversations grouped by source, destination, ports, protocol, and verdict outcome.
|
||||
|
||||
@@ -199,6 +199,7 @@ export interface BeaconCandidateEvidence {
|
||||
packet_count: number;
|
||||
avg_interval_seconds: number;
|
||||
jitter_ratio: number;
|
||||
interval_samples: number[];
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user