Skip to content

Commit 8a8ade6

Browse files
committed
feat(ui): restore Group by alias in call flow host grouping
Homer 11 only offered ungrouped and group-by-IP columns; merge endpoints that share the same IP alias so the ladder shows logical roles again. Fixes #747
1 parent 6d7e21d commit 8a8ade6

4 files changed

Lines changed: 193 additions & 25 deletions

File tree

src/ui/src/dashboard/flow/FilterPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export function FilterPanel({
107107
>
108108
<RadioRow value="ungrouped" label="Ungrouped (IP + port)" />
109109
<RadioRow value="group-by-ip" label="Group by IP" />
110+
<RadioRow value="group-by-alias" label="Group by alias" />
110111
</RadioGroupPrimitive.Root>
111112
</section>
112113

src/ui/src/dashboard/flow/FlowHostsLine.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ function HostHoverCard({ host }: { host: Host }) {
3232
const hasMeta =
3333
!!host.displayLabel || !!host.aliasImage || (host.aliasTags && host.aliasTags.length > 0)
3434

35+
const ipTooltip =
36+
host.ips && host.ips.length > 1 ? `IPs: ${host.ips.join(', ')}` : ''
37+
3538
return (
3639
<div className="flex max-w-[260px] flex-col gap-2 text-left font-normal">
3740
<div className="font-mono text-[11px] leading-snug opacity-95">{host.ip}</div>
41+
{ipTooltip ? <div className="text-[10px] opacity-80">{ipTooltip}</div> : null}
3842
{portTooltip ? <div className="text-[10px] opacity-80">{portTooltip}</div> : null}
3943
{hasMeta ? (
4044
<div className="space-y-2 border-t border-white/15 pt-2">
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { buildFlow, buildHosts, endpointAlias, hostKey, hostKeyFromMessage } from './flow-data'
3+
4+
describe('hostKey group-by-alias', () => {
5+
it('uses alias prefix when enrichment provides alias', () => {
6+
const row = { src_ip: '10.0.0.1', src_port: 5060, aliasSrc: 'SBC' }
7+
expect(hostKey('10.0.0.1', 5060, 'group-by-alias', row, 'src')).toBe('alias:SBC')
8+
})
9+
10+
it('falls back to ip:port without alias', () => {
11+
const row = { src_ip: '10.0.0.1', src_port: 5060 }
12+
expect(hostKey('10.0.0.1', 5060, 'group-by-alias', row, 'src')).toBe('10.0.0.1:5060')
13+
})
14+
})
15+
16+
describe('buildHosts group-by-alias', () => {
17+
it('merges different IPs that share the same alias', () => {
18+
const items = [
19+
{
20+
src_ip: '10.0.0.1',
21+
dst_ip: '10.0.0.2',
22+
src_port: 5060,
23+
dst_port: 5060,
24+
aliasSrc: 'Proxy',
25+
aliasDst: 'UAS',
26+
},
27+
{
28+
src_ip: '10.0.0.3',
29+
dst_ip: '10.0.0.2',
30+
src_port: 5060,
31+
dst_port: 5060,
32+
aliasSrc: 'Proxy',
33+
aliasDst: 'UAS',
34+
},
35+
]
36+
const hosts = buildHosts(items, 'group-by-alias')
37+
expect(hosts).toHaveLength(2)
38+
const proxy = hosts.find((h) => h.displayLabel === 'Proxy')
39+
expect(proxy).toBeDefined()
40+
expect(proxy!.ips).toEqual(expect.arrayContaining(['10.0.0.1', '10.0.0.3']))
41+
expect(hostKeyFromMessage(items[0], 'src', 'group-by-alias')).toBe(proxy!.key)
42+
expect(hostKeyFromMessage(items[1], 'src', 'group-by-alias')).toBe(proxy!.key)
43+
})
44+
45+
it('keeps un-aliased endpoints on separate ip:port columns', () => {
46+
const hosts = buildHosts(
47+
[
48+
{ src_ip: '10.0.0.1', dst_ip: '10.0.0.2', src_port: 5060, dst_port: 5060 },
49+
{ src_ip: '10.0.0.1', dst_ip: '10.0.0.2', src_port: 5061, dst_port: 5060 },
50+
],
51+
'group-by-alias',
52+
)
53+
expect(hosts.filter((h) => h.key.startsWith('10.0.0.1'))).toHaveLength(2)
54+
})
55+
})
56+
57+
describe('buildFlow group-by-alias', () => {
58+
it('places messages on the same column index when aliases match', () => {
59+
const baseTs = new Date('2026-01-01T12:00:00.000Z').getTime()
60+
const { hosts, flowItems } = buildFlow(
61+
[
62+
{
63+
src_ip: '10.0.0.1',
64+
dst_ip: '10.0.0.2',
65+
src_port: 5060,
66+
dst_port: 5060,
67+
aliasSrc: 'SBC-A',
68+
aliasDst: 'UAS',
69+
session_id: 'c1',
70+
timestamp: baseTs,
71+
},
72+
{
73+
src_ip: '10.0.0.3',
74+
dst_ip: '10.0.0.2',
75+
src_port: 5060,
76+
dst_port: 5060,
77+
aliasSrc: 'SBC-A',
78+
aliasDst: 'UAS',
79+
session_id: 'c1',
80+
timestamp: baseTs + 10,
81+
},
82+
],
83+
{ grouping: 'group-by-alias' },
84+
)
85+
expect(hosts).toHaveLength(2)
86+
expect(flowItems).toHaveLength(2)
87+
expect(flowItems[0].start).toBe(flowItems[1].start)
88+
expect(endpointAlias({ src_ip: '1.2.3.4', aliasSrc: 'Edge' }, 'src')).toBe('Edge')
89+
})
90+
})

src/ui/src/dashboard/flow/flow-data.ts

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface Host {
3232
port: number | string
3333
key: string
3434
ports?: Array<number | string>
35+
/** Distinct IPs merged into this column (group-by-ip / group-by-alias). */
36+
ips?: string[]
3537
/** Friendly name from IP alias enrichment (aliasSrc / aliasDst) */
3638
displayLabel?: string
3739
/** Custom image URL from matched alias row (call flow header) */
@@ -72,7 +74,37 @@ export interface CallIdLegend {
7274
colors: CallIdColors
7375
}
7476

75-
export type HostGrouping = 'ungrouped' | 'group-by-ip'
77+
export type HostGrouping = 'ungrouped' | 'group-by-ip' | 'group-by-alias'
78+
79+
const ALIAS_KEY_PREFIX = 'alias:'
80+
81+
function rowOf(msg: RawMessage): Record<string, unknown> {
82+
return msg as Record<string, unknown>
83+
}
84+
85+
/** Resolved alias label for an endpoint, or empty when enrichment has no alias. */
86+
export function endpointAlias(row: Record<string, unknown>, side: 'src' | 'dst'): string {
87+
const ip = String(side === 'src' ? row.src_ip ?? '' : row.dst_ip ?? '')
88+
const lbl = side === 'src' ? displaySrcIp(row) : displayDstIp(row)
89+
if (lbl && lbl !== ip) return lbl
90+
return ''
91+
}
92+
93+
function mergeHostEndpoint(
94+
host: Host,
95+
ip: string,
96+
port: number | string,
97+
grouping: HostGrouping,
98+
aliasLabel: string,
99+
): void {
100+
if (grouping === 'group-by-ip' || grouping === 'group-by-alias') {
101+
if (!host.ports!.includes(port)) host.ports!.push(port)
102+
if (grouping === 'group-by-alias' && ip && !host.ips!.includes(ip)) host.ips!.push(ip)
103+
}
104+
if (grouping === 'group-by-alias' && aliasLabel && !host.displayLabel) {
105+
host.displayLabel = aliasLabel
106+
}
107+
}
76108

77109
/**
78110
* Build the call-ID legend from a *raw* (un-filtered) message list so
@@ -151,35 +183,77 @@ export function shortcutIPv6(str: string): string {
151183
return str
152184
}
153185

154-
function hostKey(ip: string, port: number | string, grouping: HostGrouping): string {
155-
return grouping === 'group-by-ip' ? ip : `${ip}:${port}`
186+
export function hostKey(
187+
ip: string,
188+
port: number | string,
189+
grouping: HostGrouping,
190+
row?: Record<string, unknown>,
191+
side?: 'src' | 'dst',
192+
): string {
193+
if (grouping === 'group-by-ip') return ip
194+
if (grouping === 'group-by-alias' && row && side) {
195+
const alias = endpointAlias(row, side)
196+
if (alias) return `${ALIAS_KEY_PREFIX}${alias}`
197+
return `${ip}:${port}`
198+
}
199+
return `${ip}:${port}`
200+
}
201+
202+
export function hostKeyFromMessage(
203+
msg: RawMessage,
204+
side: 'src' | 'dst',
205+
grouping: HostGrouping,
206+
): string {
207+
const row = rowOf(msg)
208+
const ip = String(side === 'src' ? row.src_ip ?? 'unknown' : row.dst_ip ?? 'unknown')
209+
const port = side === 'src' ? (row.src_port ?? 0) : (row.dst_port ?? 0)
210+
return hostKey(ip, port as number | string, grouping, row, side)
156211
}
157212

158213
export function buildHosts(items: RawMessage[], grouping: HostGrouping): Host[] {
159214
const order: string[] = []
160215
const map = new Map<string, Host>()
216+
const merges = grouping === 'group-by-ip' || grouping === 'group-by-alias'
217+
161218
items.forEach((msg) => {
219+
const row = rowOf(msg)
162220
const srcIp = msg.src_ip || 'unknown'
163221
const dstIp = msg.dst_ip || 'unknown'
164222
const srcPort = msg.src_port ?? 0
165223
const dstPort = msg.dst_port ?? 0
166-
const srcKey = hostKey(srcIp, srcPort, grouping)
167-
const dstKey = hostKey(dstIp, dstPort, grouping)
224+
const srcAlias = grouping === 'group-by-alias' ? endpointAlias(row, 'src') : ''
225+
const dstAlias = grouping === 'group-by-alias' ? endpointAlias(row, 'dst') : ''
226+
const srcKey = hostKey(srcIp, srcPort, grouping, row, 'src')
227+
const dstKey = hostKey(dstIp, dstPort, grouping, row, 'dst')
168228

169229
if (!map.has(srcKey)) {
170-
map.set(srcKey, { ip: srcIp, port: srcPort, key: srcKey, ports: [srcPort] })
230+
const h: Host = {
231+
ip: srcIp,
232+
port: srcPort,
233+
key: srcKey,
234+
ports: [srcPort],
235+
ips: merges ? [srcIp] : undefined,
236+
}
237+
if (srcAlias) h.displayLabel = srcAlias
238+
map.set(srcKey, h)
171239
order.push(srcKey)
172-
} else if (grouping === 'group-by-ip') {
173-
const h = map.get(srcKey)!
174-
if (!h.ports!.includes(srcPort)) h.ports!.push(srcPort)
240+
} else if (merges) {
241+
mergeHostEndpoint(map.get(srcKey)!, srcIp, srcPort, grouping, srcAlias)
175242
}
176243

177244
if (!map.has(dstKey)) {
178-
map.set(dstKey, { ip: dstIp, port: dstPort, key: dstKey, ports: [dstPort] })
245+
const h: Host = {
246+
ip: dstIp,
247+
port: dstPort,
248+
key: dstKey,
249+
ports: [dstPort],
250+
ips: merges ? [dstIp] : undefined,
251+
}
252+
if (dstAlias) h.displayLabel = dstAlias
253+
map.set(dstKey, h)
179254
order.push(dstKey)
180-
} else if (grouping === 'group-by-ip') {
181-
const h = map.get(dstKey)!
182-
if (!h.ports!.includes(dstPort)) h.ports!.push(dstPort)
255+
} else if (merges) {
256+
mergeHostEndpoint(map.get(dstKey)!, dstIp, dstPort, grouping, dstAlias)
183257
}
184258
})
185259
return order.map((k) => map.get(k) as Host)
@@ -192,15 +266,14 @@ export function resolveHostFlowMeta(
192266
grouping: HostGrouping,
193267
): { displayLabel: string; aliasImage: string; aliasTags: string[] } {
194268
const empty = { displayLabel: '', aliasImage: '', aliasTags: [] as string[] }
195-
const rec = (m: RawMessage) => m as Record<string, unknown>
196269
for (const msg of items) {
197270
const srcIp = msg.src_ip || 'unknown'
198271
const dstIp = msg.dst_ip || 'unknown'
199272
const sp = msg.src_port ?? 0
200273
const dp = msg.dst_port ?? 0
274+
const row = rowOf(msg)
201275
if (grouping === 'group-by-ip') {
202276
if (srcIp === host.ip) {
203-
const row = rec(msg)
204277
const lbl = displaySrcIp(row)
205278
const en = aliasSrcEnrichment(row)
206279
const hasAlias = lbl !== '' && lbl !== srcIp
@@ -209,7 +282,6 @@ export function resolveHostFlowMeta(
209282
}
210283
}
211284
if (dstIp === host.ip) {
212-
const row = rec(msg)
213285
const lbl = displayDstIp(row)
214286
const en = aliasDstEnrichment(row)
215287
const hasAlias = lbl !== '' && lbl !== dstIp
@@ -218,17 +290,15 @@ export function resolveHostFlowMeta(
218290
}
219291
}
220292
} else {
221-
if (hostKey(srcIp, sp, grouping) === host.key) {
222-
const row = rec(msg)
293+
if (hostKey(srcIp, sp, grouping, row, 'src') === host.key) {
223294
const lbl = displaySrcIp(row)
224295
const en = aliasSrcEnrichment(row)
225296
const hasAlias = lbl !== '' && lbl !== srcIp
226297
if (hasAlias || en.image || en.tags.length > 0) {
227298
return { displayLabel: hasAlias ? lbl : '', aliasImage: en.image, aliasTags: en.tags }
228299
}
229300
}
230-
if (hostKey(dstIp, dp, grouping) === host.key) {
231-
const row = rec(msg)
301+
if (hostKey(dstIp, dp, grouping, row, 'dst') === host.key) {
232302
const lbl = displayDstIp(row)
233303
const en = aliasDstEnrichment(row)
234304
const hasAlias = lbl !== '' && lbl !== dstIp
@@ -238,6 +308,9 @@ export function resolveHostFlowMeta(
238308
}
239309
}
240310
}
311+
if (grouping === 'group-by-alias' && host.displayLabel) {
312+
return { ...empty, displayLabel: host.displayLabel }
313+
}
241314
return empty
242315
}
243316

@@ -252,11 +325,11 @@ export function resolveHostDisplayLabel(
252325

253326
function indexOfHost(
254327
hosts: Host[],
255-
ip: string,
256-
port: number | string,
328+
msg: RawMessage,
329+
side: 'src' | 'dst',
257330
grouping: HostGrouping,
258331
): number {
259-
const key = hostKey(ip, port, grouping)
332+
const key = hostKeyFromMessage(msg, side, grouping)
260333
return hosts.findIndex((h) => h.key === key)
261334
}
262335

@@ -331,8 +404,8 @@ export function buildFlow(items: RawMessage[] | null | undefined, opts: BuildOpt
331404
let method = msg.sip_method || msg.method || msg.event || ''
332405
const proto = msg.protocol
333406

334-
const srcIdx = indexOfHost(hosts, srcIp, srcPort, grouping)
335-
const dstIdx = indexOfHost(hosts, dstIp, dstPort, grouping)
407+
const srcIdx = indexOfHost(hosts, msg, 'src', grouping)
408+
const dstIdx = indexOfHost(hosts, msg, 'dst', grouping)
336409

337410
const isRadial = srcIdx === dstIdx
338411
const isLastHost = isRadial && hosts.length > 1 && srcIdx === hosts.length - 1

0 commit comments

Comments
 (0)