@skyhook-io/radar-app 1.16.3 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +14 -7
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +115 -8
  6. package/src/api/diagnose.test.ts +40 -0
  7. package/src/api/diagnose.ts +12 -1
  8. package/src/api/radarFeatures.render.test.tsx +19 -1
  9. package/src/api/radarFeatures.ts +3 -0
  10. package/src/api/traffic.sources.test.ts +55 -0
  11. package/src/api/traffic.ts +173 -14
  12. package/src/components/applications/ApplicationsView.tsx +3 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  14. package/src/components/capacity/CapacityView.test.tsx +31 -1
  15. package/src/components/capacity/shared.tsx +12 -21
  16. package/src/components/cnpg/CNPGProtection.tsx +2 -2
  17. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  18. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  19. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  20. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  21. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  22. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  23. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  24. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  25. package/src/components/diagnose/InvestigationView.tsx +39 -2
  26. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  27. package/src/components/diagnose/agentCatalog.ts +2 -2
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  29. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  30. package/src/components/execution/BatchExecutionView.tsx +11 -11
  31. package/src/components/helm/ChartBrowser.tsx +3 -3
  32. package/src/components/helm/RevisionHistory.tsx +3 -2
  33. package/src/components/home/ClusterHealthCard.tsx +7 -0
  34. package/src/components/home/mcpToolCatalog.ts +1 -1
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  36. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  37. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  39. package/src/components/settings/SettingsDialog.tsx +122 -19
  40. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  41. package/src/components/timeline/TimelineList.tsx +3 -0
  42. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  43. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  44. package/src/components/timeline/TimelineView.tsx +7 -0
  45. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  46. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  47. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  48. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  49. package/src/components/traffic/TrafficGraph.tsx +109 -26
  50. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  51. package/src/components/traffic/TrafficView.tsx +582 -149
  52. package/src/components/traffic/trafficFilters.test.ts +398 -1
  53. package/src/components/traffic/trafficFilters.ts +408 -0
  54. package/src/components/workload/WorkloadView.test.ts +2 -1
  55. package/src/components/workload/WorkloadView.tsx +135 -290
  56. package/src/context/TimelineCustomization.tsx +15 -0
  57. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  58. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  59. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import type { AggregatedFlow, TrafficFlow } from '../../types'
3
- import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, isExternalKind, isPolicyDropReason, requestRateOf, errorRateOf, formatRate, displayVolume, mergeFlowVolume, dedupeHTTPPairs, coverageLabel, latencyWeightOf } from './trafficFilters'
3
+ import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, isExternalKind, isPolicyDropReason, requestRateOf, errorRateOf, formatRate, displayVolume, mergeFlowVolume, dedupeHTTPPairs, coverageLabel, latencyWeightOf, endpointPair, selectionRawPairs, mergeRawPairs, graphEndpoint, graphSize, parseFocus, focusParam, focusId, touchesFocus, focusNeighborhood, endpointSummaries, searchEndpoints, namespaceSummaries, namespaceOverBudget, hasStatusData, connectionRows, selectionMatch, edgeCarriesPort, edgesOnPort, mergedEdgePort, selectedEdgeMatcher, type GraphFlow } from './trafficFilters'
4
4
 
5
5
  describe('matchesStatusRanges', () => {
6
6
  it('does not filter when nothing is selected', () => {
@@ -281,3 +281,400 @@ describe('coverageLabel', () => {
281
281
  expect(coverageLabel(undefined, '2026-09-30T08:39:04Z')).toBeNull()
282
282
  })
283
283
  })
284
+
285
+ describe('selectionRawPairs', () => {
286
+ const edge = (src: [string, string, string], dst: [string, string, string]): AggregatedFlow => ({
287
+ source: { namespace: src[0], name: src[1], kind: src[2] },
288
+ destination: { namespace: dst[0], name: dst[1], kind: dst[2] },
289
+ protocol: 'tcp', port: 443, flowCount: 1, bytesSent: 0, bytesRecv: 0, connections: 1, lastSeen: '',
290
+ })
291
+ const a = edge(['shop', 'web-1', 'Pod'], ['', '52.1.1.1', 'External'])
292
+ const b = edge(['shop', 'web-1', 'Pod'], ['', '52.1.1.2', 'External'])
293
+ const c = edge(['shop', 'web-1', 'Pod'], ['shop', 'db-0', 'Pod'])
294
+ // The graph merged a and b into one edge to a renamed external service.
295
+ const merged: GraphFlow = {
296
+ ...a,
297
+ destination: { namespace: '', name: 'HTTPS', kind: 'External' },
298
+ rawPairs: [endpointPair(a), endpointPair(b)],
299
+ }
300
+ const plain: GraphFlow = { ...c, rawPairs: [endpointPair(c)] }
301
+
302
+ it('traces a renamed node back to every server edge merged into it', () => {
303
+ const pairs = selectionRawPairs([merged, plain], { type: 'node', nodeId: 'HTTPS' })
304
+ expect(pairs?.map(p => p.destination.name).sort()).toEqual(['52.1.1.1', '52.1.1.2'])
305
+ })
306
+
307
+ it('selects an edge by direction', () => {
308
+ expect(selectionRawPairs([merged, plain], { type: 'edge', sourceId: 'shop/web-1', destId: 'shop/db-0' }))
309
+ .toEqual([endpointPair(c)])
310
+ expect(selectionRawPairs([merged, plain], { type: 'edge', sourceId: 'shop/db-0', destId: 'shop/web-1' })).toBeNull()
311
+ })
312
+
313
+ it('dedupes server edges reached through several graph edges', () => {
314
+ const pairs = selectionRawPairs([merged, plain, { ...plain }], { type: 'node', nodeId: 'shop/web-1' })
315
+ expect(pairs).toHaveLength(3)
316
+ })
317
+
318
+ it('returns null when nothing traces back, so the list falls back to the sample', () => {
319
+ expect(selectionRawPairs([merged], { type: 'node', nodeId: 'addon-group' })).toBeNull()
320
+ expect(selectionRawPairs([merged], null)).toBeNull()
321
+ })
322
+
323
+ it('selects an edge on its own port: the graph draws one edge per port', () => {
324
+ const http = { ...c, port: 80 }
325
+ const graph: GraphFlow[] = [{ ...c, rawPairs: [endpointPair(c)] }, { ...http, rawPairs: [endpointPair(http)] }]
326
+ expect(selectionRawPairs(graph, { type: 'edge', sourceId: 'shop/web-1', destId: 'shop/db-0', port: 80 }))
327
+ .toEqual([endpointPair(http)])
328
+ expect(endpointPair(http).port).toBe(80)
329
+ })
330
+
331
+ it('keeps a merged edge selected whichever of its ports it happens to carry', () => {
332
+ const http = { ...c, port: 80 }
333
+ // Merged from :443 and :80; the merged edge kept :443 this refresh.
334
+ const mergedEdge: GraphFlow = { ...c, rawPairs: [endpointPair(c), endpointPair(http)] }
335
+ expect(selectionRawPairs([mergedEdge], { type: 'edge', sourceId: 'shop/web-1', destId: 'shop/db-0', port: 80 }))
336
+ .toHaveLength(2)
337
+ })
338
+
339
+ it('resolves the addon group, drawn as one node, through its virtual endpoints', () => {
340
+ const virtual: GraphFlow = {
341
+ ...a,
342
+ source: { namespace: '', name: 'addon-internet', kind: 'AddonInternet' },
343
+ destination: { namespace: '', name: 'addon-group-target', kind: 'AddonGroupTarget' },
344
+ port: 0,
345
+ rawPairs: [endpointPair(a)],
346
+ }
347
+ expect(selectionRawPairs([virtual], { type: 'edge', sourceId: 'addon-internet', destId: 'addon-group', port: 0 }))
348
+ .toEqual([endpointPair(a)])
349
+ expect(selectionRawPairs([virtual], { type: 'node', nodeId: 'addon-group' })).toEqual([endpointPair(a)])
350
+ })
351
+
352
+ it('keeps an edge apart from its unknown-direction twin', () => {
353
+ const twin = { ...c, directionUnknown: true }
354
+ const graph: GraphFlow[] = [{ ...c, rawPairs: [endpointPair(c)] }, { ...twin, rawPairs: [endpointPair(twin)] }]
355
+ expect(selectionRawPairs(graph, { type: 'edge', sourceId: 'shop/web-1', destId: 'shop/db-0', port: 443 }))
356
+ .toEqual([endpointPair(c)])
357
+ expect(selectionRawPairs(graph, { type: 'edge', sourceId: 'shop/web-1', destId: 'shop/db-0', port: 443, directionUnknown: true }))
358
+ .toEqual([endpointPair(twin)])
359
+ })
360
+
361
+ it('returns the same order however the server ordered its aggregation', () => {
362
+ const sel = { type: 'node' as const, nodeId: 'shop/web-1' }
363
+ expect(selectionRawPairs([plain, merged], sel)).toEqual(selectionRawPairs([merged, plain], sel))
364
+ })
365
+
366
+ it('selects the traffic of every addon inside the group while addons are grouped', () => {
367
+ const dns = edge(['kube-system', 'coredns', 'Pod'], ['shop', 'web-1', 'Pod'])
368
+ const graph: GraphFlow[] = [{ ...dns, rawPairs: [endpointPair(dns)] }, plain]
369
+ const inGroup = (e: { name: string }) => e.name === 'coredns'
370
+ expect(selectionRawPairs(graph, { type: 'node', nodeId: 'addon-group' }, inGroup)).toEqual([endpointPair(dns)])
371
+ expect(selectionRawPairs(graph, { type: 'node', nodeId: 'addon-group' })).toBeNull()
372
+ })
373
+
374
+ it('merges pairs in place', () => {
375
+ const into: GraphFlow = { ...a, rawPairs: [endpointPair(a)] }
376
+ const owned = into.rawPairs
377
+ mergeRawPairs(into, { ...b, rawPairs: [endpointPair(b)] })
378
+ expect(into.rawPairs).toBe(owned)
379
+ expect(into.rawPairs).toHaveLength(2)
380
+ })
381
+
382
+ it('omits an empty namespace so the server reads it as cluster-level', () => {
383
+ expect(endpointPair(a).destination).toEqual({ namespace: undefined, name: '52.1.1.1', kind: 'External' })
384
+ })
385
+ })
386
+
387
+ describe('graphEndpoint', () => {
388
+ it('names a pod with a known workload by the workload, like the server aggregation', () => {
389
+ expect(graphEndpoint({ namespace: 'shop', name: 'web-7d9f-x2k4q', kind: 'Pod', workload: 'web', workloadKind: 'Deployment' }))
390
+ .toEqual({ namespace: 'shop', name: 'web' })
391
+ })
392
+ it('leaves everything else as it is', () => {
393
+ const bare = { namespace: 'shop', name: 'debug', kind: 'Pod' }
394
+ expect(graphEndpoint(bare)).toBe(bare)
395
+ const ext = { namespace: '', name: 'world', kind: 'External', workload: 'x' }
396
+ expect(graphEndpoint(ext)).toBe(ext)
397
+ })
398
+ })
399
+
400
+ describe('graphSize', () => {
401
+ it('counts distinct nodes and weighs edges double', () => {
402
+ const f = (s: string, d: string): AggregatedFlow => ({
403
+ source: { namespace: 'a', name: s, kind: 'Workload' },
404
+ destination: { namespace: 'a', name: d, kind: 'Workload' },
405
+ protocol: 'tcp', port: 80, flowCount: 1, bytesSent: 0, bytesRecv: 0, connections: 1, lastSeen: '',
406
+ })
407
+ expect(graphSize([f('web', 'db'), f('web', 'cache'), f('api', 'db')])).toEqual({ nodes: 4, edges: 3, score: 10 })
408
+ })
409
+ })
410
+
411
+ function edge(src: string, dst: string, extra: Partial<AggregatedFlow> = {}): AggregatedFlow {
412
+ const ep = (id: string) => {
413
+ const [namespace, name] = id.includes('/') ? id.split('/') : ['', id]
414
+ return { namespace, name, kind: namespace ? 'Workload' : 'External' }
415
+ }
416
+ return {
417
+ source: ep(src), destination: ep(dst), protocol: 'tcp', port: 80,
418
+ flowCount: 1, bytesSent: 0, bytesRecv: 0, connections: 1, lastSeen: '', ...extra,
419
+ }
420
+ }
421
+
422
+ describe('focus', () => {
423
+ it('round-trips through the URL value', () => {
424
+ for (const focus of [{ namespace: 'shop', name: 'checkout' }, { name: 'api.stripe.com' }, { name: '10.0.0.0/8' }]) {
425
+ expect(parseFocus(focusParam(focus))).toEqual(focus)
426
+ }
427
+ expect(focusId(parseFocus('shop/checkout')!)).toBe('shop/checkout')
428
+ expect(focusId({ name: '10.0.0.0/8' })).toBe('10.0.0.0/8')
429
+ expect(parseFocus('shop/')).toBeNull()
430
+ expect(parseFocus('checkout')).toBeNull()
431
+ expect(parseFocus(null)).toBeNull()
432
+ })
433
+
434
+ it('keeps the edges at either end of the focus and nothing else', () => {
435
+ const flows = [edge('shop/web', 'shop/checkout'), edge('shop/checkout', 'pay/api'), edge('shop/web', 'shop/cart')]
436
+ const n = focusNeighborhood(flows, { namespace: 'shop', name: 'checkout' })
437
+ expect(n).toHaveLength(2)
438
+ expect(n).not.toContain(flows[2])
439
+ })
440
+
441
+ it('matches by namespace too: a same-named workload elsewhere is not the focus', () => {
442
+ const flows = [edge('other/checkout', 'shop/web')]
443
+ expect(focusNeighborhood(flows, { namespace: 'shop', name: 'checkout' })).toHaveLength(0)
444
+ })
445
+
446
+ // Ungrouped, the graph draws pods. A workload focus is then its pods.
447
+ it('matches a workload\'s pods when pods are not grouped', () => {
448
+ const pod = { namespace: 'shop', name: 'checkout-7d9-abc', kind: 'Pod', workload: 'checkout' }
449
+ expect(touchesFocus(pod, { namespace: 'shop', name: 'checkout' })).toBe(true)
450
+ expect(touchesFocus({ ...pod, kind: 'Service' }, { namespace: 'shop', name: 'checkout' })).toBe(false)
451
+ })
452
+ })
453
+
454
+ describe('endpoint search', () => {
455
+ const flows = [
456
+ edge('shop/checkout', 'shop/db', { connections: 5 }),
457
+ edge('shop/cart', 'shop/checkout', { connections: 50 }),
458
+ edge('shop/checkout-worker', 'shop/db', { connections: 1, verdictCounts: { dropped: 2 } }),
459
+ ]
460
+ const summaries = endpointSummaries(flows)
461
+
462
+ it('sums an endpoint\'s traffic across its edges, once per edge', () => {
463
+ const checkout = summaries.find(s => s.id === 'shop/checkout')!
464
+ expect(checkout.volume).toBe(55)
465
+ expect(endpointSummaries([edge('shop/a', 'shop/a', { connections: 3 })])[0].volume).toBe(3)
466
+ })
467
+
468
+ it('puts an exact name first, then prefixes, problems before volume', () => {
469
+ expect(searchEndpoints(summaries, 'checkout').map(s => s.name)).toEqual(['checkout', 'checkout-worker'])
470
+ const all = searchEndpoints(summaries, 'c').map(s => s.name)
471
+ expect(all.indexOf('checkout-worker')).toBeLessThan(all.indexOf('cart'))
472
+ })
473
+
474
+ it('offers the workload of pods drawn ungrouped, as well as the pods', () => {
475
+ const pod = (name: string) => ({ namespace: 'shop', name, kind: 'Pod', workload: 'checkout', workloadKind: 'Deployment' })
476
+ const flows = [
477
+ { ...edge('shop/web', 'shop/db'), source: pod('checkout-1') },
478
+ { ...edge('shop/web', 'shop/db'), source: pod('checkout-2') },
479
+ ]
480
+ const found = searchEndpoints(endpointSummaries(flows), 'checkout')
481
+ expect(found[0]).toMatchObject({ id: 'shop/checkout', kind: 'Workload', volume: 2 })
482
+ expect(found.map(s => s.name)).toContain('checkout-1')
483
+ })
484
+
485
+ it('finds nothing for a name with no traffic', () => {
486
+ expect(searchEndpoints(summaries, 'payments')).toEqual([])
487
+ })
488
+ })
489
+
490
+ describe('namespaceSummaries', () => {
491
+ // Narrowing to a namespace keeps edges with either end in it, as the server
492
+ // does, so a cross-namespace edge belongs to both.
493
+ it('counts a cross-namespace edge toward both namespaces', () => {
494
+ const s = namespaceSummaries([edge('shop/web', 'pay/api'), edge('shop/web', 'shop/db')])
495
+ const shop = s.find(n => n.name === 'shop')!
496
+ const pay = s.find(n => n.name === 'pay')!
497
+ expect(shop.connections).toBe(2)
498
+ expect(pay.connections).toBe(1)
499
+ expect(pay.endpoints).toBe(2)
500
+ })
501
+
502
+ it('ranks the namespace with failures first', () => {
503
+ const s = namespaceSummaries([
504
+ edge('big/a', 'big/b', { connections: 1000 }),
505
+ edge('small/a', 'small/b', { connections: 1, httpStatusCounts: { '5xx': 1 } }),
506
+ ])
507
+ expect(s.map(n => n.name)).toEqual(['small', 'big'])
508
+ })
509
+
510
+ it('skips endpoints without a namespace', () => {
511
+ expect(namespaceSummaries([edge('shop/web', 'api.stripe.com')]).map(n => n.name)).toEqual(['shop'])
512
+ })
513
+ // A hub's namespace is the case that sends a user from one too-large screen
514
+ // to the next: its many callees in other namespaces stay in view.
515
+ it('marks a namespace whose own view is over the draw budget', () => {
516
+ const hub = Array.from({ length: 250 }, (_, i) => edge('edge/gateway', `team-${i % 10}/svc-${i}`))
517
+ const s = namespaceSummaries([...hub, edge('quiet/a', 'quiet/b')])
518
+ expect(namespaceOverBudget(s.find(n => n.name === 'edge')!)).toBe(true)
519
+ expect(namespaceOverBudget(s.find(n => n.name === 'quiet')!)).toBe(false)
520
+ expect(namespaceOverBudget(s.find(n => n.name === 'team-0')!)).toBe(false)
521
+ })
522
+ })
523
+
524
+ describe('hasStatusData', () => {
525
+ it('tells an edge without HTTP status from one with no 5xx', () => {
526
+ expect(hasStatusData(edge('a/x', 'a/y'))).toBe(false)
527
+ expect(hasStatusData(edge('a/x', 'a/y', { httpStatusCounts: { '2xx': 5 } }))).toBe(true)
528
+ // A healthy metrics-based edge: its zero error rate is not sent.
529
+ expect(hasStatusData(edge('a/x', 'a/y', { requestRate: 2.5 }))).toBe(true)
530
+ expect(hasStatusData(edge('a/x', 'a/y', { l7Protocol: 'HTTP' }))).toBe(true)
531
+ expect(hasStatusData(edge('a/x', 'a/y', { l7Protocol: 'DNS' }))).toBe(false)
532
+ })
533
+ })
534
+
535
+ describe('connectionRows', () => {
536
+ it('lists failing edges first, then by volume', () => {
537
+ const quiet = edge('a/x', 'a/y', { connections: 1 })
538
+ const busy = edge('a/x', 'a/z', { connections: 100 })
539
+ const dropping = edge('a/x', 'a/w', { connections: 2, verdictCounts: { dropped: 1 } })
540
+ const erroring = edge('a/x', 'a/v', { connections: 2, errorRate: 0.5 })
541
+ expect(connectionRows([quiet, busy, erroring, dropping])).toEqual([dropping, erroring, busy, quiet])
542
+ })
543
+ })
544
+
545
+ describe('mergeFlowVolume problem counts', () => {
546
+ it('sums status and verdict counts without touching the merged edges', () => {
547
+ const a = edge('a/x', 'ext', { httpStatusCounts: { '5xx': 1 }, verdictCounts: { dropped: 2 } })
548
+ const b = edge('a/y', 'ext', { httpStatusCounts: { '5xx': 3, '2xx': 4 }, verdictCounts: { forwarded: 5 } })
549
+ const merged = { ...a }
550
+ mergeFlowVolume(merged, b)
551
+ expect(merged.httpStatusCounts).toEqual({ '5xx': 4, '2xx': 4 })
552
+ expect(merged.verdictCounts).toEqual({ dropped: 2, forwarded: 5 })
553
+ expect(a.httpStatusCounts).toEqual({ '5xx': 1 })
554
+ })
555
+ })
556
+
557
+ describe('selectionMatch', () => {
558
+ const traced = (f: AggregatedFlow): GraphFlow => ({ ...f, rawPairs: [endpointPair(f)] })
559
+
560
+ // A busy node has more edges than a URL can name; as one endpoint it is
561
+ // one entry however many it has.
562
+ it('sends a node that is one server endpoint as that endpoint', () => {
563
+ const flows = Array.from({ length: 400 }, (_, i) => traced(edge('edge/gateway', `t/svc-${i}`)))
564
+ const node = { type: 'node' as const, nodeId: 'edge/gateway' }
565
+ const m = selectionMatch(selectionRawPairs(flows, node), node)
566
+ expect(m).toEqual({ endpoints: [expect.objectContaining({ namespace: 'edge', name: 'gateway' })] })
567
+ })
568
+
569
+ it('sends a node the source cannot name, such as an external one, as its edges', () => {
570
+ const flows = [traced(edge('a/web', 'api.stripe.com')), traced(edge('a/job', 'api.stripe.com'))]
571
+ const node = { type: 'node' as const, nodeId: 'api.stripe.com' }
572
+ expect(selectionMatch(selectionRawPairs(flows, node), node)).toHaveProperty('pairs')
573
+ })
574
+
575
+ it('sends an edge, and a node merged from several endpoints, as edges', () => {
576
+ const a = traced(edge('a/web', 'a/db'))
577
+ const e = { type: 'edge' as const, sourceId: 'a/web', destId: 'a/db', port: 80 }
578
+ expect(selectionMatch(selectionRawPairs([a], e), e)).toHaveProperty('pairs')
579
+ // An external name the graph merged from two addresses.
580
+ const merged: GraphFlow = { ...edge('a/web', 'MongoDB'), rawPairs: [endpointPair(edge('a/web', '10.0.0.1')), endpointPair(edge('a/web', '10.0.0.2'))] }
581
+ const mongo = { type: 'node' as const, nodeId: 'MongoDB' }
582
+ expect(selectionMatch(selectionRawPairs([merged], mongo), mongo)).toHaveProperty('pairs')
583
+ })
584
+ })
585
+
586
+ describe('edgeCarriesPort', () => {
587
+ // A merged edge shows one of its ports; a selection made on another must
588
+ // still find it.
589
+ it('finds a port merged into the edge, not just the one it shows', () => {
590
+ const merged: GraphFlow = { ...edge('a/web', 'a/db'), port: 5432, rawPairs: [endpointPair({ ...edge('a/web', 'a/db'), port: 5432 }), endpointPair({ ...edge('a/web', 'a/db'), port: 6379 })] }
591
+ expect(edgeCarriesPort(merged, 6379)).toBe(true)
592
+ expect(edgeCarriesPort(merged, 5432)).toBe(true)
593
+ expect(edgeCarriesPort(merged, 80)).toBe(false)
594
+ expect(edgeCarriesPort(merged, undefined)).toBe(true)
595
+ })
596
+ })
597
+
598
+ describe('selectedEdgeMatcher', () => {
599
+ const ep = (namespace: string, name: string) => ({ name, namespace, kind: 'Pod' })
600
+ const record = (src: string, dst: string, port: number): TrafficFlow => ({
601
+ source: ep('shop', src), destination: ep('shop', dst), protocol: 'tcp', port,
602
+ connections: 1, bytesSent: 0, bytesRecv: 0, lastSeen: '2025-01-01T00:00:00Z',
603
+ } as TrafficFlow)
604
+ const pairs = [{ source: ep('shop', 'web'), destination: ep('shop', 'api'), port: 8080 }]
605
+ const asDrawn = (e: TrafficFlow['source']) => e
606
+
607
+ it('matches the selected edge, by endpoints, direction and port', () => {
608
+ const on = selectedEdgeMatcher(pairs, true, asDrawn)
609
+ expect(on(record('web', 'api', 8080))).toBe(true)
610
+ expect(on(record('web', 'api', 9090))).toBe(false)
611
+ expect(on(record('api', 'web', 41234))).toBe(false)
612
+ })
613
+
614
+ // A server that doesn't orient responses sends one server to
615
+ // client on the client's ephemeral port; dropping it would leave requests looking
616
+ // unanswered.
617
+ it('keeps a reversed response from a Radar that does not orient responses', () => {
618
+ const on = selectedEdgeMatcher(pairs, false, asDrawn)
619
+ const response = { ...record('api', 'web', 41234), l7Protocol: 'HTTP', l7Type: 'RESPONSE' }
620
+ expect(on(response)).toBe(true)
621
+ expect(on(record('web', 'db', 5432))).toBe(false)
622
+ })
623
+
624
+ it('does not take other traffic between the endpoints, which is on edges of its own', () => {
625
+ const on = selectedEdgeMatcher(pairs, false, asDrawn)
626
+ expect(on(record('web', 'api', 9090))).toBe(false) // a request on another port
627
+ expect(on(record('api', 'web', 5432))).toBe(false) // TCP the other way
628
+ expect(on({ ...record('api', 'web', 41234), l7Protocol: 'HTTP', l7Type: 'REQUEST' })).toBe(false) // the callee calling back
629
+ expect(selectedEdgeMatcher(pairs, true, asDrawn)({ ...record('api', 'web', 41234), l7Protocol: 'HTTP', l7Type: 'RESPONSE' })).toBe(false)
630
+ })
631
+
632
+ // Known and unavoidable: the response carries the client's port, so with
633
+ // two edges web→api on different ports it belongs to either.
634
+ it('matches an old Radar\'s response to each of the caller\'s edges', () => {
635
+ const on = selectedEdgeMatcher([{ source: ep('shop', 'web'), destination: ep('shop', 'api'), port: 9090 }], false, asDrawn)
636
+ expect(on({ ...record('api', 'web', 41234), l7Protocol: 'HTTP', l7Type: 'RESPONSE' })).toBe(true)
637
+ })
638
+
639
+ it('matches everything with no selection', () => {
640
+ expect(selectedEdgeMatcher(null, true, asDrawn)(record('a', 'b', 1))).toBe(true)
641
+ })
642
+ })
643
+
644
+ describe('edgesOnPort', () => {
645
+ // Default view: one client calling 80 and 443 merges into one edge shown on
646
+ // :80, while the Internet node keeps another client's :443 apart, so two
647
+ // edges join the same endpoints.
648
+ const merged: GraphFlow = { ...edge('internet', 'web/ingress'), port: 80, rawPairs: [endpointPair({ ...edge('internet', 'web/ingress'), port: 80 }), endpointPair({ ...edge('internet', 'web/ingress'), port: 443 })] }
649
+ const tls: GraphFlow = { ...edge('internet', 'web/ingress'), port: 443, rawPairs: [endpointPair({ ...edge('internet', 'web/ingress'), port: 443 })] }
650
+
651
+ it('prefers the edge shown on the port over one that only carries it', () => {
652
+ expect(edgesOnPort([merged, tls], f => f, 443)).toEqual([tls])
653
+ expect(edgesOnPort([merged, tls], f => f, 80)).toEqual([merged])
654
+ })
655
+
656
+ it('falls back to an edge the port was merged into', () => {
657
+ expect(edgesOnPort([merged], f => f, 443)).toEqual([merged])
658
+ expect(edgesOnPort([merged, tls], f => f, 8443)).toEqual([])
659
+ })
660
+
661
+ it('keeps a selection on the :443 edge to that edge\'s records', () => {
662
+ const sel = { type: 'edge' as const, sourceId: 'internet', destId: 'web/ingress', port: 443 }
663
+ expect(selectionRawPairs([merged, tls], sel)).toEqual(tls.rawPairs)
664
+ })
665
+ })
666
+
667
+ describe('mergedEdgePort', () => {
668
+ it('shows the lowest port whatever order the parts arrive in', () => {
669
+ expect(mergedEdgePort(443, 80)).toBe(80)
670
+ expect(mergedEdgePort(80, 443)).toBe(80)
671
+ })
672
+
673
+ // Port 0 is "none reported" (ICMP, or a record without one): a few such
674
+ // records must not hide the real port.
675
+ it('ignores port 0 unless no part has a port', () => {
676
+ expect(mergedEdgePort(0, 5432)).toBe(5432)
677
+ expect(mergedEdgePort(5432, 0)).toBe(5432)
678
+ expect(mergedEdgePort(0, 0)).toBe(0)
679
+ })
680
+ })