diff --git a/src/ui/src/dashboard/flow/FilterPanel.tsx b/src/ui/src/dashboard/flow/FilterPanel.tsx index 8857389f..c17ba69b 100644 --- a/src/ui/src/dashboard/flow/FilterPanel.tsx +++ b/src/ui/src/dashboard/flow/FilterPanel.tsx @@ -107,6 +107,7 @@ export function FilterPanel({ > + diff --git a/src/ui/src/dashboard/flow/FlowHostsLine.tsx b/src/ui/src/dashboard/flow/FlowHostsLine.tsx index d3d97cd4..57eaca7e 100644 --- a/src/ui/src/dashboard/flow/FlowHostsLine.tsx +++ b/src/ui/src/dashboard/flow/FlowHostsLine.tsx @@ -32,9 +32,13 @@ function HostHoverCard({ host }: { host: Host }) { const hasMeta = !!host.displayLabel || !!host.aliasImage || (host.aliasTags && host.aliasTags.length > 0) + const ipTooltip = + host.ips && host.ips.length > 1 ? `IPs: ${host.ips.join(', ')}` : '' + return (
{host.ip}
+ {ipTooltip ?
{ipTooltip}
: null} {portTooltip ?
{portTooltip}
: null} {hasMeta ? (
diff --git a/src/ui/src/dashboard/flow/flow-data.test.ts b/src/ui/src/dashboard/flow/flow-data.test.ts new file mode 100644 index 00000000..3c91b97e --- /dev/null +++ b/src/ui/src/dashboard/flow/flow-data.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { buildFlow, buildHosts, endpointAlias, hostKey, hostKeyFromMessage } from './flow-data' + +describe('hostKey group-by-alias', () => { + it('uses alias prefix when enrichment provides alias', () => { + const row = { src_ip: '10.0.0.1', src_port: 5060, aliasSrc: 'SBC' } + expect(hostKey('10.0.0.1', 5060, 'group-by-alias', row, 'src')).toBe('alias:SBC') + }) + + it('falls back to ip:port without alias', () => { + const row = { src_ip: '10.0.0.1', src_port: 5060 } + expect(hostKey('10.0.0.1', 5060, 'group-by-alias', row, 'src')).toBe('10.0.0.1:5060') + }) +}) + +describe('buildHosts group-by-alias', () => { + it('merges different IPs that share the same alias', () => { + const items = [ + { + src_ip: '10.0.0.1', + dst_ip: '10.0.0.2', + src_port: 5060, + dst_port: 5060, + aliasSrc: 'Proxy', + aliasDst: 'UAS', + }, + { + src_ip: '10.0.0.3', + dst_ip: '10.0.0.2', + src_port: 5060, + dst_port: 5060, + aliasSrc: 'Proxy', + aliasDst: 'UAS', + }, + ] + const hosts = buildHosts(items, 'group-by-alias') + expect(hosts).toHaveLength(2) + const proxy = hosts.find((h) => h.displayLabel === 'Proxy') + expect(proxy).toBeDefined() + expect(proxy!.ips).toEqual(expect.arrayContaining(['10.0.0.1', '10.0.0.3'])) + expect(hostKeyFromMessage(items[0], 'src', 'group-by-alias')).toBe(proxy!.key) + expect(hostKeyFromMessage(items[1], 'src', 'group-by-alias')).toBe(proxy!.key) + }) + + it('keeps un-aliased endpoints on separate ip:port columns', () => { + const hosts = buildHosts( + [ + { src_ip: '10.0.0.1', dst_ip: '10.0.0.2', src_port: 5060, dst_port: 5060 }, + { src_ip: '10.0.0.1', dst_ip: '10.0.0.2', src_port: 5061, dst_port: 5060 }, + ], + 'group-by-alias', + ) + expect(hosts.filter((h) => h.key.startsWith('10.0.0.1'))).toHaveLength(2) + }) +}) + +describe('buildFlow group-by-alias', () => { + it('places messages on the same column index when aliases match', () => { + const baseTs = new Date('2026-01-01T12:00:00.000Z').getTime() + const { hosts, flowItems } = buildFlow( + [ + { + src_ip: '10.0.0.1', + dst_ip: '10.0.0.2', + src_port: 5060, + dst_port: 5060, + aliasSrc: 'SBC-A', + aliasDst: 'UAS', + session_id: 'c1', + timestamp: baseTs, + }, + { + src_ip: '10.0.0.3', + dst_ip: '10.0.0.2', + src_port: 5060, + dst_port: 5060, + aliasSrc: 'SBC-A', + aliasDst: 'UAS', + session_id: 'c1', + timestamp: baseTs + 10, + }, + ], + { grouping: 'group-by-alias' }, + ) + expect(hosts).toHaveLength(2) + expect(flowItems).toHaveLength(2) + expect(flowItems[0].start).toBe(flowItems[1].start) + expect(endpointAlias({ src_ip: '1.2.3.4', aliasSrc: 'Edge' }, 'src')).toBe('Edge') + }) +}) diff --git a/src/ui/src/dashboard/flow/flow-data.ts b/src/ui/src/dashboard/flow/flow-data.ts index 97d41e10..9088825e 100644 --- a/src/ui/src/dashboard/flow/flow-data.ts +++ b/src/ui/src/dashboard/flow/flow-data.ts @@ -32,6 +32,8 @@ export interface Host { port: number | string key: string ports?: Array + /** Distinct IPs merged into this column (group-by-ip / group-by-alias). */ + ips?: string[] /** Friendly name from IP alias enrichment (aliasSrc / aliasDst) */ displayLabel?: string /** Custom image URL from matched alias row (call flow header) */ @@ -72,7 +74,37 @@ export interface CallIdLegend { colors: CallIdColors } -export type HostGrouping = 'ungrouped' | 'group-by-ip' +export type HostGrouping = 'ungrouped' | 'group-by-ip' | 'group-by-alias' + +const ALIAS_KEY_PREFIX = 'alias:' + +function rowOf(msg: RawMessage): Record { + return msg as Record +} + +/** Resolved alias label for an endpoint, or empty when enrichment has no alias. */ +export function endpointAlias(row: Record, side: 'src' | 'dst'): string { + const ip = String(side === 'src' ? row.src_ip ?? '' : row.dst_ip ?? '') + const lbl = side === 'src' ? displaySrcIp(row) : displayDstIp(row) + if (lbl && lbl !== ip) return lbl + return '' +} + +function mergeHostEndpoint( + host: Host, + ip: string, + port: number | string, + grouping: HostGrouping, + aliasLabel: string, +): void { + if (grouping === 'group-by-ip' || grouping === 'group-by-alias') { + if (!host.ports!.includes(port)) host.ports!.push(port) + if (grouping === 'group-by-alias' && ip && !host.ips!.includes(ip)) host.ips!.push(ip) + } + if (grouping === 'group-by-alias' && aliasLabel && !host.displayLabel) { + host.displayLabel = aliasLabel + } +} /** * Build the call-ID legend from a *raw* (un-filtered) message list so @@ -151,35 +183,77 @@ export function shortcutIPv6(str: string): string { return str } -function hostKey(ip: string, port: number | string, grouping: HostGrouping): string { - return grouping === 'group-by-ip' ? ip : `${ip}:${port}` +export function hostKey( + ip: string, + port: number | string, + grouping: HostGrouping, + row?: Record, + side?: 'src' | 'dst', +): string { + if (grouping === 'group-by-ip') return ip + if (grouping === 'group-by-alias' && row && side) { + const alias = endpointAlias(row, side) + if (alias) return `${ALIAS_KEY_PREFIX}${alias}` + return `${ip}:${port}` + } + return `${ip}:${port}` +} + +export function hostKeyFromMessage( + msg: RawMessage, + side: 'src' | 'dst', + grouping: HostGrouping, +): string { + const row = rowOf(msg) + const ip = String(side === 'src' ? row.src_ip ?? 'unknown' : row.dst_ip ?? 'unknown') + const port = side === 'src' ? (row.src_port ?? 0) : (row.dst_port ?? 0) + return hostKey(ip, port as number | string, grouping, row, side) } export function buildHosts(items: RawMessage[], grouping: HostGrouping): Host[] { const order: string[] = [] const map = new Map() + const merges = grouping === 'group-by-ip' || grouping === 'group-by-alias' + items.forEach((msg) => { + const row = rowOf(msg) const srcIp = msg.src_ip || 'unknown' const dstIp = msg.dst_ip || 'unknown' const srcPort = msg.src_port ?? 0 const dstPort = msg.dst_port ?? 0 - const srcKey = hostKey(srcIp, srcPort, grouping) - const dstKey = hostKey(dstIp, dstPort, grouping) + const srcAlias = grouping === 'group-by-alias' ? endpointAlias(row, 'src') : '' + const dstAlias = grouping === 'group-by-alias' ? endpointAlias(row, 'dst') : '' + const srcKey = hostKey(srcIp, srcPort, grouping, row, 'src') + const dstKey = hostKey(dstIp, dstPort, grouping, row, 'dst') if (!map.has(srcKey)) { - map.set(srcKey, { ip: srcIp, port: srcPort, key: srcKey, ports: [srcPort] }) + const h: Host = { + ip: srcIp, + port: srcPort, + key: srcKey, + ports: [srcPort], + ips: merges ? [srcIp] : undefined, + } + if (srcAlias) h.displayLabel = srcAlias + map.set(srcKey, h) order.push(srcKey) - } else if (grouping === 'group-by-ip') { - const h = map.get(srcKey)! - if (!h.ports!.includes(srcPort)) h.ports!.push(srcPort) + } else if (merges) { + mergeHostEndpoint(map.get(srcKey)!, srcIp, srcPort, grouping, srcAlias) } if (!map.has(dstKey)) { - map.set(dstKey, { ip: dstIp, port: dstPort, key: dstKey, ports: [dstPort] }) + const h: Host = { + ip: dstIp, + port: dstPort, + key: dstKey, + ports: [dstPort], + ips: merges ? [dstIp] : undefined, + } + if (dstAlias) h.displayLabel = dstAlias + map.set(dstKey, h) order.push(dstKey) - } else if (grouping === 'group-by-ip') { - const h = map.get(dstKey)! - if (!h.ports!.includes(dstPort)) h.ports!.push(dstPort) + } else if (merges) { + mergeHostEndpoint(map.get(dstKey)!, dstIp, dstPort, grouping, dstAlias) } }) return order.map((k) => map.get(k) as Host) @@ -192,15 +266,14 @@ export function resolveHostFlowMeta( grouping: HostGrouping, ): { displayLabel: string; aliasImage: string; aliasTags: string[] } { const empty = { displayLabel: '', aliasImage: '', aliasTags: [] as string[] } - const rec = (m: RawMessage) => m as Record for (const msg of items) { const srcIp = msg.src_ip || 'unknown' const dstIp = msg.dst_ip || 'unknown' const sp = msg.src_port ?? 0 const dp = msg.dst_port ?? 0 + const row = rowOf(msg) if (grouping === 'group-by-ip') { if (srcIp === host.ip) { - const row = rec(msg) const lbl = displaySrcIp(row) const en = aliasSrcEnrichment(row) const hasAlias = lbl !== '' && lbl !== srcIp @@ -209,7 +282,6 @@ export function resolveHostFlowMeta( } } if (dstIp === host.ip) { - const row = rec(msg) const lbl = displayDstIp(row) const en = aliasDstEnrichment(row) const hasAlias = lbl !== '' && lbl !== dstIp @@ -218,8 +290,7 @@ export function resolveHostFlowMeta( } } } else { - if (hostKey(srcIp, sp, grouping) === host.key) { - const row = rec(msg) + if (hostKey(srcIp, sp, grouping, row, 'src') === host.key) { const lbl = displaySrcIp(row) const en = aliasSrcEnrichment(row) const hasAlias = lbl !== '' && lbl !== srcIp @@ -227,8 +298,7 @@ export function resolveHostFlowMeta( return { displayLabel: hasAlias ? lbl : '', aliasImage: en.image, aliasTags: en.tags } } } - if (hostKey(dstIp, dp, grouping) === host.key) { - const row = rec(msg) + if (hostKey(dstIp, dp, grouping, row, 'dst') === host.key) { const lbl = displayDstIp(row) const en = aliasDstEnrichment(row) const hasAlias = lbl !== '' && lbl !== dstIp @@ -238,6 +308,9 @@ export function resolveHostFlowMeta( } } } + if (grouping === 'group-by-alias' && host.displayLabel) { + return { ...empty, displayLabel: host.displayLabel } + } return empty } @@ -252,11 +325,11 @@ export function resolveHostDisplayLabel( function indexOfHost( hosts: Host[], - ip: string, - port: number | string, + msg: RawMessage, + side: 'src' | 'dst', grouping: HostGrouping, ): number { - const key = hostKey(ip, port, grouping) + const key = hostKeyFromMessage(msg, side, grouping) return hosts.findIndex((h) => h.key === key) } @@ -331,8 +404,8 @@ export function buildFlow(items: RawMessage[] | null | undefined, opts: BuildOpt let method = msg.sip_method || msg.method || msg.event || '' const proto = msg.protocol - const srcIdx = indexOfHost(hosts, srcIp, srcPort, grouping) - const dstIdx = indexOfHost(hosts, dstIp, dstPort, grouping) + const srcIdx = indexOfHost(hosts, msg, 'src', grouping) + const dstIdx = indexOfHost(hosts, msg, 'dst', grouping) const isRadial = srcIdx === dstIdx const isLastHost = isRadial && hosts.length > 1 && srcIdx === hosts.length - 1