Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions docs/features/network-intelligence.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------------

Expand Down
88 changes: 87 additions & 1 deletion docs/features/network-visualization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <shared-volume-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 <shared-volume-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 ``<canvas>``, 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/<fileId>/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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
30 changes: 15 additions & 15 deletions frontend/src/components/intelligence/ClusterGraph/ClusterGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -311,15 +313,6 @@ const GROUP_ICONS: Record<GroupBy, string> = {

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<string, unknown> {
Expand All @@ -332,18 +325,25 @@ interface IntelClusterNodeData extends Record<string, unknown> {
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
Expand All @@ -361,7 +361,7 @@ function IntelClusterNode({ data }: NodeProps) {
<Handle type="source" position={Position.Top} className="intel-handle" style={{ top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }} />

{hasRisk && (
<span className="intel-cluster-risk-badge" title={riskTitle} style={{ color: isTrafficMode && d.trafficRatio > 0.55 ? '#ffcdd2' : '#e74c3c' }}>
<span className="intel-cluster-risk-badge" title={riskTitle} style={{ color: isTrafficMode && trafficRatio > 0.55 ? '#ffcdd2' : '#e74c3c' }}>
<i className="bi bi-exclamation-triangle-fill" />
</span>
)}
Expand All @@ -378,7 +378,7 @@ function IntelClusterNode({ data }: NodeProps) {
{d.dominantProtocols.length > 0 && (
<div className="intel-cluster-protocols">
{d.dominantProtocols.slice(0, 3).map(p => (
<span key={p} className={`intel-cluster-badge${isTrafficMode && d.trafficRatio > 0.55 ? ' intel-cluster-badge-dark' : ''}`}>{p}</span>
<span key={p} className={`intel-cluster-badge${isTrafficMode && trafficRatio > 0.55 ? ' intel-cluster-badge-dark' : ''}`}>{p}</span>
))}
</div>
)}
Expand Down Expand Up @@ -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,
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, [number, number]>;

// ── 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 ────────────────────────────────────────
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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';
}

Expand Down
Loading
Loading