From 62e97e8254d7fd74146ca5ecc4fffcdb6c722366 Mon Sep 17 00:00:00 2001 From: notyusheng Date: Sun, 19 Jul 2026 02:19:18 +0800 Subject: [PATCH 1/2] feat(network): node-to-node volume heatmap + volume-coloured edges (#525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pair-level volumetric view to the network diagram, in both analysis mode and the monitor snapshot dialog. The diagram shows topology; nothing showed how *much* each pair talks. A matrix is the right shape for that. - Shared volume colour scale (utils/volumeColor.ts): one log-scaled, perceptually-tuned blue ramp, light/dark aware, replacing the two copy-pasted linear `trafficColor` helpers in ClusterGraph and CountryMapView. Ramps validated against both surfaces. Domain is fitted to the on-screen range so the full gradient is spent on real values. - VolumeHeatmap: src×dst matrix on a canvas (the grid is O(N²) but sparse, so cost scales with conversations, not N²). Fits to the container, with a zoom control and fullscreen for dense captures. Axis labels are IPs (unique, unlike identity labels); clicking one opens host details. - Volume-coloured diagram edges via a "Color edges by: Protocol | Volume" toggle on NetworkGraph, sharing the heatmap's scale and a gradient legend. Switching repaints in place without re-running the layout. - Wired into NetworkDiagramPage (analysis) and SnapshotDetailModal (the monitor Capture Timeline diagram), with cross-filter highlighting. - Deletes MonitorNetworkDiagram.tsx, dead since #321 replaced it with the snapshot-modal flow. Co-Authored-By: Claude Opus 4.8 --- docs/features/network-intelligence.rst | 30 +- docs/features/network-visualization.rst | 88 ++- .../ClusterGraph/ClusterGraph.tsx | 30 +- .../ClusterGraph/CountryMapView.tsx | 15 +- .../MonitorNetworkDiagram.tsx | 587 ------------------ .../SnapshotDetailModal.tsx | 129 +++- .../network/NetworkGraph/NetworkGraph.tsx | 93 ++- .../network/VolumeHeatmap/VolumeHeatmap.css | 159 +++++ .../network/VolumeHeatmap/VolumeHeatmap.tsx | Bin 0 -> 18395 bytes .../network/VolumeHeatmap/heatmapLayout.ts | 72 +++ .../components/network/VolumeHeatmap/index.ts | 2 + .../network/VolumeLegend/VolumeLegend.css | 35 ++ .../network/VolumeLegend/VolumeLegend.tsx | 54 ++ .../components/network/VolumeLegend/index.ts | 1 + .../NetworkDiagram/NetworkDiagramPage.tsx | 129 +++- frontend/src/utils/useResolvedDark.ts | 29 + frontend/src/utils/volumeColor.ts | 167 +++++ 17 files changed, 997 insertions(+), 623 deletions(-) delete mode 100644 frontend/src/components/monitor/MonitorNetworkDiagram/MonitorNetworkDiagram.tsx create mode 100644 frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css create mode 100644 frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.tsx create mode 100644 frontend/src/components/network/VolumeHeatmap/heatmapLayout.ts create mode 100644 frontend/src/components/network/VolumeHeatmap/index.ts create mode 100644 frontend/src/components/network/VolumeLegend/VolumeLegend.css create mode 100644 frontend/src/components/network/VolumeLegend/VolumeLegend.tsx create mode 100644 frontend/src/components/network/VolumeLegend/index.ts create mode 100644 frontend/src/utils/useResolvedDark.ts create mode 100644 frontend/src/utils/volumeColor.ts 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..cd510144 --- /dev/null +++ b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css @@ -0,0 +1,159 @@ +.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; +} + +/* Fullscreen from inside a modal. + The shared .nd-css-fullscreen sits at z-index 1040, below Bootstrap's modal + (~1055), so a heatmap fullscreened from the monitor dialog would render + *behind* the dialog containing it. This variant stacks above instead. */ +.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; +} + +.tp-heatmap-fullscreen-over-modal .tp-heatmap { + flex: 1; + min-height: 0; +} + +.tp-heatmap-fullscreen-over-modal .tp-heatmap-scroll { + flex: 1; + min-height: 0; +} diff --git a/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.tsx b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a8743f89800fec9dba5cfb8e3ed29d221c1e49e0 GIT binary patch literal 18395 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 literal 0 HcmV?d00001 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})`; +} From d49ff8700188abd01b6bb43a18a9b6f9f3d9b8e8 Mon Sep 17 00:00:00 2001 From: notyusheng Date: Sun, 19 Jul 2026 02:27:29 +0800 Subject: [PATCH 2/2] fix/remove-duplicate-fullscreen-css-block The .tp-heatmap-fullscreen-over-modal rules were refactored into a shared .nd-css-fullscreen block but the original standalone block was left at the end of the file. Per gemini-code-assist review on #542. Co-Authored-By: Claude Opus 4.8 --- .../network/VolumeHeatmap/VolumeHeatmap.css | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css index cd510144..94db5cfa 100644 --- a/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css +++ b/frontend/src/components/network/VolumeHeatmap/VolumeHeatmap.css @@ -130,30 +130,3 @@ text-align: center; font-variant-numeric: tabular-nums; } - -/* Fullscreen from inside a modal. - The shared .nd-css-fullscreen sits at z-index 1040, below Bootstrap's modal - (~1055), so a heatmap fullscreened from the monitor dialog would render - *behind* the dialog containing it. This variant stacks above instead. */ -.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; -} - -.tp-heatmap-fullscreen-over-modal .tp-heatmap { - flex: 1; - min-height: 0; -} - -.tp-heatmap-fullscreen-over-modal .tp-heatmap-scroll { - flex: 1; - min-height: 0; -}