diff --git a/docs/features/network-intelligence.rst b/docs/features/network-intelligence.rst index 9e6eb91e..853e8917 100644 --- a/docs/features/network-intelligence.rst +++ b/docs/features/network-intelligence.rst @@ -52,11 +52,37 @@ Color Modes Two color modes are available via the **Color by** toggle: -- **Traffic** — cluster nodes are shaded on a blue heatmap proportional to - their ``totalBytes`` relative to the busiest cluster. Darker = more traffic. +- **Traffic** — cluster nodes are shaded by ``totalBytes`` relative to the + busiest cluster, using the shared volume color scale (see below). - **Risk** — clusters with at least one nDPI risk flag show a red warning badge. Nodes without risk flags are neutral grey. +.. _shared-volume-scale: + +The Shared Volume Color Scale +----------------------------- + +Everywhere traffic volume is encoded as color — cluster nodes, the country map, +the node-to-node heatmap and volume-colored diagram edges (see +:doc:`network-visualization`) — the same scale is used, so a given shade always +means the same thing. It lives in ``frontend/src/utils/volumeColor.ts``. + +Two properties are worth knowing when reading any of these views: + +- **The scale is logarithmic.** Traffic volume is heavily skewed: a handful of + pairs typically carry most of the bytes. On a linear ramp that yields one dark + mark and a wash of pale ones. Note this means the midpoint of the legend is + *not* half the maximum. +- **The scale is relative to what is on screen.** It normalizes against the + busiest item in the current, filtered view — so changing filters re-fits the + ramp, and shades are comparable within a view rather than across captures. + +In dark mode the anchors flip: magnitude reads as distance from the surface, so +on a light background "loud" is dark blue, and on a dark background it is bright +blue. Marks that are stroked rather than filled (diagram edges) use a floored +variant of the ramp, so even the quietest edge stays visible instead of fading +into the canvas. + Cluster Side Panel ------------------ diff --git a/docs/features/network-visualization.rst b/docs/features/network-visualization.rst index 8fcc1fbf..9815712f 100644 --- a/docs/features/network-visualization.rst +++ b/docs/features/network-visualization.rst @@ -36,7 +36,93 @@ they produce multiple edges — one per conversation row. Edge thickness is fixed: **1.5 px** for edges in a single capture, or **2.5 px** for edges that appear in both captures when using the Compare view. Edge width -does not vary with traffic volume. +does not vary with traffic volume — volume is carried by color instead, when the +**Color edges by** control is set to Volume (see below). + +Edge Color Modes +~~~~~~~~~~~~~~~~ + +The **Color edges by** control above the diagram switches what edge color means: + +- **Protocol** (default) — each edge takes its protocol's color. +- **Volume** — each edge is shaded by its ``totalBytes`` on the + :ref:`shared volume color scale `, the same scale the + node-to-node heatmap uses. A gradient legend appears beside the control. + +These are alternatives rather than layers. Color is a single channel, and +encoding two meanings in it at once would make neither readable — so only one is +active at a time, and whichever is active is accompanied by its legend. + +Switching modes repaints the edges in place; it does not re-run the layout, so +node positions are preserved. + +The control is available in both the analysis-mode diagram and the monitor-mode +snapshot diagram, which share the same underlying graph component. + +Node-to-Node Volume Heatmap +--------------------------- + +Below the diagram, the **Node-to-Node Volume** panel renders a src×dst matrix of +the displayed hosts, each cell shaded by the volume between that pair on the +:ref:`shared volume color scale `. The diagram answers +*who talks to whom*; the matrix answers *how much*, which a force-directed +layout cannot show — a hairball is the wrong shape for "which pair is loudest". + +Key properties: + +- **Same data as the diagram.** The matrix is aggregated from exactly the + filtered edges the diagram is rendering, so filtering the diagram re-fits the + matrix too. Note the two aggregate at different levels: a cell is the total + across every conversation between a pair, whereas the diagram draws one edge + per protocol/app. For a single-protocol pair a cell and its edge are the same + number; for a multi-protocol pair the cell is the sum of its edges. Each view + normalizes against its own maximum, so compare within a view. +- **Busiest-first ordering.** Rows and columns are sorted by total volume, so + the dark cells cluster toward the top-left and the shape of the matrix itself + carries information. +- **Renders every host.** The grid is drawn to a ````, not a table, so it + is not limited the way a DOM grid would be — a 500-host capture is 250,000 + cells, which as DOM nodes would freeze the tab. Because the matrix is *sparse*, + only pairs that actually exchanged traffic are drawn, so cost scales with the + number of conversations rather than with N². A sanity cap of 1000 hosts applies; + beyond that a cell is under a pixel. Any hosts dropped by that cap are the + *quietest*, since the axes are ordered by volume. +- **Fits, then zooms.** Cell size is fitted to the panel width (2–22px). At high + host counts the matrix becomes a dense overview texture where block structure + and hotspots are the signal, and axis labels drop out below 9px per cell. The + zoom control trades that overview for an inspectable grid that scrolls, and + fullscreen gives the fitted view more room — 100 hosts fit with labels at + 17px per cell in fullscreen, versus 10px in the inline panel. +- **Cross-filtering.** Clicking a cell highlights that pair in the diagram; + selecting a node in the diagram emphasizes its row and column in the matrix. +- **Axis labels are IP addresses**, shown when cells are at least 9px, and + clicking one opens that host's details panel. IPs are used rather than identity + or device-type labels because those are not unique — two hosts can carry the + same one, which on a matrix axis would leave two rows that cannot be told + apart. Hosts with no IP (L2-only nodes) fall back to their MAC. Hovering a + cell shows the fuller identity of both endpoints — hostname and adjudicated + identity where known — in the readout beneath the grid. + +The panel appears in two places, with the same canvas rendering, zoom and +fullscreen in both: + +- the **Network Topology Diagram** tab in analysis mode + (``/analysis//network-diagram``), below the diagram; +- the **Network Diagram** tab of the snapshot dialog in monitor mode, reached + from **Capture Timeline** on a network's detail page. + +The one difference is where fullscreen stacks: from the snapshot dialog it must +sit *above* the modal containing it, so it uses its own overlay rather than the +shared card treatment. In the dialog the panel is collapsed by default, since +that view is already dense and the diagram is the primary subject. + +.. note:: + + Cells are directed (row = source, column = destination), but each + conversation's ``totalBytes`` covers **both** directions of that exchange. A + cell therefore reads as "volume of the exchanges this host initiated", not + "bytes this host sent". Separating the two requires per-direction counters, + which are not yet captured. Node Attributes and Their Sources ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/frontend/src/components/intelligence/ClusterGraph/ClusterGraph.tsx b/frontend/src/components/intelligence/ClusterGraph/ClusterGraph.tsx index 1f9751d7..0e4c442c 100644 --- a/frontend/src/components/intelligence/ClusterGraph/ClusterGraph.tsx +++ b/frontend/src/components/intelligence/ClusterGraph/ClusterGraph.tsx @@ -27,6 +27,8 @@ import '@xyflow/react/dist/style.css'; import './ClusterGraph.css'; import ELK from 'elkjs'; import { formatBytes } from '@/utils/formatters'; +import { makeVolumeColor, volumeTextColor, volumeRatio } from '@/utils/volumeColor'; +import { useResolvedDark } from '@/utils/useResolvedDark'; import type { ClusterGraphResponse, ClusterNode as ClusterNodeData, GroupBy } from '@/features/intelligence/services/intelligenceService'; import { conversationService } from '@/features/conversation/services/conversationService'; import type { Conversation } from '@/types'; @@ -311,15 +313,6 @@ const GROUP_ICONS: Record = { type ColorMode = 'risk' | 'traffic'; -/** Returns a blue shade interpolated by ratio (0→light, 1→dark). */ -function trafficColor(ratio: number): string { - // Interpolate from #d0e4f7 (light blue) to #1565c0 (dark blue) - const r = Math.round(208 - ratio * (208 - 21)); - const g = Math.round(228 - ratio * (228 - 101)); - const b = Math.round(247 - ratio * (247 - 192)); - return `rgb(${r},${g},${b})`; -} - // ── Custom node ─────────────────────────────────────────────────────────────── interface IntelClusterNodeData extends Record { @@ -332,18 +325,25 @@ interface IntelClusterNodeData extends Record { groupType: string; selected: boolean; colorMode: ColorMode; - trafficRatio: number; // 0-1, totalBytes / maxBytes across all clusters + /** Largest totalBytes across all clusters — the volume scale's upper bound. */ + maxBytes: number; } function IntelClusterNode({ data }: NodeProps) { const d = data as IntelClusterNodeData; const hasRisk = d.riskCount > 0; const icon = GROUP_ICONS[d.groupType as GroupBy] ?? 'bi-diagram-3'; + const dark = useResolvedDark(); const isTrafficMode = d.colorMode === 'traffic'; - const bgColor = isTrafficMode ? trafficColor(d.trafficRatio) : undefined; + // Shared volume scale — the same one the node-to-node heatmap and volume-coloured + // diagram edges use, so a shade means the same thing across every view. + const trafficRatio = volumeRatio(d.totalBytes, d.maxBytes); + const bgColor = isTrafficMode + ? makeVolumeColor(d.maxBytes, dark)(d.totalBytes) + : undefined; const textColor = isTrafficMode - ? (d.trafficRatio > 0.55 ? '#fff' : '#212529') + ? volumeTextColor(trafficRatio, dark) : (hasRisk ? '#e74c3c' : '#495057'); const riskTitle = hasRisk @@ -361,7 +361,7 @@ function IntelClusterNode({ data }: NodeProps) { {hasRisk && ( - 0.55 ? '#ffcdd2' : '#e74c3c' }}> + 0.55 ? '#ffcdd2' : '#e74c3c' }}> )} @@ -378,7 +378,7 @@ function IntelClusterNode({ data }: NodeProps) { {d.dominantProtocols.length > 0 && (
{d.dominantProtocols.slice(0, 3).map(p => ( - 0.55 ? ' intel-cluster-badge-dark' : ''}`}>{p} + 0.55 ? ' intel-cluster-badge-dark' : ''}`}>{p} ))}
)} @@ -720,7 +720,7 @@ export const ClusterGraph = ({ data, loading, groupBy, onGroupByChange, fileId, groupType: c.groupType, selected: selectedCluster?.id === c.id, colorMode, - trafficRatio: c.totalBytes / MAX_NODE_BYTES, + maxBytes: MAX_NODE_BYTES, } satisfies IntelClusterNodeData, })); diff --git a/frontend/src/components/intelligence/ClusterGraph/CountryMapView.tsx b/frontend/src/components/intelligence/ClusterGraph/CountryMapView.tsx index 09a57828..fc024fa4 100644 --- a/frontend/src/components/intelligence/ClusterGraph/CountryMapView.tsx +++ b/frontend/src/components/intelligence/ClusterGraph/CountryMapView.tsx @@ -11,19 +11,13 @@ import { Button } from '@govtechsg/sgds-react'; import type { ClusterGraphResponse, ClusterNode } from '@/features/intelligence/services/intelligenceService'; import { intelligenceService } from '@/features/intelligence/services/intelligenceService'; import { formatBytes } from '@/utils/formatters'; +import { makeVolumeColor } from '@/utils/volumeColor'; +import { useResolvedDark } from '@/utils/useResolvedDark'; import worldTopojson from 'virtual:world-map'; import centroids from '@/assets/geo/country-centroids.json'; const CENTROID_MAP = centroids as unknown as Record; -// ── Color helpers ────────────────────────────────────────────────────────────── -function trafficColor(ratio: number): string { - const r = Math.round(208 - ratio * (208 - 21)); - const g = Math.round(228 - ratio * (228 - 101)); - const b = Math.round(247 - ratio * (247 - 192)); - return `rgb(${r},${g},${b})`; -} - type ColorMode = 'risk' | 'traffic'; // ── Zoom levels tuned per country size ──────────────────────────────────────── @@ -67,6 +61,7 @@ export function CountryMapView({ const [cityLoading, setCityLoading] = useState(false); const [zoom, setZoom] = useState(1); const [center, setCenter] = useState<[number, number]>([0, 20]); + const dark = useResolvedDark(); const MAX_BYTES = useMemo(() => Math.max(...data.clusters.map(c => c.totalBytes), 1), [data.clusters]); const clusterById = useMemo(() => new Map(data.clusters.map(c => [c.id, c])), [data.clusters]); @@ -127,13 +122,13 @@ export function CountryMapView({ if (!cluster) return hovered ? '#dee2e6' : '#e9ecef'; if (cc === drilledCC) return '#bbd4f7'; const base = colorMode === 'traffic' - ? trafficColor(cluster.totalBytes / MAX_BYTES) + ? makeVolumeColor(MAX_BYTES, dark)(cluster.totalBytes) : cluster.riskCount > 0 ? '#fce8e6' : '#d6e4f7'; return hovered ? base : base; } function markerFill(cluster: ClusterNode, maxBytes: number): string { - if (colorMode === 'traffic') return trafficColor(cluster.totalBytes / maxBytes); + if (colorMode === 'traffic') return makeVolumeColor(maxBytes, dark)(cluster.totalBytes); return cluster.riskCount > 0 ? '#e74c3c' : '#1a73e8'; } diff --git a/frontend/src/components/monitor/MonitorNetworkDiagram/MonitorNetworkDiagram.tsx b/frontend/src/components/monitor/MonitorNetworkDiagram/MonitorNetworkDiagram.tsx deleted file mode 100644 index fa5b752a..00000000 --- a/frontend/src/components/monitor/MonitorNetworkDiagram/MonitorNetworkDiagram.tsx +++ /dev/null @@ -1,587 +0,0 @@ -import { Spinner } from '@components/common/Spinner/Spinner'; -import { useMemo, useState, useEffect } from 'react'; -import { Button, Form, Modal } from '@govtechsg/sgds-react'; -import type { NetworkSnapshot, ChangeEvent } from '@/features/monitor/types/monitor.types'; -import type { NodeHighlight } from '@/components/network/NetworkGraph/NetworkGraph'; -import { NetworkGraph } from '@/components/network/NetworkGraph'; -import { NetworkControls } from '@/components/network/NetworkControls'; -import { NodeDetails } from '@/components/network/NodeDetails'; -import { NodeLabelSettingsModal } from '@/components/network/NodeLabelSettingsModal'; -import type { GraphNode } from '@/features/network/types'; -import { useNetworkData } from '@/features/network/hooks/useNetworkData'; -import { toggleSet } from '@/features/network/constants'; -import { edgeMatchesLegendKey } from '@/features/network/services/networkService'; -import { parseDateTime } from '@/utils/dateUtils'; - -interface MonitorNetworkDiagramProps { - show: boolean; - onHide: () => void; - initialSnapshotId: string; - snapshots: NetworkSnapshot[]; - changeEvents: ChangeEvent[]; -} - -const HIGHLIGHT_COLORS: Record = { - CRITICAL: '#e74c3c', - WARNING: '#f39c12', - INFO: '#2ecc71', -}; - -function labelForChange( - changeType: string, - oldValue: Record | null, - newValue: Record | null, -): string { - switch (changeType) { - case 'MAC_ADDED': return 'New device'; - case 'IP_MAC_DRIFT': - return (oldValue?.['mac'] && newValue?.['mac'] && oldValue['mac'] !== newValue['mac']) ? 'Potential ARP spoof' : 'IP reassignment'; - case 'GATEWAY_CHANGE': return 'Gateway changed'; - case 'ASN_CHANGE': return 'New ISP'; - case 'PROTOCOL_ADDED': return 'New protocol'; - case 'APP_ADDED': return 'New app'; - case 'VPN_DRIFT': return newValue?.['riskType'] ? 'VPN detected' : 'VPN stopped'; - default: return changeType; - } -} - -function severityRank(s: string): number { - return s === 'CRITICAL' ? 3 : s === 'WARNING' ? 2 : 1; -} - -function buildHighlightMap(events: ChangeEvent[], toSnapshotId: string): Map { - const map = new Map(); - for (const e of events.filter(ev => ev.toSnapshotId === toSnapshotId)) { - const color = HIGHLIGHT_COLORS[e.severity] ?? HIGHLIGHT_COLORS.INFO; - const label = labelForChange(e.changeType, e.oldValue, e.newValue); - const addHl = (key: string, description?: string) => { - const existing = map.get(key); - if (!existing || severityRank(e.severity) > severityRank(existing.label)) { - map.set(key, { color, label, description }); - } - }; - switch (e.changeType) { - case 'MAC_ADDED': { - const ip = e.newValue?.['ip'] as string | undefined; - addHl(e.entityKey, `New device appeared${ip ? ` at ${ip}` : ''}`); - if (ip) addHl(ip, `New device with MAC ${e.entityKey}`); - break; - } - case 'IP_MAC_DRIFT': { - const oldMac = e.oldValue?.['mac'] as string | undefined; - const newMac = e.newValue?.['mac'] as string | undefined; - const oldIp = e.oldValue?.['ip'] as string | undefined; - const newIp = e.newValue?.['ip'] as string | undefined; - if (oldMac && newMac && oldMac !== newMac) { - addHl(e.entityKey, `MAC changed from ${oldMac} to ${newMac}`); - addHl(newMac, `Now claiming IP ${e.entityKey} (was ${oldMac})`); - } else { - addHl(e.entityKey, `IP changed from ${oldIp ?? '?'} to ${newIp ?? '?'}`); - if (newIp) addHl(newIp, `MAC ${e.entityKey} moved here from ${oldIp ?? '?'}`); - } - break; - } - case 'GATEWAY_CHANGE': { - const newIp = e.newValue?.['ip'] as string | undefined; - const oldIp = e.oldValue?.['ip'] as string | undefined; - if (newIp) addHl(newIp, `New gateway (was ${oldIp ?? '?'})`); - if (oldIp) addHl(oldIp, `Previous gateway (replaced by ${newIp ?? '?'})`); - break; - } - default: - break; - } - } - return map; -} - -function formatSnapLabel(snap: NetworkSnapshot): string { - if (snap.startTime) { - const ms = parseDateTime(snap.startTime as unknown as string | number[]); - return new Date(ms).toLocaleDateString('en-GB', { month: 'short', day: 'numeric', year: 'numeric' }); - } - return snap.fileName; -} - -export const MonitorNetworkDiagram = ({ - show, - onHide, - initialSnapshotId, - snapshots, - changeEvents, -}: MonitorNetworkDiagramProps) => { - const sorted = useMemo( - () => [...snapshots].sort((a, b) => a.snapshotOrder - b.snapshotOrder), - [snapshots], - ); - - const [selectedId, setSelectedId] = useState(initialSnapshotId); - const [layoutType, setLayoutType] = useState<'circular' | 'hierarchicalTd'>('circular'); - const [selectedNode, setSelectedNode] = useState(null); - const [isFullscreen, setIsFullscreen] = useState(false); - const [showFilterModal, setShowFilterModal] = useState(false); - const [showLabelModal, setShowLabelModal] = useState(false); - - // ── Filter state ────────────────────────────────────────────────────────── - const [ipFilter, setIpFilter] = useState(''); - const [portFilter, setPortFilter] = useState(''); - const [hasRisksOnly, setHasRisksOnly] = useState(false); - const [activeLegendProtocols, setActiveLegendProtocols] = useState([]); - const [activeNodeFilters, setActiveNodeFilters] = useState([]); - const [activeAppFilters, setActiveAppFilters] = useState([]); - const [activeL7Protocols, setActiveL7Protocols] = useState([]); - const [activeCategories, setActiveCategories] = useState([]); - const [activeRiskTypes, setActiveRiskTypes] = useState([]); - const [activeCustomSigs, setActiveCustomSigs] = useState([]); - const [activeFileTypes, setActiveFileTypes] = useState([]); - const [activeCountries, setActiveCountries] = useState([]); - - const toggleLegendProtocol = toggleSet(setActiveLegendProtocols); - const toggleNodeFilter = toggleSet(setActiveNodeFilters); - const toggleAppFilter = toggleSet(setActiveAppFilters); - const toggleL7Protocol = toggleSet(setActiveL7Protocols); - const toggleCategory = toggleSet(setActiveCategories); - const toggleRiskType = toggleSet(setActiveRiskTypes); - const toggleCustomSig = toggleSet(setActiveCustomSigs); - const toggleFileType = toggleSet(setActiveFileTypes); - const toggleCountry = toggleSet(setActiveCountries); - - const clearAllFilters = () => { - setActiveLegendProtocols([]); - setActiveNodeFilters([]); - setIpFilter(''); - setPortFilter(''); - setActiveAppFilters([]); - setActiveL7Protocols([]); - setActiveCategories([]); - setActiveRiskTypes([]); - setActiveCustomSigs([]); - setActiveFileTypes([]); - setActiveCountries([]); - setHasRisksOnly(false); - }; - - // Sync to the clicked snapshot each time the modal opens; reset UI state - useEffect(() => { - if (!show) { - setSelectedNode(null); - setShowFilterModal(false); - setShowLabelModal(false); - return; - } - if (initialSnapshotId) { - setSelectedId(initialSnapshotId); - setSelectedNode(null); - setIsFullscreen(false); - } - }, [show, initialSnapshotId]); - - const selectedIndex = sorted.findIndex(s => s.id === selectedId); - - // Arrow key navigation between snapshots (only when NodeDetails is not open) - useEffect(() => { - if (!show) return; - const onKeyDown = (e: KeyboardEvent) => { - if (selectedNode) return; - if (e.key === 'ArrowLeft' && selectedIndex > 0) { - e.preventDefault(); - setSelectedId(sorted[selectedIndex - 1].id); - } else if (e.key === 'ArrowRight' && selectedIndex < sorted.length - 1) { - e.preventDefault(); - setSelectedId(sorted[selectedIndex + 1].id); - } - }; - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [show, selectedIndex, sorted, selectedNode]); - - const selectedSnap = sorted[selectedIndex] ?? sorted[sorted.length - 1] ?? null; - const prevSnap = selectedIndex > 0 ? sorted[selectedIndex - 1] : null; - - const highlightedNodes = useMemo(() => { - if (!selectedSnap) return undefined; - return buildHighlightMap(changeEvents, selectedSnap.id); - }, [changeEvents, selectedSnap]); - - const { nodes, edges, loading } = useNetworkData(selectedSnap?.fileId ?? ''); - - // ── "Present" sets ──────────────────────────────────────────────────────── - - const presentNodeTypes = useMemo(() => { - const s = new Set(); - nodes.forEach(n => s.add(n.data.nodeType)); - return s; - }, [nodes]); - - const presentDeviceTypes = useMemo(() => { - const s = new Set(); - nodes.forEach(n => { if (n.data.deviceType) s.add(n.data.deviceType); }); - return s; - }, [nodes]); - - const presentEdgeLegendKeys = useMemo(() => { - const s = new Set(); - edges.forEach(edge => { - const proto = edge.data.protocol.toUpperCase(); - const app = (edge.data.appName ?? '').toUpperCase(); - ['HTTP', 'HTTPS', 'DNS', 'TCP', 'UDP', 'ICMP', 'ARP', 'STP', 'LLDP', 'CDP', 'EAPOL'].forEach( - key => { if (edgeMatchesLegendKey(proto, app, key)) s.add(key); } - ); - }); - return s; - }, [edges]); - - const presentAppNames = useMemo(() => { - const s = new Set(); - edges.forEach(e => { if (e.data.appName) s.add(e.data.appName); }); - return [...s].sort(); - }, [edges]); - - const presentL7Protocols = useMemo(() => { - const s = new Set(); - edges.forEach(e => { if (e.data.l7Protocol) s.add(e.data.l7Protocol); }); - return [...s].sort(); - }, [edges]); - - const presentCategories = useMemo(() => { - const s = new Set(); - edges.forEach(e => { if (e.data.category) s.add(e.data.category); }); - return [...s].sort(); - }, [edges]); - - const presentRiskTypes = useMemo(() => { - const s = new Set(); - edges.forEach(e => e.data.flowRisks?.forEach(r => s.add(r))); - return [...s].sort(); - }, [edges]); - - const presentCustomSigs = useMemo(() => { - const s = new Set(); - edges.forEach(e => e.data.customSignatures?.forEach(sig => s.add(sig))); - return [...s].sort(); - }, [edges]); - - const presentFileTypes = useMemo(() => { - const s = new Set(); - edges.forEach(e => e.data.detectedFileTypes?.forEach(f => s.add(f))); - return [...s].sort(); - }, [edges]); - - const presentCountries = useMemo(() => { - const s = new Set(); - edges.forEach(e => { - if (e.data.srcCountry) s.add(e.data.srcCountry); - if (e.data.dstCountry) s.add(e.data.dstCountry); - }); - return [...s].sort(); - }, [edges]); - - // ── Filter logic ────────────────────────────────────────────────────────── - - const { filteredNodes, filteredEdges } = useMemo(() => { - let filtered = edges; - - if (hasRisksOnly) filtered = filtered.filter(e => e.data.hasRisks); - if (activeLegendProtocols.length > 0) { - filtered = filtered.filter(edge => { - const proto = edge.data.protocol.toUpperCase(); - const app = (edge.data.appName ?? '').toUpperCase(); - return activeLegendProtocols.some(key => edgeMatchesLegendKey(proto, app, key)); - }); - } - if (activeAppFilters.length > 0) - filtered = filtered.filter(e => activeAppFilters.includes(e.data.appName ?? '')); - if (activeL7Protocols.length > 0) - filtered = filtered.filter(e => activeL7Protocols.includes(e.data.l7Protocol ?? '')); - if (activeCategories.length > 0) - filtered = filtered.filter(e => activeCategories.includes(e.data.category ?? '')); - if (activeRiskTypes.length > 0) - filtered = filtered.filter(e => activeRiskTypes.some(r => e.data.flowRisks?.includes(r))); - if (activeCustomSigs.length > 0) - filtered = filtered.filter(e => activeCustomSigs.some(s => e.data.customSignatures?.includes(s))); - if (activeFileTypes.length > 0) - filtered = filtered.filter(e => activeFileTypes.some(f => e.data.detectedFileTypes?.includes(f))); - if (activeCountries.length > 0) - filtered = filtered.filter(e => - activeCountries.includes(e.data.srcCountry ?? '') || - activeCountries.includes(e.data.dstCountry ?? '') - ); - if (activeNodeFilters.length > 0) { - const matchingIds = new Set( - nodes - .filter(n => - activeNodeFilters.some(key => { - if (key.startsWith('nt:')) return n.data.nodeType === key.slice(3); - if (key.startsWith('dt:')) return n.data.deviceType === key.slice(3); - return false; - }) - ) - .map(n => n.id) - ); - filtered = filtered.filter(e => matchingIds.has(e.source) || matchingIds.has(e.target)); - } - if (portFilter) { - const portNum = parseInt(portFilter, 10); - if (!isNaN(portNum)) - filtered = filtered.filter(e => e.data.srcPort === portNum || e.data.dstPort === portNum); - } - - const hasActiveFilters = - activeLegendProtocols.length > 0 || activeNodeFilters.length > 0 || - activeAppFilters.length > 0 || activeL7Protocols.length > 0 || - activeCategories.length > 0 || activeRiskTypes.length > 0 || - activeCustomSigs.length > 0 || activeFileTypes.length > 0 || - activeCountries.length > 0 || hasRisksOnly || - ipFilter.length > 0 || portFilter.length > 0; - - const ipLower = ipFilter.toLowerCase(); - let visibleNodes = nodes; - if (ipFilter) { - visibleNodes = nodes.filter( - n => - n.data.ip.toLowerCase().includes(ipLower) || - (n.data.hostname ?? '').toLowerCase().includes(ipLower) - ); - } - - if (hasActiveFilters) { - const matchedByIp = new Set(visibleNodes.map(n => n.id)); - if (ipFilter) - filtered = filtered.filter(e => matchedByIp.has(e.source) || matchedByIp.has(e.target)); - const visibleNodeIds = new Set(); - filtered.forEach(e => { visibleNodeIds.add(e.source); visibleNodeIds.add(e.target); }); - visibleNodes = nodes.filter(n => visibleNodeIds.has(n.id) || (ipFilter && matchedByIp.has(n.id))); - } - - return { filteredNodes: visibleNodes, filteredEdges: filtered }; - }, [ - nodes, edges, activeLegendProtocols, activeNodeFilters, activeAppFilters, - activeL7Protocols, activeCategories, activeRiskTypes, activeCustomSigs, - activeFileTypes, activeCountries, hasRisksOnly, ipFilter, portFilter, - ]); - - const activeFilterCount = - activeLegendProtocols.length + activeNodeFilters.length + activeAppFilters.length + - activeL7Protocols.length + activeCategories.length + activeRiskTypes.length + - activeCustomSigs.length + activeFileTypes.length + activeCountries.length + - (ipFilter ? 1 : 0) + (portFilter ? 1 : 0) + (hasRisksOnly ? 1 : 0); - - // ── Change legend ───────────────────────────────────────────────────────── - - const legendItems = useMemo(() => { - if (!highlightedNodes) return []; - const seen = new Map(); - for (const hl of highlightedNodes.values()) { - if (!seen.has(hl.label)) seen.set(hl.label, hl.color); - } - return [...seen.entries()].map(([label, color]) => ({ label, color })); - }, [highlightedNodes]); - - return ( - <> - - - - - Network Diagram — {selectedSnap?.fileName ?? ''} - - - - - - - {/* Snapshot selector + comparison info */} -
-
- - setSelectedId(e.target.value)} - > - {sorted.map((snap, i) => ( - - ))} - - -
- - {prevSnap ? ( - - - Changes vs {formatSnapLabel(prevSnap)} - - ) : ( - Baseline — no previous snapshot to compare - )} - - {legendItems.length > 0 && ( -
- {legendItems.map(item => ( - - - {item.label} - - ))} -
- )} -
- - {/* Graph */} -
- {loading ? ( -
- - Loading graph… -
- ) : ( - setSelectedNode(node)} - onFilterClick={() => setShowFilterModal(true)} - activeFilterCount={activeFilterCount} - /> - )} -
- - {highlightedNodes && highlightedNodes.size === 0 && !loading && prevSnap && ( -
- - No node-level changes detected between these two snapshots. -
- )} -
-
- - {selectedNode && selectedSnap && ( - setSelectedNode(null)} - changeHighlight={highlightedNodes?.get(selectedNode.label ?? '') ?? highlightedNodes?.get(selectedNode.data.ip ?? '') ?? highlightedNodes?.get(selectedNode.data.mac ?? '')} - zIndex={1070} - /> - )} - - {/* Filter modal — rendered outside the main modal so it stacks on top */} - setShowFilterModal(false)} size="lg"> - - Filters - - - setActiveLegendProtocols([])} - presentEdgeLegendKeys={presentEdgeLegendKeys} - activeNodeFilters={activeNodeFilters} - onNodeFilterClick={toggleNodeFilter} - onNodeFilterClear={() => setActiveNodeFilters([])} - presentNodeTypes={presentNodeTypes} - presentDeviceTypes={presentDeviceTypes} - ipFilter={ipFilter} - onIpFilterChange={setIpFilter} - portFilter={portFilter} - onPortFilterChange={setPortFilter} - activeAppFilters={activeAppFilters} - onAppFilterClick={toggleAppFilter} - onAppFilterClear={() => setActiveAppFilters([])} - presentAppNames={presentAppNames} - activeL7Protocols={activeL7Protocols} - onL7ProtocolClick={toggleL7Protocol} - onL7ProtocolClear={() => setActiveL7Protocols([])} - presentL7Protocols={presentL7Protocols} - activeCategories={activeCategories} - onCategoryClick={toggleCategory} - onCategoryClear={() => setActiveCategories([])} - presentCategories={presentCategories} - activeRiskTypes={activeRiskTypes} - onRiskTypeClick={toggleRiskType} - onRiskTypeClear={() => setActiveRiskTypes([])} - presentRiskTypes={presentRiskTypes} - activeCustomSigs={activeCustomSigs} - onCustomSigClick={toggleCustomSig} - onCustomSigClear={() => setActiveCustomSigs([])} - presentCustomSigs={presentCustomSigs} - activeFileTypes={activeFileTypes} - onFileTypeClick={toggleFileType} - onFileTypeClear={() => setActiveFileTypes([])} - presentFileTypes={presentFileTypes} - activeCountries={activeCountries} - onCountryClick={toggleCountry} - onCountryClear={() => setActiveCountries([])} - presentCountries={presentCountries} - hasRisksOnly={hasRisksOnly} - onHasRisksOnlyChange={setHasRisksOnly} - activeFilterCount={activeFilterCount} - onClearAllFilters={clearAllFilters} - defaultCollapsed={false} - /> - - - - - - - {/* Node-label settings — separate modal so it stacks above the diagram */} - setShowLabelModal(false)} /> - - ); -}; diff --git a/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx b/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx index 3d4ae331..a15de932 100644 --- a/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx +++ b/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx @@ -19,7 +19,12 @@ import { insightsService } from '@/features/insights/services/insightsService'; import type { NetworkSnapshot, ChangeEvent } from '@/features/monitor/types/monitor.types'; import type { NetworkInsight, InsightOptions } from '@/features/insights/types/insights.types'; import type { GraphNode } from '@/features/network/types'; -import type { NodeHighlight } from '@/components/network/NetworkGraph/NetworkGraph'; +import type { NodeHighlight, EdgeColorMode } from '@/components/network/NetworkGraph/NetworkGraph'; +import { edgeBytesRange } from '@/components/network/NetworkGraph/NetworkGraph'; +import { VolumeHeatmap } from '@/components/network/VolumeHeatmap'; +import type { VolumePair } from '@/components/network/VolumeHeatmap'; +import { VolumeLegend } from '@/components/network/VolumeLegend'; +import { useResolvedDark } from '@/utils/useResolvedDark'; import { parseDateTime } from '@/utils/dateUtils'; type Tab = 'diagram' | 'changes' | 'security' | 'context' | 'subnets' | 'insights'; @@ -134,6 +139,11 @@ export const SnapshotDetailModal = ({ const [selectedNode, setSelectedNode] = useState(null); const [showFilterModal, setShowFilterModal] = useState(false); const [showLabelModal, setShowLabelModal] = useState(false); + const isDark = useResolvedDark(); + const [edgeColorMode, setEdgeColorMode] = useState('protocol'); + const [showHeatmap, setShowHeatmap] = useState(false); + const [isHeatmapFullscreen, setIsHeatmapFullscreen] = useState(false); + const [selectedPair, setSelectedPair] = useState(null); // Filter state const [ipFilter, setIpFilter] = useState(''); @@ -245,6 +255,10 @@ export const SnapshotDetailModal = ({ }), [nodes, edges, hasRisksOnly, activeLegendProtocols, activeAppFilters, activeL7Protocols, activeCategories, activeRiskTypes, activeCustomSigs, activeFileTypes, activeCountries, activeNodeFilters, portFilter, ipFilter]); + // Volume range of the current view, from the same helper the graph paints with, + // so this snapshot's legend and edges always share a domain. + const edgeRange = useMemo(() => edgeBytesRange(filteredEdges), [filteredEdges]); + const activeFilterCount = activeLegendProtocols.length + activeNodeFilters.length + activeAppFilters.length + activeL7Protocols.length + activeCategories.length + activeRiskTypes.length + @@ -255,15 +269,32 @@ export const SnapshotDetailModal = ({ useEffect(() => { if (activeTab !== 'diagram') return; const handler = (e: KeyboardEvent) => { + // Heatmap fullscreen owns Escape while it is open — without this the key + // falls through to the dialog and closes the whole thing instead of just + // leaving fullscreen. Capture phase, so it runs before the modal's handler. + if (e.key === 'Escape' && isHeatmapFullscreen) { + e.preventDefault(); + e.stopPropagation(); + setIsHeatmapFullscreen(false); + return; + } + // Snapshot stepping would be disorienting while the matrix is fullscreen. + if (isHeatmapFullscreen) return; if (e.key === 'ArrowLeft' && diagramIndex > 0) { setDiagramSnapshotId(sorted[diagramIndex - 1].id); } else if (e.key === 'ArrowRight' && diagramIndex < sorted.length - 1) { setDiagramSnapshotId(sorted[diagramIndex + 1].id); } }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [activeTab, diagramIndex, sorted]); + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [activeTab, diagramIndex, sorted, isHeatmapFullscreen]); + + // A fullscreen matrix is position:fixed outside the dialog's stacking context, + // so it must not outlive a tab change that hides the diagram beneath it. + useEffect(() => { + if (activeTab !== 'diagram') setIsHeatmapFullscreen(false); + }, [activeTab]); // Context tab state const [contextDraft, setContextDraft] = useState(snapshot.context ?? ''); @@ -513,6 +544,28 @@ export const SnapshotDetailModal = ({ +
+ Color edges by + setEdgeColorMode(e.target.value as EdgeColorMode)} + > + + + + {edgeColorMode === 'volume' && ( + + )} +
+ {prevSnap ? ( @@ -557,6 +610,7 @@ export const SnapshotDetailModal = ({ onNodeClick={node => setSelectedNode(node)} onFilterClick={() => setShowFilterModal(true)} activeFilterCount={activeFilterCount} + edgeColorMode={edgeColorMode} /> )} @@ -566,6 +620,73 @@ export const SnapshotDetailModal = ({ No node-level changes detected between these two snapshots. )} + + {/* Node-to-node volume for this snapshot. Collapsed by default — the + dialog is already dense and the diagram is the primary view here. + Unlike the analysis page this does not drive `highlightedNodes`, + which in monitor mode already carries snapshot-change highlighting. */} + {!graphLoading && ( +
+
+ + Node-to-Node Volume + + Traffic between each pair of displayed hosts + + +
+ {showHeatmap && ( + + )} + +
+
+ {showHeatmap && ( +
+ +
+ )} +
+ )} )} diff --git a/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx b/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx index 86ac957f..958af636 100644 --- a/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx +++ b/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx @@ -8,6 +8,7 @@ import circular from 'graphology-layout/circular'; import noverlap from 'graphology-layout-noverlap'; import type { GraphNode, GraphEdge } from '@/features/network/types'; import { getProtocolColor, NODE_TYPE_CONFIG } from '@/features/network/constants'; +import { makeVolumeEdgeColor } from '@/utils/volumeColor'; import { deviceTypeIcon, deviceTypeLabel, DEVICE_TYPES } from '@/utils/deviceType'; import { useStore } from '@/store'; import type { NodeLabelConfig } from '@/store/slices/nodeLabelSlice'; @@ -23,6 +24,8 @@ export interface NodeHighlight { description?: string; } +export type EdgeColorMode = 'protocol' | 'volume'; + interface NetworkGraphProps { nodes: GraphNode[]; edges: GraphEdge[]; @@ -38,6 +41,18 @@ interface NetworkGraphProps { activeFilterCount?: number; /** Monitor mode: map of node label (IP/MAC) → highlight colour + badge text */ highlightedNodes?: Map; + /** + * What edge colour encodes. Protocol (the default) uses `getProtocolColor`; + * volume shades each edge by `totalBytes` on the shared volume scale — the same + * ramp the node-to-node heatmap uses. Note an edge is one protocol/app between a + * pair (see `deduplicateEdges`), while a heatmap cell is the pair's total across + * all protocols, so the two agree exactly only for single-protocol pairs. + * + *

These are mutually exclusive by design: colour is one channel, and showing + * two meanings at once would make neither legible. Whichever is active needs a + * legend rendered by the caller. + */ + edgeColorMode?: EdgeColorMode; /** * Render light regardless of the user's theme — for PDF capture, which always wants white * diagrams (see captureNetworkDiagrams.ts). @@ -299,6 +314,24 @@ function deduplicateEdges(edges: GraphEdge[]): GraphEdge[] { return result; } + +/** + * Range of positive edge volumes, used to fit the volume colour scale. + * Exported so a caller rendering the legend derives the exact same domain the + * edges are painted with — a legend fitted to a different range would lie. + */ +export function edgeBytesRange(edges: GraphEdge[]): { min: number; max: number } { + let max = 0; + let min = Infinity; + for (const e of edges) { + const b = e.data.totalBytes ?? 0; + if (b <= 0) continue; + if (b > max) max = b; + if (b < min) min = b; + } + return { min: Number.isFinite(min) ? min : 1, max }; +} + // --------------------------------------------------------------------------- // Build a graphology graph from GraphNode[] / GraphEdge[] // --------------------------------------------------------------------------- @@ -307,6 +340,8 @@ function buildGraph( nodes: GraphNode[], edges: GraphEdge[], primarySource?: string, + edgeColorMode: EdgeColorMode = 'protocol', + darkMode = false, ): Graph { const graph = new Graph({ multi: true, type: 'directed' }); @@ -342,20 +377,31 @@ function buildGraph( }); } + // Fitting the scale to the current view's range means it re-fits whenever + // filters change, so contrast is never wasted on values nothing reaches. + const { min: minEdgeBytes, max: maxEdgeBytes } = edgeBytesRange(validEdges); + const volumeColor = makeVolumeEdgeColor(maxEdgeBytes, darkMode, minEdgeBytes); + for (const e of validEdges) { - const color = getProtocolColor(e.data.protocol); const isSecondaryOnly = e.data.sources?.length === 1 && primarySource !== undefined && e.data.sources[0] !== primarySource; graph.addEdgeWithKey(e.id, e.source, e.target, { - color, + color: + edgeColorMode === 'volume' + ? volumeColor(e.data.totalBytes ?? 0) + : getProtocolColor(e.data.protocol), size: 1.2, label: e.label, type: 'arrow', isSecondaryOnly, packetCount: e.data.packetCount, + // Kept on the edge so the colour mode can be switched by repainting in + // place, without rebuilding the graph and throwing away the layout. + totalBytes: e.data.totalBytes ?? 0, + protocol: e.data.protocol, }); } @@ -417,6 +463,7 @@ export const NetworkGraph = memo(function NetworkGraph({ onFilterClick, activeFilterCount = 0, highlightedNodes, + edgeColorMode = 'protocol', forceLight = false, }: NetworkGraphProps) { const themeMode = useStore(s => s.themeMode); @@ -528,7 +575,7 @@ export const NetworkGraph = memo(function NetworkGraph({ sigmaRef.current?.kill(); sigmaRef.current = null; - const graph = buildGraph(nodes, edges, primarySource); + const graph = buildGraph(nodes, edges, primarySource, edgeColorMode, darkMode); graphRef.current = graph; // Seed the per-node text lines before the first paint. Read config fresh so a @@ -822,6 +869,46 @@ export const NetworkGraph = memo(function NetworkGraph({ sigmaRef.current?.refresh(); }, [hoveredNode]); + // Repaint edges when the colour encoding changes. + // + // Deliberately not a dependency of the graph-building effect above: switching + // Protocol↔Volume must not rebuild the graph, because that would re-run the + // layout and scramble positions the user has been reading. Everything needed + // (protocol, totalBytes) is already on each edge, so this is a pure repaint. + // + // darkMode is absent from the deps for the opposite reason — a theme change + // *does* rebuild via the effect above, which repaints with the correct ramp. + useEffect(() => { + const graph = graphRef.current; + if (!graph) return; + + let maxEdgeBytes = 0; + let minEdgeBytes = Infinity; + graph.forEachEdge((_key, attrs) => { + const b = (attrs.totalBytes as number) ?? 0; + if (b <= 0) return; + if (b > maxEdgeBytes) maxEdgeBytes = b; + if (b < minEdgeBytes) minEdgeBytes = b; + }); + const volumeColor = makeVolumeEdgeColor( + maxEdgeBytes, + darkMode, + Number.isFinite(minEdgeBytes) ? minEdgeBytes : 1 + ); + + graph.forEachEdge((key, attrs) => { + graph.setEdgeAttribute( + key, + 'color', + edgeColorMode === 'volume' + ? volumeColor((attrs.totalBytes as number) ?? 0) + : getProtocolColor((attrs.protocol as string) ?? '') + ); + }); + sigmaRef.current?.refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [edgeColorMode]); + // Fit view const handleFitView = useCallback(() => { sigmaRef.current?.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 300 }); diff --git a/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css new file mode 100644 index 00000000..94db5cfa --- /dev/null +++ b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css @@ -0,0 +1,132 @@ +.tp-heatmap { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tp-heatmap-toolbar { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.tp-heatmap-clear { + text-decoration: none; +} + +/* The grid is fitted to this box's width, so it should not normally scroll. The + overflow is a safety net for narrow containers, and this is also the element + the canvas measures itself against. */ +.tp-heatmap-scroll { + overflow: auto; + border: 1px solid var(--tp-border, #dee2e6); + border-radius: 4px; +} + +/* Positioning context for the hover tooltip. */ +.tp-heatmap-canvas-wrap { + position: relative; + display: inline-block; + line-height: 0; +} + +.tp-heatmap-canvas { + display: block; +} + +.tp-heatmap-tooltip { + position: absolute; + z-index: 5; + pointer-events: none; + background: var(--tp-surface, #fff); + color: var(--tp-text, #212529); + border: 1px solid var(--tp-border, #dee2e6); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); + padding: 4px 8px; + font-size: 0.7rem; + line-height: 1.4; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.tp-heatmap-tooltip-muted { + color: var(--tp-text-muted, #6c757d); +} + +.tp-heatmap-readout { + min-height: 1.2em; + font-variant-numeric: tabular-nums; +} + +.tp-heatmap-empty { + padding: 1.5rem; + text-align: center; +} + +/* ── Fullscreen ──────────────────────────────────────────────── + Two entry points share these rules: + .nd-css-fullscreen — the card treatment used by the + topology diagram (analysis page). + .tp-heatmap-fullscreen-over-modal — used from the monitor dialog. The + shared card class sits at z-index + 1040, below Bootstrap's modal (~1055), + so fullscreening from inside the + dialog would render *behind* it. This + variant stacks above instead. + In both, the toolbar and readout keep their natural height and the matrix + takes the rest, so the legend stays visible while a large grid is on screen. */ +.tp-heatmap-fullscreen-over-modal { + position: fixed; + inset: 0; + z-index: 1080; + border-radius: 0; + display: flex; + flex-direction: column; + background: var(--tp-surface, #fff); + margin: 0; + padding: 1rem 1.25rem; + overflow: hidden; +} + +.nd-css-fullscreen .tp-heatmap-body, +.tp-heatmap-fullscreen-over-modal .tp-heatmap-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.nd-css-fullscreen .tp-heatmap, +.tp-heatmap-fullscreen-over-modal .tp-heatmap { + flex: 1; + min-height: 0; +} + +.nd-css-fullscreen .tp-heatmap-scroll, +.tp-heatmap-fullscreen-over-modal .tp-heatmap-scroll { + flex: 1; + min-height: 0; +} + +.tp-heatmap-zoom { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.tp-heatmap-zoom .btn { + --sgds-btn-padding-y: 0; + --sgds-btn-padding-x: 0.35rem; + line-height: 1.2; +} + +.tp-heatmap-zoom-value { + font-size: 0.7rem; + color: var(--tp-text-muted, #6c757d); + min-width: 34px; + text-align: center; + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.tsx b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.tsx new file mode 100644 index 00000000..a8743f89 Binary files /dev/null and b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.tsx differ diff --git a/frontend/src/components/network/VolumeHeatmap/heatmapLayout.ts b/frontend/src/components/network/VolumeHeatmap/heatmapLayout.ts new file mode 100644 index 00000000..f23fe28c --- /dev/null +++ b/frontend/src/components/network/VolumeHeatmap/heatmapLayout.ts @@ -0,0 +1,72 @@ +/** + * Grid geometry for the volume heatmap. + * + * Split out from the component so the sizing rules can be reasoned about (and + * exercised) on their own — the matrix is O(N²) and how a cell is sized is the + * difference between a readable grid and an unusable one. + */ + +/** + * Sanity ceiling on axis size. Not a rendering limit — the canvas copes with far + * more — but past this point a cell is well under a pixel and the matrix has + * stopped conveying anything, so there is no reason to keep going. + */ +export const MAX_AXIS_HOSTS = 1000; + +/** Cell size bounds, in CSS px. The grid is fitted to its container between these. */ +export const MIN_CELL = 2; +export const MAX_CELL = 22; +/** Below this cell size, axis labels are unreadable and are dropped. */ +export const LABEL_MIN_CELL = 9; + +/** Zoom stops, in px per cell. Fine at the bottom, where 1px matters most. */ +export const ZOOM_STEPS = [2, 3, 4, 6, 8, 10, 14, 18, 22]; + +export const LABEL_GUTTER = { x: 140, y: 96 }; +export const BARE_GUTTER = { x: 8, y: 8 }; + +export function clamp(v: number, lo: number, hi: number) { + return Math.max(lo, Math.min(hi, v)); +} + +export interface HeatmapLayout { + cellSize: number; + gutterX: number; + gutterY: number; + labels: boolean; +} + +/** + * Fits the grid to the available width, or honours an explicit zoom. + * + * Labels need a gutter, but the gutter competes with the grid for that width, so + * the fitted path resolves the circularity by trying the labelled layout first + * and falling back to a bare one when the resulting cells would be too small to + * label anyway. + */ +export function computeLayout(width: number, n: number, override?: number | null): HeatmapLayout { + // An explicit zoom wins over fitting: the user has asked for a specific cell + // size, and the container scrolls to accommodate it. + if (override != null) { + const cellSize = clamp(override, MIN_CELL, MAX_CELL); + const labels = cellSize >= LABEL_MIN_CELL; + return { + cellSize, + gutterX: labels ? LABEL_GUTTER.x : BARE_GUTTER.x, + gutterY: labels ? LABEL_GUTTER.y : BARE_GUTTER.y, + labels, + }; + } + + if (n === 0 || width <= 0) { + return { cellSize: MAX_CELL, gutterX: LABEL_GUTTER.x, gutterY: LABEL_GUTTER.y, labels: true }; + } + + const labelled = clamp(Math.floor((width - LABEL_GUTTER.x - 2) / n), MIN_CELL, MAX_CELL); + if (labelled >= LABEL_MIN_CELL) { + return { cellSize: labelled, gutterX: LABEL_GUTTER.x, gutterY: LABEL_GUTTER.y, labels: true }; + } + + const bare = clamp(Math.floor((width - BARE_GUTTER.x - 2) / n), MIN_CELL, MAX_CELL); + return { cellSize: bare, gutterX: BARE_GUTTER.x, gutterY: BARE_GUTTER.y, labels: false }; +} diff --git a/frontend/src/components/network/VolumeHeatmap/index.ts b/frontend/src/components/network/VolumeHeatmap/index.ts new file mode 100644 index 00000000..f1eb0439 --- /dev/null +++ b/frontend/src/components/network/VolumeHeatmap/index.ts @@ -0,0 +1,2 @@ +export { VolumeHeatmap } from './VolumeHeatmap'; +export type { VolumePair } from './VolumeHeatmap'; diff --git a/frontend/src/components/network/VolumeLegend/VolumeLegend.css b/frontend/src/components/network/VolumeLegend/VolumeLegend.css new file mode 100644 index 00000000..4ad8c31c --- /dev/null +++ b/frontend/src/components/network/VolumeLegend/VolumeLegend.css @@ -0,0 +1,35 @@ +.tp-volume-legend { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.75rem; + color: var(--tp-text-muted, #6c757d); + white-space: nowrap; +} + +.tp-volume-legend-label { + font-weight: 600; +} + +.tp-volume-legend-bar { + display: inline-block; + width: 88px; + height: 10px; + border-radius: 2px; + border: 1px solid var(--tp-border, #dee2e6); +} + +.tp-volume-legend-min, +.tp-volume-legend-max { + font-variant-numeric: tabular-nums; +} + +.tp-volume-legend-log { + padding: 0 0.25rem; + border: 1px solid var(--tp-border, #dee2e6); + border-radius: 2px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.02em; + cursor: help; +} diff --git a/frontend/src/components/network/VolumeLegend/VolumeLegend.tsx b/frontend/src/components/network/VolumeLegend/VolumeLegend.tsx new file mode 100644 index 00000000..090a48cf --- /dev/null +++ b/frontend/src/components/network/VolumeLegend/VolumeLegend.tsx @@ -0,0 +1,54 @@ +import { formatBytes } from '@/utils/formatters'; +import { volumeGradientCss } from '@/utils/volumeColor'; +import './VolumeLegend.css'; + +interface VolumeLegendProps { + /** Largest value on the current scale — the gradient's high end. */ + maxValue: number; + /** Smallest positive value on the current scale — the gradient's low end. */ + minValue?: number; + dark: boolean; + /** Which ramp this legend describes. Must match the marks it explains. */ + variant?: 'cell' | 'edge'; + label?: string; + className?: string; +} + +/** + * Gradient legend for the shared volume colour scale. + * + * Rendered next to both the heatmap and the volume-coloured diagram edges so the + * two are visibly reading from the same scale. The `variant` must match the marks + * being explained — the edge ramp is floored for visibility, so labelling edges + * with a cell gradient would misstate the mapping. + * + * The scale is logarithmic, which is called out in the tooltip: without that note + * a reader would reasonably assume the midpoint is half the maximum. + */ +export const VolumeLegend = ({ + maxValue, + minValue = 1, + dark, + variant = 'cell', + label = 'Volume', + className = '', +}: VolumeLegendProps) => ( +

+ {label} + {/* Labelled with the actual low end, not 0 — the ramp is fitted to the + observed range, so claiming it starts at zero would misstate the mapping. */} + {formatBytes(minValue)} +
+); diff --git a/frontend/src/components/network/VolumeLegend/index.ts b/frontend/src/components/network/VolumeLegend/index.ts new file mode 100644 index 00000000..cd44ed53 --- /dev/null +++ b/frontend/src/components/network/VolumeLegend/index.ts @@ -0,0 +1 @@ +export { VolumeLegend } from './VolumeLegend'; diff --git a/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx b/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx index c90b0b23..36166c98 100644 --- a/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx +++ b/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx @@ -12,6 +12,12 @@ import { buildActiveFilterLabels, toggleSet } from '@/features/network/constants import { edgeMatchesLegendKey } from '@/features/network/services/networkService'; import { formatBytes } from '@/utils/formatters'; import { NetworkGraph } from '@components/network/NetworkGraph'; +import type { EdgeColorMode, NodeHighlight } from '@components/network/NetworkGraph/NetworkGraph'; +import { edgeBytesRange } from '@components/network/NetworkGraph/NetworkGraph'; +import { VolumeHeatmap } from '@components/network/VolumeHeatmap'; +import type { VolumePair } from '@components/network/VolumeHeatmap'; +import { VolumeLegend } from '@components/network/VolumeLegend'; +import { useResolvedDark } from '@/utils/useResolvedDark'; import { NetworkControls } from '@components/network/NetworkControls'; import { NodeDetails } from '@components/network/NodeDetails'; import { NodeLabelSettingsModal } from '@components/network/NodeLabelSettingsModal'; @@ -82,6 +88,13 @@ export const NetworkDiagramPage = () => { 'circular' ); + const isDark = useResolvedDark(); + const [edgeColorMode, setEdgeColorMode] = useState('protocol'); + const [showHeatmap, setShowHeatmap] = useState(false); + const [isHeatmapFullscreen, setIsHeatmapFullscreen] = useState(false); + const heatmapCardRef = useRef(null); + const [selectedPair, setSelectedPair] = useState(null); + const graphCardRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); @@ -104,6 +117,19 @@ export const NetworkDiagramPage = () => { return () => document.removeEventListener('keydown', handler); }, [isFullscreen, showFilterModal, showLabelModal, selectedNode]); + // Escape exits heatmap fullscreen. Separate from the diagram's handler above so + // the two fullscreens never fight over the same key press. + useEffect(() => { + if (!isHeatmapFullscreen) return; + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (selectedNode) { setSelectedNode(null); return; } + setIsHeatmapFullscreen(false); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isHeatmapFullscreen, selectedNode]); + // ─── "Present" sets: only show options that exist in the data ─────────────── const presentNodeTypes = useMemo(() => { @@ -361,6 +387,24 @@ export const NetworkDiagramPage = () => { (portFilter ? 1 : 0) + (hasRisksOnly ? 1 : 0); + // Volume range of the *current* view. Derived with the same helper the graph + // paints with, so the legend can never describe a different domain than the + // edges it explains. + const edgeRange = useMemo(() => edgeBytesRange(filteredEdges), [filteredEdges]); + + // Cross-filter: a selected heatmap cell highlights that pair in the diagram. + // `highlightedNodes` is keyed by node *label*, not id, so translate first. + const highlightedNodes = useMemo(() => { + if (!selectedPair) return undefined; + const labelById = new Map(filteredNodes.map(n => [n.id, n.label])); + const map = new Map(); + const src = labelById.get(selectedPair.source); + const dst = labelById.get(selectedPair.target); + if (src) map.set(src, { color: '#0d6efd', label: 'src', description: 'Selected pair — source' }); + if (dst) map.set(dst, { color: '#0d6efd', label: 'dst', description: 'Selected pair — destination' }); + return map.size > 0 ? map : undefined; + }, [selectedPair, filteredNodes]); + // Keep the parent's ref up to date with the currently visible graph state so // the report button captures exactly what the user sees. useEffect(() => { @@ -530,7 +574,31 @@ export const NetworkDiagramPage = () => { Topology Diagram -
+
+ {/* Colour is a single channel — protocol and volume are offered as + alternatives rather than layered, and the active one is always + accompanied by its legend. */} +
+ Color edges by + setEdgeColorMode(e.target.value as EdgeColorMode)} + > + + + +
+ {edgeColorMode === 'volume' && ( + + )}
+ {/* Row 3: Node-to-node volume matrix. + The diagram answers "who talks to whom"; this answers "how much", which a + force-directed layout cannot show. Both read the same filtered edges. */} + + +
+ Node-to-Node Volume + + Traffic between each pair of displayed hosts + +
+
+ {showHeatmap && ( + + )} + +
+
+ {showHeatmap && ( + + + + )} +
+ {selectedNode && ( s.themeMode); + const [sysDark, setSysDark] = useState( + () => window.matchMedia('(prefers-color-scheme: dark)').matches + ); + + useEffect(() => { + if (themeMode !== 'system') return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e: MediaQueryListEvent) => setSysDark(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [themeMode]); + + if (themeMode === 'light') return false; + if (themeMode === 'dark') return true; + return sysDark; +} diff --git a/frontend/src/utils/volumeColor.ts b/frontend/src/utils/volumeColor.ts new file mode 100644 index 00000000..9013433d --- /dev/null +++ b/frontend/src/utils/volumeColor.ts @@ -0,0 +1,167 @@ +// Imported from the umbrella `d3` package (a declared dependency with @types/d3) +// rather than `d3-scale`/`d3-interpolate`, which are only present transitively. +// Vite tree-shakes the unused modules out of the bundle. +import { scaleSequentialLog, interpolateRgbBasis } from 'd3'; + +/** + * Shared sequential colour scale for traffic **volume** (magnitude). + * + * One hue (blue), light→dark, used everywhere volume is encoded as colour: + * the node-to-node heatmap, volume-coloured diagram edges, Network Intelligence + * cluster nodes and the country map. Previously each of those hand-rolled its own + * linear `trafficColor` — this replaces them so a given shade always means the + * same number across views. + * + * Two properties matter and neither was true of the old helper: + * + * 1. **Log, not linear.** Traffic volume is heavily skewed — a handful of pairs + * carry most of the bytes. A linear ramp yields one dark cell and a wash of + * pale ones. `scaleSequentialLog` spreads the mid-range out. + * 2. **Dark-mode anchors flip.** Magnitude reads as *distance from the surface*, + * so on a light surface "loud" is dark blue, and on a dark surface it is + * bright blue. A dark-mode ramp is not an inverted light ramp; it is its own. + * + * Ramps are validated against the light (#ffffff) and dark (#0d1117) chart + * surfaces for single-hue, monotone lightness and visible step gaps. + */ + +/** + * Cell ramps — for filled areas (heatmap cells, cluster node backgrounds). + * The end nearest the surface is deliberately allowed to recede: a near-empty + * cell should fade into the background rather than assert itself. Matrix + * structure is carried by the cell gaps, not by the palest fill. + */ +const CELL_RAMP_LIGHT = ['#cde2fb', '#9ec5f4', '#6da7ec', '#3987e5', '#256abf', '#184f95', '#0d366b']; +const CELL_RAMP_DARK = ['#10243d', '#173a63', '#1f5390', '#2a78d6', '#5598e7', '#86b6ef', '#cde2fb']; + +/** + * Edge ramps — for stroked marks (diagram edges). + * Floored so that even the quietest edge clears ~2:1 against the surface. An + * edge cannot be allowed to recede the way a cell can: a near-surface stroke on + * the graph canvas is simply an invisible connection, which would read as "no + * traffic" rather than "little traffic". + */ +const EDGE_RAMP_LIGHT = ['#86b6ef', '#5598e7', '#256abf', '#184f95', '#0d366b']; +const EDGE_RAMP_DARK = ['#184f95', '#2a78d6', '#5598e7', '#86b6ef', '#cde2fb']; + +export type VolumeColorFn = (value: number) => string; + +/** The colour used for a zero/absent value, per ramp. */ +function floorColor(ramp: string[]): string { + return ramp[0]; +} + +/** + * Normalises a domain to something a log scale can actually span. + * + * `minValue` should be the smallest *positive* value in view. Defaulting it to 1 + * would be wrong for real data: byte counts start in the hundreds, so a [1, max] + * domain spends the bottom half of the ramp on values that never occur and + * squeezes the real range into what is left — the same wasted-contrast problem + * the log scale exists to avoid. Fitting the domain to the observed range keeps + * the whole ramp meaningful. + */ +function domainOf(minValue: number, maxValue: number): [number, number] { + const lo = Math.max(1, Math.min(minValue, maxValue)); + // A degenerate domain (single distinct value) would make the scale divide by + // zero; widening it renders that value at the top of the ramp. + const hi = maxValue > lo ? maxValue : lo * 2; + return [lo, hi]; +} + +function makeScale(ramp: string[], minValue: number, maxValue: number): VolumeColorFn { + const interpolator = interpolateRgbBasis(ramp); + const scale = scaleSequentialLog(interpolator).domain(domainOf(minValue, maxValue)).clamp(true); + // Log scales cannot span 0, so absent/zero values are pinned to the floor + // colour rather than passed through the scale. + return (value: number) => (value > 0 ? scale(value) : floorColor(ramp)); +} + +/** + * Colour scale for filled areas (heatmap cells, cluster nodes). + * + * `maxValue`/`minValue` describe the current view — the scale is always relative + * to what is on screen, so re-filtering re-normalises. Pass `minValue` as the + * smallest positive value present to use the full ramp. + */ +export function makeVolumeColor(maxValue: number, dark: boolean, minValue = 1): VolumeColorFn { + return makeScale(dark ? CELL_RAMP_DARK : CELL_RAMP_LIGHT, minValue, maxValue); +} + +/** Colour scale for stroked marks (diagram edges) — floored to stay visible. */ +export function makeVolumeEdgeColor(maxValue: number, dark: boolean, minValue = 1): VolumeColorFn { + return makeScale(dark ? EDGE_RAMP_DARK : EDGE_RAMP_LIGHT, minValue, maxValue); +} + +/** + * Ink colour for text sitting *on top of* a volume-filled area. + * Replaces the `ratio > 0.55 ? '#fff' : '#212529'` heuristics that were inlined + * at each call site. `ratio` is 0–1 within the ramp, not a raw value. + */ +export function volumeTextColor(ratio: number, dark: boolean): string { + if (dark) { + // Dark ramp runs dark→light, so the *high* end is the pale one. + return ratio > 0.55 ? '#0d1117' : '#e6edf3'; + } + return ratio > 0.55 ? '#ffffff' : '#212529'; +} + +/** + * Where `value` sits along the ramp, 0–1, in the same log space the colour uses. + * + * Callers that need to react to "how dark is this fill" — picking ink, say — + * must use this rather than a plain `value / max`. A linear ratio disagrees with + * a log fill everywhere except the endpoints, which is exactly how you end up + * with dark text on a dark cell. + */ +export function volumeRatio(value: number, maxValue: number, minValue = 1): number { + if (value <= 0) return 0; + const [lo, hi] = domainOf(minValue, maxValue); + const r = (Math.log(value) - Math.log(lo)) / (Math.log(hi) - Math.log(lo)); + return Math.max(0, Math.min(1, r)); +} + +export interface VolumeLegendStop { + /** 0–1 position along the gradient. */ + offset: number; + color: string; +} + +/** + * Evenly spaced gradient stops describing a scale, for rendering a legend. + * Sampled in *log* space so the legend gradient matches what the marks actually + * do — a linear sample would misrepresent where the mid-tones land. + */ +export function volumeLegendStops( + maxValue: number, + dark: boolean, + variant: 'cell' | 'edge' = 'cell', + minValue = 1, + steps = 12, +): VolumeLegendStop[] { + const color = + variant === 'edge' + ? makeVolumeEdgeColor(maxValue, dark, minValue) + : makeVolumeColor(maxValue, dark, minValue); + const [lo, hi] = domainOf(minValue, maxValue); + + return Array.from({ length: steps }, (_, i) => { + const offset = i / (steps - 1); + // Invert the log scale: the value sitting this fraction along the ramp. + const value = Math.exp(Math.log(lo) + offset * (Math.log(hi) - Math.log(lo))); + return { offset, color: color(value) }; + }); +} + +/** CSS `linear-gradient(...)` string for a legend bar. */ +export function volumeGradientCss( + maxValue: number, + dark: boolean, + variant: 'cell' | 'edge' = 'cell', + minValue = 1, +): string { + const stops = volumeLegendStops(maxValue, dark, variant, minValue) + .map(s => `${s.color} ${(s.offset * 100).toFixed(1)}%`) + .join(', '); + return `linear-gradient(to right, ${stops})`; +}