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;
}