@skyhook-io/radar-app 1.10.0 → 1.12.2

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 (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. package/src/utils/wails-clipboard.ts +127 -0
@@ -17,15 +17,7 @@ import type { AddonMode } from './TrafficView'
17
17
  import { getNamespaceColor } from '../../utils/traffic-colors'
18
18
  import { Tooltip } from '../ui/Tooltip'
19
19
  import { Input } from '@skyhook-io/k8s-ui'
20
-
21
- // Connection threshold options
22
- const CONNECTION_THRESHOLDS = [
23
- { value: 0, label: 'All traffic' },
24
- { value: 100, label: '100+ connections' },
25
- { value: 1000, label: '1K+ connections' },
26
- { value: 10000, label: '10K+ connections' },
27
- { value: 100000, label: '100K+ connections' },
28
- ]
20
+ import { volumeThresholds } from './trafficFilters'
29
21
 
30
22
  // Time range options
31
23
  const TIME_RANGES = [
@@ -63,7 +55,19 @@ interface TrafficFilterSidebarProps {
63
55
  setTimeRange: (v: string) => void
64
56
 
65
57
  // L7 filters (Hubble-only)
66
- isHubble?: boolean
58
+ /** Whether the flows carry any L7 detail at all. Not "is this Hubble": Beyla and
59
+ * Istio report L7 too, and gating on the source name hid these filters from them. */
60
+ showL7Filters?: boolean
61
+ /** Status buckets that can actually match. A source reporting no status
62
+ * distribution still reports an error rate, so 5xx can be offered on the strength
63
+ * of that while the others would match nothing. */
64
+ availableStatusRanges?: string[]
65
+ availableVerdicts?: string[]
66
+ hasDNSQueries?: boolean
67
+ availableHTTPMethods?: string[]
68
+ /** The active source measures rates rather than counting events, which changes
69
+ * both the unit and the useful scale of the volume filter. */
70
+ isRateBased?: boolean
67
71
  l7Protocol: string // 'all' | 'HTTP' | 'DNS' | 'TCP'
68
72
  setL7Protocol: (v: string) => void
69
73
  l7Methods: Set<string>
@@ -154,7 +158,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
154
158
  setDetectServices,
155
159
  timeRange,
156
160
  setTimeRange,
157
- isHubble,
161
+ showL7Filters,
162
+ availableStatusRanges = [],
163
+ availableVerdicts = [],
164
+ hasDNSQueries,
165
+ availableHTTPMethods = [],
166
+ isRateBased,
158
167
  l7Protocol,
159
168
  setL7Protocol,
160
169
  l7Methods,
@@ -209,7 +218,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
209
218
  onChange={(e) => setMinConnections(Number(e.target.value))}
210
219
  className="flex-1 bg-theme-elevated text-theme-text-primary text-xs rounded px-2 py-1.5 border border-theme-border focus:outline-none focus:ring-1 focus:ring-blue-500"
211
220
  >
212
- {CONNECTION_THRESHOLDS.map(({ value, label }) => (
221
+ {volumeThresholds(isRateBased).map(({ value, label }) => (
213
222
  <option key={value} value={value}>{label}</option>
214
223
  ))}
215
224
  </select>
@@ -316,8 +325,10 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
316
325
  </div>
317
326
  </div>
318
327
 
319
- {/* L7 Filters (Hubble only) */}
320
- {isHubble && (
328
+ {/* L7 filters, shown whenever the flows carry L7 detail. Every control below
329
+ is gated on data that exists, so the panel never offers one that cannot
330
+ return anything. */}
331
+ {showL7Filters && (
321
332
  <div className="space-y-2 px-3 py-2 border-t border-theme-border">
322
333
  <div className="flex items-center gap-1.5">
323
334
  <Filter className="w-3 h-3 text-theme-text-tertiary" />
@@ -346,12 +357,13 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
346
357
  </div>
347
358
 
348
359
  {/* HTTP sub-filters (visible when protocol is All or HTTP) */}
349
- {(l7Protocol === 'all' || l7Protocol === 'HTTP') && (
360
+ {(l7Protocol === 'all' || l7Protocol === 'HTTP') && (availableHTTPMethods.length > 0 || availableStatusRanges.length > 0) && (
350
361
  <>
362
+ {availableHTTPMethods.length > 0 && (
351
363
  <div>
352
364
  <div className="text-[10px] text-theme-text-tertiary mb-1">HTTP Method</div>
353
365
  <div className="flex flex-wrap gap-1">
354
- {['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(method => (
366
+ {availableHTTPMethods.map(method => (
355
367
  <button
356
368
  key={method}
357
369
  onClick={() => onToggleL7Method(method)}
@@ -367,7 +379,9 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
367
379
  ))}
368
380
  </div>
369
381
  </div>
382
+ )}
370
383
 
384
+ {availableStatusRanges.length > 0 && (
371
385
  <div>
372
386
  <div className="text-[10px] text-theme-text-tertiary mb-1">Status Code</div>
373
387
  <div className="flex flex-wrap gap-1">
@@ -376,7 +390,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
376
390
  { label: '3xx', active: SEVERITY_BADGE.info },
377
391
  { label: '4xx', active: SEVERITY_BADGE.warning },
378
392
  { label: '5xx', active: SEVERITY_BADGE.error },
379
- ] as const).map(({ label, active }) => (
393
+ ] as const).filter(({ label }) => availableStatusRanges.includes(label)).map(({ label, active }) => (
380
394
  <button
381
395
  key={label}
382
396
  onClick={() => onToggleL7StatusRange(label)}
@@ -392,11 +406,12 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
392
406
  ))}
393
407
  </div>
394
408
  </div>
409
+ )}
395
410
  </>
396
411
  )}
397
412
 
398
413
  {/* DNS sub-filter (visible when protocol is All or DNS) */}
399
- {(l7Protocol === 'all' || l7Protocol === 'DNS') && (
414
+ {(l7Protocol === 'all' || l7Protocol === 'DNS') && hasDNSQueries && (
400
415
  <div>
401
416
  <div className="text-[10px] text-theme-text-tertiary mb-1">DNS Query</div>
402
417
  <Input
@@ -408,7 +423,8 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
408
423
  </div>
409
424
  )}
410
425
 
411
- {/* Verdict (always visible applies to all protocols) */}
426
+ {/* Verdict, limited to the verdicts the data actually contains. */}
427
+ {availableVerdicts.length > 0 && (
412
428
  <div>
413
429
  <div className="text-[10px] text-theme-text-tertiary mb-1">Verdict</div>
414
430
  <div className="flex flex-wrap gap-1">
@@ -416,7 +432,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
416
432
  { label: 'forwarded', active: SEVERITY_BADGE.success },
417
433
  { label: 'dropped', active: SEVERITY_BADGE.error },
418
434
  { label: 'error', active: SEVERITY_BADGE.warning },
419
- ] as const).map(({ label, active }) => (
435
+ ] as const).filter(({ label }) => availableVerdicts.includes(label)).map(({ label, active }) => (
420
436
  <button
421
437
  key={label}
422
438
  onClick={() => onToggleL7Verdict(label)}
@@ -432,6 +448,7 @@ export const TrafficFilterSidebar = memo(function TrafficFilterSidebar({
432
448
  ))}
433
449
  </div>
434
450
  </div>
451
+ )}
435
452
  </div>
436
453
  )}
437
454
 
@@ -181,10 +181,24 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
181
181
  </span>
182
182
  </Tooltip>
183
183
 
184
- {/* Destination */}
185
- <Tooltip content={flow.destination.namespace ? `${flow.destination.namespace}/${flow.destination.name}` : flow.destination.name} wrapperClassName="min-w-0">
184
+ {/* Destination. An unoriented conversation has no caller and no
185
+ callee the two ends are ordered arbitrarily — so it is marked
186
+ rather than presented as a direction the source established. */}
187
+ <Tooltip
188
+ content={
189
+ flow.directionUnknown
190
+ ? 'Direction unknown: this source could not determine which end opened the conversation'
191
+ : flow.destination.namespace
192
+ ? `${flow.destination.namespace}/${flow.destination.name}`
193
+ : flow.destination.name
194
+ }
195
+ wrapperClassName="min-w-0"
196
+ >
186
197
  <span className="truncate text-theme-text-primary">
187
198
  {flow.destination.name}
199
+ {flow.directionUnknown && (
200
+ <span className="ml-1 text-theme-text-tertiary">(direction unknown)</span>
201
+ )}
188
202
  </span>
189
203
  </Tooltip>
190
204
 
@@ -23,6 +23,7 @@ import { isClusterAddon, type AddonMode } from './TrafficView'
23
23
  import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
24
24
  import { getNamespaceColor } from '../../utils/traffic-colors'
25
25
  import { Tooltip } from '../ui/Tooltip'
26
+ import { isRateBasedSource } from './trafficFilters'
26
27
 
27
28
  const elk = new ELK()
28
29
 
@@ -280,16 +281,21 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
280
281
  )}>
281
282
  :{portInfo.port}
282
283
  </span>
283
- <span className={clsx(
284
- 'truncate',
285
- data.isHotPath
286
- ? 'text-orange-400'
287
- : (hasNamespaceColor || isAddonNode)
288
- ? 'text-white/60'
289
- : 'text-theme-text-tertiary'
290
- )}>
291
- {formatConnections(portInfo.connections)}
292
- </span>
284
+ {/* A port serving traffic with no HTTP has no request rate to show, and
285
+ "0" beside the port reads as an idle port rather than an unmeasured
286
+ one. The port itself is still worth listing. */}
287
+ {portInfo.connections > 0 && (
288
+ <span className={clsx(
289
+ 'truncate',
290
+ data.isHotPath
291
+ ? 'text-orange-400'
292
+ : (hasNamespaceColor || isAddonNode)
293
+ ? 'text-white/60'
294
+ : 'text-theme-text-tertiary'
295
+ )}>
296
+ {formatConnections(portInfo.connections)}{data.connLabel === 'req/s' ? '/s' : ''}
297
+ </span>
298
+ )}
293
299
  </div>
294
300
  ))}
295
301
  {data.ports.filter(p => p.port !== 0).length > MAX_VISIBLE_PORTS && (
@@ -303,7 +309,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
303
309
  </div>
304
310
  )}
305
311
  {/* Total connections (only if no ports shown, or all ports are 0) */}
306
- {(!data.ports || data.ports.filter(p => p.port !== 0).length === 0) && data.totalConnections && data.totalConnections > 0 && (
312
+ {(!data.ports || data.ports.filter(p => p.port !== 0).length === 0) && data.totalConnections != null && data.totalConnections > 0 && (
307
313
  <div className="mt-1">
308
314
  <span className={clsx(
309
315
  'text-xs truncate',
@@ -339,6 +345,19 @@ function TrafficLegend() {
339
345
  </svg>
340
346
  <span className="text-theme-text-secondary">HTTP / gRPC</span>
341
347
  </div>
348
+ <div className="flex items-center gap-2">
349
+ <svg width="24" height="8" className="shrink-0" aria-hidden="true">
350
+ <line x1="0" y1="4" x2="20" y2="4" stroke="currentColor" strokeWidth="2" className="text-theme-text-tertiary" />
351
+ <polygon points="20,1 24,4 20,7" className="fill-theme-text-tertiary" />
352
+ </svg>
353
+ <span className="text-theme-text-secondary">Arrow = who opened it</span>
354
+ </div>
355
+ <div className="flex items-center gap-2">
356
+ <svg width="24" height="8" className="shrink-0" aria-hidden="true">
357
+ <line x1="0" y1="4" x2="24" y2="4" stroke="currentColor" strokeWidth="2" className="text-theme-text-tertiary" />
358
+ </svg>
359
+ <span className="text-theme-text-secondary">No arrow = direction unknown</span>
360
+ </div>
342
361
  <div className="flex items-center gap-2">
343
362
  <svg width="24" height="8" className="shrink-0">
344
363
  <line x1="0" y1="4" x2="24" y2="4" stroke="#06b6d4" strokeWidth="2" />
@@ -402,12 +421,14 @@ function DetailsPanel({
402
421
  selection,
403
422
  onClose,
404
423
  flows,
405
- isIstio,
424
+ isRateBased,
406
425
  }: {
407
426
  selection: Selection
408
427
  onClose: () => void
409
428
  flows: AggregatedFlow[]
410
- isIstio: boolean
429
+ // True for sources that measure a rate rather than counting connections —
430
+ // Istio and Beyla both do. Changes the wording and the /s suffix.
431
+ isRateBased: boolean
411
432
  }) {
412
433
  if (!selection) return null
413
434
 
@@ -548,10 +569,10 @@ function DetailsPanel({
548
569
  Service: <span className="text-theme-text-primary capitalize">{nodeData.serviceCategory}</span>
549
570
  </div>
550
571
  )}
551
- {nodeData.totalConnections && (
572
+ {nodeData.totalConnections != null && nodeData.totalConnections > 0 && (
552
573
  <div className="text-xs text-theme-text-secondary">
553
- {isIstio ? 'Total request rate' : 'Total connections'}: <span className="text-theme-text-primary font-medium">
554
- {formatConnections(nodeData.totalConnections)}{isIstio ? '/s' : ''}
574
+ {isRateBased ? 'Total request rate' : 'Total connections'}: <span className="text-theme-text-primary font-medium">
575
+ {formatConnections(nodeData.totalConnections)}{isRateBased ? '/s' : ''}
555
576
  </span>
556
577
  </div>
557
578
  )}
@@ -605,7 +626,7 @@ function DetailsPanel({
605
626
  .map(([proto, count]) => (
606
627
  <span key={proto} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-bg text-theme-text-secondary">
607
628
  <span className="font-medium">{proto}</span>
608
- <span className="text-theme-text-tertiary">{formatConnections(count)}</span>
629
+ <span className="text-theme-text-tertiary">{formatConnections(count)}{isRateBased ? '/s' : ''}</span>
609
630
  </span>
610
631
  ))}
611
632
  </div>
@@ -672,7 +693,15 @@ function DetailsPanel({
672
693
  <span className="text-theme-text-primary truncate flex-1">
673
694
  {flow.source.name}
674
695
  </span>
675
- <ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
696
+ {/* Same rule as the canvas and the edge panel: an edge nobody
697
+ could orient is not drawn with an arrow. */}
698
+ {flow.directionUnknown ? (
699
+ <Tooltip content="Neither end was reported as the initiator">
700
+ <span className="text-theme-text-tertiary shrink-0">&mdash;</span>
701
+ </Tooltip>
702
+ ) : (
703
+ <ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
704
+ )}
676
705
  {flow.port !== 0 && (
677
706
  <span className="text-blue-400 font-mono">:{flow.port}</span>
678
707
  )}
@@ -681,17 +710,19 @@ function DetailsPanel({
681
710
  <span className="px-1 py-0.5 rounded bg-theme-bg text-theme-text-tertiary uppercase">
682
711
  {flow.protocol || 'tcp'}
683
712
  </span>
684
- <span className="text-theme-text-secondary">
685
- {formatConnections(flow.connections)} {isIstio ? 'req/s' : 'conn'}
686
- </span>
713
+ {flow.connections > 0 && (
714
+ <span className="text-theme-text-secondary">
715
+ {formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
716
+ </span>
717
+ )}
687
718
  {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
688
719
  <span className="text-theme-text-tertiary">
689
720
  {formatBytes(flow.bytesSent + flow.bytesRecv)}
690
721
  </span>
691
722
  )}
692
- {flow.errorCount && flow.errorCount > 0 && (
723
+ {flow.errorCount != null && flow.errorCount > 0 && (
693
724
  <span className="text-red-400">
694
- {formatConnections(flow.errorCount)} err
725
+ {formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
695
726
  </span>
696
727
  )}
697
728
  </div>
@@ -713,7 +744,13 @@ function DetailsPanel({
713
744
  .map((flow, i) => (
714
745
  <div key={i} className="text-xs p-2 rounded bg-theme-elevated space-y-1">
715
746
  <div className="flex items-center gap-1.5">
716
- <ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
747
+ {flow.directionUnknown ? (
748
+ <Tooltip content="Neither end was reported as the initiator">
749
+ <span className="text-theme-text-tertiary shrink-0">&mdash;</span>
750
+ </Tooltip>
751
+ ) : (
752
+ <ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
753
+ )}
717
754
  <span className="text-theme-text-primary truncate flex-1">
718
755
  {flow.destination.name}
719
756
  </span>
@@ -725,17 +762,19 @@ function DetailsPanel({
725
762
  <span className="px-1 py-0.5 rounded bg-theme-bg text-theme-text-tertiary uppercase">
726
763
  {flow.protocol || 'tcp'}
727
764
  </span>
728
- <span className="text-theme-text-secondary">
729
- {formatConnections(flow.connections)} {isIstio ? 'req/s' : 'conn'}
730
- </span>
765
+ {flow.connections > 0 && (
766
+ <span className="text-theme-text-secondary">
767
+ {formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
768
+ </span>
769
+ )}
731
770
  {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
732
771
  <span className="text-theme-text-tertiary">
733
772
  {formatBytes(flow.bytesSent + flow.bytesRecv)}
734
773
  </span>
735
774
  )}
736
- {flow.errorCount && flow.errorCount > 0 && (
775
+ {flow.errorCount != null && flow.errorCount > 0 && (
737
776
  <span className="text-red-400">
738
- {formatConnections(flow.errorCount)} err
777
+ {formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
739
778
  </span>
740
779
  )}
741
780
  </div>
@@ -753,7 +792,16 @@ function DetailsPanel({
753
792
  <div className="space-y-2">
754
793
  <div className="flex items-center gap-2 text-sm">
755
794
  <span className="text-theme-text-primary truncate">{edgeData.source.split('/').pop()}</span>
756
- <ArrowRight className="h-4 w-4 text-theme-text-tertiary shrink-0" />
795
+ {edgeData.flow?.directionUnknown ? (
796
+ // The graph omits the arrowhead on this edge because nothing
797
+ // established which end opened the conversation. An arrow here
798
+ // would assert exactly what the graph is declining to claim.
799
+ <Tooltip content="Neither end was reported as the initiator">
800
+ <span className="text-theme-text-tertiary shrink-0">&mdash;</span>
801
+ </Tooltip>
802
+ ) : (
803
+ <ArrowRight className="h-4 w-4 text-theme-text-tertiary shrink-0" />
804
+ )}
757
805
  <span className="text-theme-text-primary truncate">{edgeData.target.split('/').pop()}</span>
758
806
  </div>
759
807
 
@@ -772,12 +820,17 @@ function DetailsPanel({
772
820
  : edgeData.protocol}
773
821
  </div>
774
822
  </div>
775
- <div className="p-2 rounded bg-theme-elevated">
776
- <div className="text-theme-text-tertiary">{isIstio ? 'Request Rate' : 'Connections'}</div>
777
- <div className="text-theme-text-primary font-medium">
778
- {formatConnections(edgeData.connections)}{isIstio ? '/s' : ''}
823
+ {(!isRateBased || edgeData.connections > 0) && (
824
+ // A rate-based source reports no request rate for traffic with no
825
+ // HTTP, and "0/s" beside a data figure in megabytes reads as "no
826
+ // traffic" rather than "not measured".
827
+ <div className="p-2 rounded bg-theme-elevated">
828
+ <div className="text-theme-text-tertiary">{isRateBased ? 'Request Rate' : 'Connections'}</div>
829
+ <div className="text-theme-text-primary font-medium">
830
+ {formatConnections(edgeData.connections)}{isRateBased ? '/s' : ''}
831
+ </div>
779
832
  </div>
780
- </div>
833
+ )}
781
834
  {edgeData.flow && (edgeData.flow.bytesSent > 0 || edgeData.flow.bytesRecv > 0) && (
782
835
  <div className="p-2 rounded bg-theme-elevated">
783
836
  <div className="text-theme-text-tertiary">Data</div>
@@ -786,7 +839,7 @@ function DetailsPanel({
786
839
  </div>
787
840
  </div>
788
841
  )}
789
- {edgeData.flow?.requestCount && edgeData.flow.requestCount > 0 && (
842
+ {edgeData.flow?.requestCount != null && edgeData.flow.requestCount > 0 && (
790
843
  <div className="p-2 rounded bg-theme-elevated">
791
844
  <div className="text-theme-text-tertiary">Requests</div>
792
845
  <div className="text-theme-text-primary font-medium">
@@ -794,12 +847,12 @@ function DetailsPanel({
794
847
  </div>
795
848
  </div>
796
849
  )}
797
- {edgeData.flow?.errorCount && edgeData.flow.errorCount > 0 && (
850
+ {edgeData.flow?.errorCount != null && edgeData.flow.errorCount > 0 && (
798
851
  <div className="p-2 rounded bg-red-500/10 border border-red-500/30">
799
852
  <div className="text-red-400">Errors (5xx)</div>
800
853
  <div className="text-red-400 font-medium">
801
854
  {formatConnections(edgeData.flow.errorCount)}/s
802
- {edgeData.flow.requestCount && edgeData.flow.requestCount > 0 && (
855
+ {edgeData.flow.requestCount != null && edgeData.flow.requestCount > 0 && (
803
856
  <span className="text-red-300 ml-1">
804
857
  ({((edgeData.flow.errorCount / edgeData.flow.requestCount) * 100).toFixed(1)}%)
805
858
  </span>
@@ -910,17 +963,23 @@ function DetailsPanel({
910
963
  {edgeData.flow && (
911
964
  <div className="space-y-1 pt-2 border-t border-theme-border">
912
965
  <div className="text-xs text-theme-text-secondary">
913
- Source: <span className="text-theme-text-primary">{edgeData.flow.source.name}</span>
966
+ {edgeData.flow.directionUnknown ? 'Endpoint' : 'Source'}: <span className="text-theme-text-primary">{edgeData.flow.source.name}</span>
914
967
  {edgeData.flow.source.namespace && (
915
968
  <span className="text-theme-text-tertiary"> ({edgeData.flow.source.namespace})</span>
916
969
  )}
917
970
  </div>
918
971
  <div className="text-xs text-theme-text-secondary">
919
- Destination: <span className="text-theme-text-primary">{edgeData.flow.destination.name}</span>
972
+ {edgeData.flow.directionUnknown ? 'Endpoint' : 'Destination'}: <span className="text-theme-text-primary">{edgeData.flow.destination.name}</span>
920
973
  {edgeData.flow.destination.namespace && (
921
974
  <span className="text-theme-text-tertiary"> ({edgeData.flow.destination.namespace})</span>
922
975
  )}
923
976
  </div>
977
+ {edgeData.flow.directionUnknown && (
978
+ <div className="text-xs text-theme-text-tertiary mt-1">
979
+ Which endpoint opened this conversation was not reported, so the
980
+ two are listed in a fixed order rather than as caller and callee.
981
+ </div>
982
+ )}
924
983
  {edgeData.flow.destination.kind.toLowerCase() === 'external' && (
925
984
  <div className="text-xs text-yellow-400 mt-1">
926
985
  External service
@@ -998,9 +1057,20 @@ function FitViewOnChange({
998
1057
  return null
999
1058
  }
1000
1059
 
1060
+ // A pair can carry both oriented and unoriented traffic at the same port, and the
1061
+ // backend keeps those as separate aggregates because no single answer about the
1062
+ // direction is right for both. Without the suffix they would share an edge id and
1063
+ // one would silently replace the other. Only unoriented edges are suffixed, so
1064
+ // every id a source that always orients its flows produces is unchanged.
1065
+ function trafficEdgeId(sourceId: string, destId: string, flow: AggregatedFlow): string {
1066
+ return `${sourceId}->${destId}:${flow.port}${flow.directionUnknown ? ':unoriented' : ''}`
1067
+ }
1068
+
1001
1069
  export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', onSelectionChange }: TrafficGraphProps) {
1002
- const isIstio = trafficSource === 'istio'
1003
- const connLabel = isIstio ? 'req/s' : 'conn'
1070
+ // Beyla is rate-based too: its Connections field carries requests per second,
1071
+ // not a connection count, so it needs the same label Istio gets.
1072
+ const isRateBased = isRateBasedSource(trafficSource)
1073
+ const connLabel = isRateBased ? 'req/s' : 'conn'
1004
1074
  const [layoutedNodes, setLayoutedNodes] = useState<Node<TrafficNodeData>[]>([])
1005
1075
  const [layoutedEdges, setLayoutedEdges] = useState<Edge[]>([])
1006
1076
  const [selection, setSelection] = useState<Selection | null>(null)
@@ -1015,7 +1085,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1015
1085
  const destId = flow.destination.namespace
1016
1086
  ? `${flow.destination.namespace}/${flow.destination.name}`
1017
1087
  : flow.destination.name
1018
- const edgeId = `${sourceId}->${destId}:${flow.port}`
1088
+ const edgeId = trafficEdgeId(sourceId, destId, flow)
1019
1089
  map.set(edgeId, flow)
1020
1090
  })
1021
1091
  return map
@@ -1198,7 +1268,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1198
1268
  }
1199
1269
 
1200
1270
  // Create edge with visual encoding (Phase 2.1, 2.2, 2.3)
1201
- const edgeId = `${sourceId}->${destId}:${flow.port}`
1271
+ const edgeId = trafficEdgeId(sourceId, destId, flow)
1202
1272
  const isHotEdge = flow.connections >= hotPathThreshold && hotPathThreshold > 0
1203
1273
  const hasErrors = (flow.errorCount ?? 0) > 0
1204
1274
 
@@ -1218,20 +1288,34 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1218
1288
  const strokeWidth = getEdgeWidth(flow.connections)
1219
1289
 
1220
1290
  // Phase 2.2: Edge label - connection count with unit suffix + L7 details
1221
- const connStr = isIstio
1291
+ const connStr = isRateBased
1222
1292
  ? `${formatConnections(flow.connections)}/s`
1223
1293
  : formatConnections(flow.connections)
1224
1294
  const l7Label = flow.l7Protocol ? `${flow.l7Protocol} · ` : ''
1225
1295
  const latencyLabel = flow.latencyP50Ms ? ` · ${formatLatency(flow.latencyP50Ms)}` : ''
1296
+ // errorCount is a rate for a rate-based source, exactly like connections above.
1297
+ // Without the suffix a destination failing once a second reads as one error.
1226
1298
  const errorLabel = hasErrors
1227
- ? ` · ${formatConnections(flow.errorCount ?? 0)} err`
1299
+ ? ` · ${formatConnections(flow.errorCount ?? 0)} err${isRateBased ? '/s' : ''}`
1228
1300
  : ''
1229
- const edgeLabel = `${l7Label}${connStr}${latencyLabel}${errorLabel}`
1301
+ // A source that measures rates has no count for an edge with no requests on
1302
+ // it — a plain TCP conversation, or one whose direction is unknown. Printing
1303
+ // "0/s" there reads as no traffic, on an edge that may be carrying megabytes.
1304
+ const countLabel = isRateBased && !flow.connections ? '' : connStr
1305
+ const edgeLabel = [l7Label + countLabel, latencyLabel, errorLabel]
1306
+ .join('')
1307
+ .replace(/^ · /, '')
1230
1308
 
1231
1309
  edgeList.push({
1232
1310
  id: edgeId,
1233
1311
  source: sourceId,
1234
1312
  target: destId,
1313
+ // React Flow's default is "Edge from A to B", which states a direction this
1314
+ // edge is deliberately drawn without. Assistive technology should hear the
1315
+ // same uncertainty the missing arrowhead conveys.
1316
+ ariaLabel: flow.directionUnknown
1317
+ ? `Connection between ${sourceId} and ${destId}, direction unknown`
1318
+ : `Edge from ${sourceId} to ${destId}`,
1235
1319
  type: 'smoothstep',
1236
1320
  animated: isHotEdge, // Animate hot paths
1237
1321
  label: edgeLabel,
@@ -1249,12 +1333,20 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1249
1333
  stroke: strokeColor,
1250
1334
  ...(hasDrops && { strokeDasharray: '6 3' }),
1251
1335
  },
1252
- markerEnd: {
1253
- type: MarkerType.ArrowClosed,
1254
- width: 16,
1255
- height: 16,
1256
- color: strokeColor,
1257
- },
1336
+ // No arrowhead when the initiator is unknown. Beyla labels both halves of a
1337
+ // UDP conversation the same way, so an arrow would be asserting something
1338
+ // the data does not say; the edge still carries the traffic in both
1339
+ // directions.
1340
+ ...(flow.directionUnknown
1341
+ ? {}
1342
+ : {
1343
+ markerEnd: {
1344
+ type: MarkerType.ArrowClosed,
1345
+ width: 16,
1346
+ height: 16,
1347
+ color: strokeColor,
1348
+ },
1349
+ }),
1258
1350
  })
1259
1351
  })
1260
1352
 
@@ -1264,7 +1356,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1264
1356
  addonGroupEdge, // Pass this for adding after group is created
1265
1357
  addonGroupOutEdge, // Pass this for adding group → kubernetes edge
1266
1358
  }
1267
- }, [flows, hotPathThreshold, showNamespaceGroups, serviceCategories, addonMode, isIstio, connLabel])
1359
+ }, [flows, hotPathThreshold, showNamespaceGroups, serviceCategories, addonMode, isRateBased, connLabel])
1268
1360
 
1269
1361
  // Apply ELK layout
1270
1362
  const applyLayout = useCallback(async () => {
@@ -1389,7 +1481,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1389
1481
  target: 'addon-group',
1390
1482
  type: 'smoothstep',
1391
1483
  animated: isHotEdge,
1392
- label: formatConnections(connections),
1484
+ label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
1393
1485
  labelBgStyle: {
1394
1486
  fill: '#581c87', // purple-900
1395
1487
  fillOpacity: 0.9,
@@ -1423,7 +1515,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1423
1515
  target: targetId,
1424
1516
  type: 'smoothstep',
1425
1517
  animated: isHotEdge,
1426
- label: formatConnections(connections),
1518
+ label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
1427
1519
  labelBgStyle: {
1428
1520
  fill: '#581c87', // purple-900
1429
1521
  fillOpacity: 0.9,
@@ -1462,7 +1554,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1462
1554
  setLayoutedNodes(positionedNodes)
1463
1555
  setLayoutedEdges(rawEdges)
1464
1556
  }
1465
- }, [rawNodes, rawEdges, addonMode, addonGroupEdge, addonGroupOutEdge, hotPathThreshold])
1557
+ }, [rawNodes, rawEdges, addonMode, addonGroupEdge, addonGroupOutEdge, hotPathThreshold, isRateBased])
1466
1558
 
1467
1559
  // Run layout when flows change
1468
1560
  useEffect(() => {
@@ -1556,7 +1648,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1556
1648
  selection={selection}
1557
1649
  onClose={() => setSelection(null)}
1558
1650
  flows={flows}
1559
- isIstio={isIstio}
1651
+ isRateBased={isRateBased}
1560
1652
  />
1561
1653
  )}
1562
1654
  </div>