From ae432b74376ed6959e1f75f29bf2581fb776345f Mon Sep 17 00:00:00 2001 From: malmert Date: Tue, 31 Mar 2026 22:37:00 +0200 Subject: [PATCH] test visuals --- backend/src/api/analysis_api.py | 1 + backend/src/utilities/database.py | 1 + frontend/src/pages/Analysis.tsx | 191 ++++++++++++++++++++++++++++++ frontend/src/types/analysis.ts | 1 + 4 files changed, 194 insertions(+) diff --git a/backend/src/api/analysis_api.py b/backend/src/api/analysis_api.py index b16aa41..872863c 100644 --- a/backend/src/api/analysis_api.py +++ b/backend/src/api/analysis_api.py @@ -251,6 +251,7 @@ class BeaconCandidateEvidence(BaseModel): packet_count: int avg_interval_seconds: float jitter_ratio: float + interval_samples: List[float] = Field(default_factory=list) first_seen: datetime last_seen: datetime diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index e297030..6d06137 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -2056,6 +2056,7 @@ class DatabasePool: "packet_count": int(record.get("packet_count") or 0), "avg_interval_seconds": round(avg_interval, 2), "jitter_ratio": round(jitter_ratio, 3), + "interval_samples": [round(interval, 2) for interval in intervals[:16]], "first_seen": first_seen_raw.isoformat() if hasattr(first_seen_raw, "isoformat") else first_seen_raw, "last_seen": last_seen_raw.isoformat() if hasattr(last_seen_raw, "isoformat") else last_seen_raw, } diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 27c67f2..255e728 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -6,6 +6,7 @@ import { Checkbox, Col, Empty, + Drawer, InputNumber, Row, Space, @@ -1167,6 +1168,188 @@ function ProtocolHeatmap({ data }: { data: TopologyData }) { return ; } +function ConversationMatrix({ conversations }: { conversations: ConversationEvidence[] }) { + const svgRef = useRef(null); + + const matrixData = useMemo(() => { + const sourceTotals = new Map(); + const destinationTotals = new Map(); + const cellMap = new Map< + string, + { + source: string; + destination: string; + packetCount: number; + byteCount: number; + protocols: Set; + lastSeen: string; + } + >(); + + for (const conversation of conversations) { + const source = endpointText(conversation.src_ip_address, conversation.src_mac_address); + const destination = endpointText(conversation.dst_ip_address, conversation.dst_mac_address); + sourceTotals.set(source, (sourceTotals.get(source) ?? 0) + conversation.packet_count); + destinationTotals.set(destination, (destinationTotals.get(destination) ?? 0) + conversation.packet_count); + + const key = `${source}->${destination}`; + const existing = cellMap.get(key); + if (existing) { + existing.packetCount += conversation.packet_count; + existing.byteCount += conversation.byte_count; + existing.protocols.add(conversation.protocol); + if (conversation.last_seen > existing.lastSeen) { + existing.lastSeen = conversation.last_seen; + } + } else { + cellMap.set(key, { + source, + destination, + packetCount: conversation.packet_count, + byteCount: conversation.byte_count, + protocols: new Set([conversation.protocol]), + lastSeen: conversation.last_seen, + }); + } + } + + const sources = Array.from(sourceTotals.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 18) + .map(([label]) => label); + const destinations = Array.from(destinationTotals.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 18) + .map(([label]) => label); + + const cells = Array.from(cellMap.values()).filter( + (cell) => sources.includes(cell.source) && destinations.includes(cell.destination), + ); + + return { sources, destinations, cells }; + }, [conversations]); + + 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() + .domain(matrixData.destinations) + .range([margin.left, width - margin.right]) + .paddingInner(0.08); + const y = d3 + .scaleBand() + .domain(matrixData.sources) + .range([margin.top, height - margin.bottom]) + .paddingInner(0.08); + const maxPackets = d3.max(matrixData.cells, (cell) => cell.packetCount) ?? 1; + const color = d3.scaleSequential(d3.interpolateOrRd).domain([0, maxPackets]); + + svg + .append('rect') + .attr('x', 0) + .attr('y', 0) + .attr('width', width) + .attr('height', height) + .attr('rx', 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 ; + } + + return ; +} + export default function Analysis(): ReactElement { const [sinceMinutes, setSinceMinutes] = useState(null); const [limitPerInterface, setLimitPerInterface] = useState(50); @@ -1784,6 +1967,14 @@ export default function Analysis(): ReactElement { label: 'Communication', children: ( + + + 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. + + + + Directional conversations grouped by source, destination, ports, protocol, and verdict outcome. diff --git a/frontend/src/types/analysis.ts b/frontend/src/types/analysis.ts index fa83522..dee5c72 100644 --- a/frontend/src/types/analysis.ts +++ b/frontend/src/types/analysis.ts @@ -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; }