@skyhook-io/radar-app 1.14.6 → 1.16.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 (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. package/src/utils/version.ts +11 -0
@@ -23,7 +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, isExternalKind } from './trafficFilters'
26
+ import { isRateBasedSource, isExternalKind, requestRateOf, errorRateOf, formatRate, displayVolume, latencyWeightOf } from './trafficFilters'
27
27
 
28
28
  const elk = new ELK()
29
29
 
@@ -74,6 +74,12 @@ function formatConnections(count: number): string {
74
74
  return count.toString()
75
75
  }
76
76
 
77
+ // A volume in the source's own unit: a rate to two significant places for a
78
+ // rate-based source, a whole count otherwise.
79
+ function formatVolume(volume: number, isRateBased: boolean): string {
80
+ return isRateBased ? formatRate(volume) : formatConnections(volume)
81
+ }
82
+
77
83
  function formatLatency(ms: number): string {
78
84
  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`
79
85
  if (ms >= 1) return `${ms.toFixed(1)}ms`
@@ -293,7 +299,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
293
299
  ? 'text-white/60'
294
300
  : 'text-theme-text-tertiary'
295
301
  )}>
296
- {formatConnections(portInfo.connections)}{data.connLabel === 'req/s' ? '/s' : ''}
302
+ {formatVolume(portInfo.connections, data.connLabel === 'req/s')}{data.connLabel === 'req/s' ? '/s' : ''}
297
303
  </span>
298
304
  )}
299
305
  </div>
@@ -319,7 +325,7 @@ function TrafficNode({ data }: { data: TrafficNodeData }) {
319
325
  ? 'text-white/70'
320
326
  : 'text-theme-text-tertiary'
321
327
  )}>
322
- {formatConnections(data.totalConnections)} {data.connLabel || 'conn'}
328
+ {formatVolume(data.totalConnections, data.connLabel === 'req/s')} {data.connLabel || 'conn'}
323
329
  </span>
324
330
  </div>
325
331
  )}
@@ -460,7 +466,7 @@ function DetailsPanel({
460
466
  totalBytes: relatedFlows.reduce((sum, f) => sum + f.bytesSent + f.bytesRecv, 0),
461
467
  protocols: relatedFlows.reduce((acc, f) => {
462
468
  const proto = f.protocol?.toUpperCase() || 'TCP'
463
- acc[proto] = (acc[proto] || 0) + f.connections
469
+ acc[proto] = (acc[proto] || 0) + displayVolume(f, isRateBased)
464
470
  return acc
465
471
  }, {} as Record<string, number>),
466
472
  lastSeen: relatedFlows.reduce((latest, f) => {
@@ -468,22 +474,28 @@ function DetailsPanel({
468
474
  return !latest || f.lastSeen > latest ? f.lastSeen : latest
469
475
  }, null as string | null),
470
476
  flowCount: relatedFlows.reduce((sum, f) => sum + (f.flowCount || 1), 0),
471
- totalRequests: relatedFlows.reduce((sum, f) => sum + (f.requestCount ?? 0), 0),
472
- totalErrors: relatedFlows.reduce((sum, f) => sum + (f.errorCount ?? 0), 0),
477
+ totalRequests: relatedFlows.reduce((sum, f) => sum + requestRateOf(f), 0),
478
+ totalErrors: relatedFlows.reduce((sum, f) => sum + errorRateOf(f), 0),
473
479
  l7Protocols: relatedFlows.reduce((acc, f) => {
474
480
  if (f.l7Protocol) acc.add(f.l7Protocol)
475
481
  return acc
476
482
  }, new Set<string>()),
477
- // Aggregate latency across edges (median of P50s, max of P95s)
478
- latencyP50Ms: (() => {
479
- const p50s = relatedFlows.map(f => f.latencyP50Ms).filter((v): v is number => v != null && v > 0)
480
- if (p50s.length === 0) return undefined
481
- p50s.sort((a, b) => a - b)
482
- return p50s[Math.floor(p50s.length / 2)]
483
+ // Percentiles of separate edges do not combine into a node percentile, so
484
+ // the node shows the slowest edge's P95 as that edge's, named.
485
+ slowestP95: (() => {
486
+ let slowest: AggregatedFlow | undefined
487
+ for (const f of relatedFlows) {
488
+ if (f.latencyP95Ms && (!slowest || f.latencyP95Ms > slowest.latencyP95Ms!)) slowest = f
489
+ }
490
+ return slowest && { ms: slowest.latencyP95Ms!, edge: `${slowest.source.workload || slowest.source.name} → ${slowest.destination.workload || slowest.destination.name}` }
483
491
  })(),
484
- latencyP95Ms: (() => {
485
- const p95s = relatedFlows.map(f => f.latencyP95Ms).filter((v): v is number => v != null && v > 0)
486
- return p95s.length > 0 ? Math.max(...p95s) : undefined
492
+ // A metric-based source reports only an average per edge. The node's is
493
+ // those averages weighted by the traffic each stands for, so a slow trickle
494
+ // does not stand for a node that answers almost everything quickly.
495
+ avgLatencyMs: (() => {
496
+ const total = relatedFlows.reduce((sum, f) => sum + latencyWeightOf(f), 0)
497
+ if (total === 0) return undefined
498
+ return relatedFlows.reduce((sum, f) => sum + (f.avgLatencyMs ?? 0) * latencyWeightOf(f), 0) / total
487
499
  })(),
488
500
  // Aggregate HTTP status distribution
489
501
  httpStatusCounts: relatedFlows.reduce((acc, f) => {
@@ -572,7 +584,7 @@ function DetailsPanel({
572
584
  {nodeData.totalConnections != null && nodeData.totalConnections > 0 && (
573
585
  <div className="text-xs text-theme-text-secondary">
574
586
  {isRateBased ? 'Total request rate' : 'Total connections'}: <span className="text-theme-text-primary font-medium">
575
- {formatConnections(nodeData.totalConnections)}{isRateBased ? '/s' : ''}
587
+ {formatVolume(nodeData.totalConnections, isRateBased)}{isRateBased ? '/s' : ''}
576
588
  </span>
577
589
  </div>
578
590
  )}
@@ -596,14 +608,14 @@ function DetailsPanel({
596
608
  {nodeStats.totalRequests > 0 && (
597
609
  <div className="p-2 rounded bg-theme-elevated text-xs">
598
610
  <div className="text-theme-text-tertiary">Requests</div>
599
- <div className="text-theme-text-primary font-medium">{formatConnections(nodeStats.totalRequests)}/s</div>
611
+ <div className="text-theme-text-primary font-medium">{formatRate(nodeStats.totalRequests)}/s</div>
600
612
  </div>
601
613
  )}
602
614
  {nodeStats.totalErrors > 0 && (
603
615
  <div className="p-2 rounded bg-red-500/10 border border-red-500/30 text-xs">
604
616
  <div className="text-red-400">Errors (5xx)</div>
605
617
  <div className="text-red-400 font-medium">
606
- {formatConnections(nodeStats.totalErrors)}/s
618
+ {formatRate(nodeStats.totalErrors)}/s
607
619
  {nodeStats.totalRequests > 0 && (
608
620
  <span className="text-red-300 ml-1">
609
621
  ({((nodeStats.totalErrors / nodeStats.totalRequests) * 100).toFixed(1)}%)
@@ -626,7 +638,7 @@ function DetailsPanel({
626
638
  .map(([proto, count]) => (
627
639
  <span key={proto} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-bg text-theme-text-secondary">
628
640
  <span className="font-medium">{proto}</span>
629
- <span className="text-theme-text-tertiary">{formatConnections(count)}{isRateBased ? '/s' : ''}</span>
641
+ <span className="text-theme-text-tertiary">{formatVolume(count, isRateBased)}{isRateBased ? '/s' : ''}</span>
630
642
  </span>
631
643
  ))}
632
644
  </div>
@@ -636,16 +648,17 @@ function DetailsPanel({
636
648
  )}
637
649
 
638
650
  {/* Node latency */}
639
- {nodeStats?.latencyP50Ms && (
651
+ {nodeStats?.avgLatencyMs && (
640
652
  <div className="pt-1">
641
- <div className="text-[10px] text-theme-text-tertiary mb-1">Latency</div>
642
- <div className="flex gap-2 text-xs">
643
- <span className={clsx('font-medium', latencyColor(nodeStats.latencyP50Ms))}>
644
- P50: {formatLatency(nodeStats.latencyP50Ms)}
653
+ <div className="text-[10px] text-theme-text-tertiary mb-1">Latency across its edges</div>
654
+ <div className="flex flex-col gap-0.5 text-xs">
655
+ <span className={clsx('font-medium', latencyColor(nodeStats.avgLatencyMs))}>
656
+ Avg: {formatLatency(nodeStats.avgLatencyMs)}
645
657
  </span>
646
- {nodeStats.latencyP95Ms && (
647
- <span className={clsx('font-medium', latencyColor(nodeStats.latencyP95Ms))}>
648
- P95: {formatLatency(nodeStats.latencyP95Ms)}
658
+ {nodeStats.slowestP95 && (
659
+ <span className="text-theme-text-secondary min-w-0 truncate">
660
+ Slowest edge P95: <span className={clsx('font-medium', latencyColor(nodeStats.slowestP95.ms))}>{formatLatency(nodeStats.slowestP95.ms)}</span>
661
+ <span className="text-theme-text-tertiary"> ({nodeStats.slowestP95.edge})</span>
649
662
  </span>
650
663
  )}
651
664
  </div>
@@ -712,7 +725,7 @@ function DetailsPanel({
712
725
  </span>
713
726
  {flow.connections > 0 && (
714
727
  <span className="text-theme-text-secondary">
715
- {formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
728
+ {formatVolume(displayVolume(flow, isRateBased), isRateBased)} {isRateBased ? 'req/s' : 'conn'}
716
729
  </span>
717
730
  )}
718
731
  {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
@@ -722,7 +735,7 @@ function DetailsPanel({
722
735
  )}
723
736
  {flow.errorCount != null && flow.errorCount > 0 && (
724
737
  <span className="text-red-400">
725
- {formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
738
+ {isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount)} err`}
726
739
  </span>
727
740
  )}
728
741
  </div>
@@ -764,7 +777,7 @@ function DetailsPanel({
764
777
  </span>
765
778
  {flow.connections > 0 && (
766
779
  <span className="text-theme-text-secondary">
767
- {formatConnections(flow.connections)} {isRateBased ? 'req/s' : 'conn'}
780
+ {formatVolume(displayVolume(flow, isRateBased), isRateBased)} {isRateBased ? 'req/s' : 'conn'}
768
781
  </span>
769
782
  )}
770
783
  {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
@@ -774,7 +787,7 @@ function DetailsPanel({
774
787
  )}
775
788
  {flow.errorCount != null && flow.errorCount > 0 && (
776
789
  <span className="text-red-400">
777
- {formatConnections(flow.errorCount)} err{isRateBased ? '/s' : ''}
790
+ {isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount)} err`}
778
791
  </span>
779
792
  )}
780
793
  </div>
@@ -827,7 +840,7 @@ function DetailsPanel({
827
840
  <div className="p-2 rounded bg-theme-elevated">
828
841
  <div className="text-theme-text-tertiary">{isRateBased ? 'Request Rate' : 'Connections'}</div>
829
842
  <div className="text-theme-text-primary font-medium">
830
- {formatConnections(edgeData.connections)}{isRateBased ? '/s' : ''}
843
+ {formatVolume(edgeData.flow ? displayVolume(edgeData.flow, isRateBased) : edgeData.connections, isRateBased)}{isRateBased ? '/s' : ''}
831
844
  </div>
832
845
  </div>
833
846
  )}
@@ -839,22 +852,22 @@ function DetailsPanel({
839
852
  </div>
840
853
  </div>
841
854
  )}
842
- {edgeData.flow?.requestCount != null && edgeData.flow.requestCount > 0 && (
855
+ {edgeData.flow && requestRateOf(edgeData.flow) > 0 && (
843
856
  <div className="p-2 rounded bg-theme-elevated">
844
857
  <div className="text-theme-text-tertiary">Requests</div>
845
858
  <div className="text-theme-text-primary font-medium">
846
- {formatConnections(edgeData.flow.requestCount)}/s
859
+ {formatRate(requestRateOf(edgeData.flow))}/s
847
860
  </div>
848
861
  </div>
849
862
  )}
850
- {edgeData.flow?.errorCount != null && edgeData.flow.errorCount > 0 && (
863
+ {edgeData.flow && errorRateOf(edgeData.flow) > 0 && (
851
864
  <div className="p-2 rounded bg-red-500/10 border border-red-500/30">
852
865
  <div className="text-red-400">Errors (5xx)</div>
853
866
  <div className="text-red-400 font-medium">
854
- {formatConnections(edgeData.flow.errorCount)}/s
855
- {edgeData.flow.requestCount != null && edgeData.flow.requestCount > 0 && (
867
+ {formatRate(errorRateOf(edgeData.flow))}/s
868
+ {requestRateOf(edgeData.flow) > 0 && (
856
869
  <span className="text-red-300 ml-1">
857
- ({((edgeData.flow.errorCount / edgeData.flow.requestCount) * 100).toFixed(1)}%)
870
+ ({((errorRateOf(edgeData.flow) / requestRateOf(edgeData.flow)) * 100).toFixed(1)}%)
858
871
  </span>
859
872
  )}
860
873
  </div>
@@ -881,6 +894,21 @@ function DetailsPanel({
881
894
  ))}
882
895
  </div>
883
896
  </div>
897
+ ) : edgeData.flow?.avgLatencyMs ? (
898
+ // The source measured an average, not a distribution: there is
899
+ // no P95 to show, and repeating the average under that label
900
+ // would present it as one.
901
+ <div className="pt-2 border-t border-theme-border">
902
+ <div className="text-[10px] text-theme-text-tertiary mb-1.5">Latency</div>
903
+ <div className="grid grid-cols-3 gap-1.5 text-xs">
904
+ <div className="p-1.5 rounded bg-theme-elevated text-center">
905
+ <div className="text-theme-text-tertiary text-[9px]">Avg</div>
906
+ <div className={clsx('font-medium', latencyColor(edgeData.flow.avgLatencyMs))}>
907
+ {formatLatency(edgeData.flow.avgLatencyMs)}
908
+ </div>
909
+ </div>
910
+ </div>
911
+ </div>
884
912
  ) : null}
885
913
 
886
914
  {/* HTTP status distribution */}
@@ -1115,13 +1143,15 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1115
1143
  connectionCounts.set(destId, (connectionCounts.get(destId) || 0) + 1)
1116
1144
 
1117
1145
  // Sum total connections
1118
- totalConnections.set(sourceId, (totalConnections.get(sourceId) || 0) + flow.connections)
1119
- totalConnections.set(destId, (totalConnections.get(destId) || 0) + flow.connections)
1146
+ // Display-only sums: the unrounded rate for a rate-based source.
1147
+ const volume = displayVolume(flow, isRateBased)
1148
+ totalConnections.set(sourceId, (totalConnections.get(sourceId) || 0) + volume)
1149
+ totalConnections.set(destId, (totalConnections.get(destId) || 0) + volume)
1120
1150
 
1121
1151
  // Track ports for destination nodes
1122
1152
  if (!nodePorts.has(destId)) nodePorts.set(destId, new Map())
1123
1153
  const portCounts = nodePorts.get(destId)!
1124
- portCounts.set(flow.port, (portCounts.get(flow.port) || 0) + flow.connections)
1154
+ portCounts.set(flow.port, (portCounts.get(flow.port) || 0) + volume)
1125
1155
 
1126
1156
  // Track hot path nodes (Phase 2.3)
1127
1157
  if (flow.connections >= hotPathThreshold && hotPathThreshold > 0) {
@@ -1289,14 +1319,15 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1289
1319
 
1290
1320
  // Phase 2.2: Edge label - connection count with unit suffix + L7 details
1291
1321
  const connStr = isRateBased
1292
- ? `${formatConnections(flow.connections)}/s`
1322
+ ? `${formatRate(displayVolume(flow, isRateBased))}/s`
1293
1323
  : formatConnections(flow.connections)
1294
1324
  const l7Label = flow.l7Protocol ? `${flow.l7Protocol} · ` : ''
1295
- const latencyLabel = flow.latencyP50Ms ? ` · ${formatLatency(flow.latencyP50Ms)}` : ''
1325
+ const edgeLatency = flow.latencyP50Ms || flow.avgLatencyMs
1326
+ const latencyLabel = edgeLatency ? ` · ${formatLatency(edgeLatency)}` : ''
1296
1327
  // errorCount is a rate for a rate-based source, exactly like connections above.
1297
1328
  // Without the suffix a destination failing once a second reads as one error.
1298
1329
  const errorLabel = hasErrors
1299
- ? ` · ${formatConnections(flow.errorCount ?? 0)} err${isRateBased ? '/s' : ''}`
1330
+ ? ` · ${isRateBased ? `${formatRate(errorRateOf(flow))} err/s` : `${formatConnections(flow.errorCount ?? 0)} err`}`
1300
1331
  : ''
1301
1332
  // A source that measures rates has no count for an edge with no requests on
1302
1333
  // it — a plain TCP conversation, or one whose direction is unknown. Printing
@@ -1471,7 +1502,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1471
1502
 
1472
1503
  // Add edge from addon-internet to addon-group if we have one
1473
1504
  if (addonMode === 'group' && groupEdgeInfo) {
1474
- const { connections } = groupEdgeInfo
1505
+ const { connections, flow: groupFlow } = groupEdgeInfo
1475
1506
  const sourceId = 'addon-internet'
1476
1507
  const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
1477
1508
 
@@ -1481,7 +1512,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1481
1512
  target: 'addon-group',
1482
1513
  type: 'smoothstep',
1483
1514
  animated: isHotEdge,
1484
- label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
1515
+ label: isRateBased ? `${formatRate(displayVolume(groupFlow, isRateBased))}/s` : formatConnections(connections),
1485
1516
  labelBgStyle: {
1486
1517
  fill: '#581c87', // purple-900
1487
1518
  fillOpacity: 0.9,
@@ -1506,7 +1537,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1506
1537
 
1507
1538
  // Add edge from addon-group to kubernetes if we have one
1508
1539
  if (addonMode === 'group' && groupOutEdgeInfo) {
1509
- const { connections, targetId } = groupOutEdgeInfo
1540
+ const { connections, targetId, flow: groupFlow } = groupOutEdgeInfo
1510
1541
  const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
1511
1542
 
1512
1543
  finalEdges.push({
@@ -1515,7 +1546,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
1515
1546
  target: targetId,
1516
1547
  type: 'smoothstep',
1517
1548
  animated: isHotEdge,
1518
- label: isRateBased ? `${formatConnections(connections)}/s` : formatConnections(connections),
1549
+ label: isRateBased ? `${formatRate(displayVolume(groupFlow, isRateBased))}/s` : formatConnections(connections),
1519
1550
  labelBgStyle: {
1520
1551
  fill: '#581c87', // purple-900
1521
1552
  fillOpacity: 0.9,
@@ -6,14 +6,17 @@ import { TrafficWizard } from './TrafficWizard'
6
6
  import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
7
7
  import { TrafficFilterSidebar } from './TrafficFilterSidebar'
8
8
  import { TrafficFlowListProvider } from './TrafficFlowListContext'
9
- import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle } from 'lucide-react'
9
+ import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle, Clock } from 'lucide-react'
10
10
  import { clsx } from 'clsx'
11
11
  import { useQueryClient } from '@tanstack/react-query'
12
12
  import { useDock } from '../dock'
13
13
  import { AlertBanner, EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
14
14
  import { useConnection } from '../../context/ConnectionContext'
15
15
  import { Tooltip } from '../ui/Tooltip'
16
- import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind } from './trafficFilters'
16
+ import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind, mergeFlowVolume, coverageLabel } from './trafficFilters'
17
+
18
+ // Consecutive 2s retries of an empty result that came with a transient warning.
19
+ const MAX_EMPTY_RETRIES = 5
17
20
 
18
21
  // Addon types for filtering
19
22
  export type AddonMode = 'show' | 'group' | 'hide'
@@ -442,19 +445,34 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
442
445
  // an attribute it does not export, traffic it cannot orient. Retrying returns
443
446
  // the same answer, so retrying forever is all cost and no progress.
444
447
  const warningIsPermanent = flowsData?.warningKind === 'partial'
445
-
446
- // Auto-retry when flows return with warning but no data (e.g., port-forward not ready yet)
448
+ const coverage = coverageLabel(flowsData?.coveredSince, flowsData?.timestamp)
449
+ // An 'incomplete' one is a fetch that worked but could not see everything
450
+ // (events lost, nodes unreachable). Not a failure, so not retried at once.
451
+ const warningIsIncomplete = flowsData?.warningKind === 'incomplete'
452
+
453
+ // Auto-retry when flows return with warning but no data (e.g., port-forward not
454
+ // ready yet). Bounded: a warning that keeps coming back — a node the relay
455
+ // cannot reach, a query that keeps failing — is the answer rather than a hiccup,
456
+ // and polling it every 2s forever costs the source a burst of queries each time.
457
+ const emptyRetriesRef = useRef(0)
458
+ // A different question gets its own retries.
459
+ useEffect(() => {
460
+ emptyRetriesRef.current = 0
461
+ }, [namespaces, timeRange])
447
462
  useEffect(() => {
448
- if (
449
- flowsData?.warning &&
450
- !warningIsPermanent &&
451
- (!flowsData.aggregated || flowsData.aggregated.length === 0) &&
452
- !flowsFetching
453
- ) {
454
- const timer = setTimeout(() => refetchFlowsRaw(), 2000)
463
+ const empty = !flowsData?.aggregated || flowsData.aggregated.length === 0
464
+ if (!flowsData?.warning || !empty) {
465
+ emptyRetriesRef.current = 0
466
+ return
467
+ }
468
+ if (!warningIsPermanent && !warningIsIncomplete && !flowsFetching && emptyRetriesRef.current < MAX_EMPTY_RETRIES) {
469
+ const timer = setTimeout(() => {
470
+ emptyRetriesRef.current += 1
471
+ refetchFlowsRaw()
472
+ }, 2000)
455
473
  return () => clearTimeout(timer)
456
474
  }
457
- }, [flowsData, warningIsPermanent, flowsFetching, refetchFlowsRaw])
475
+ }, [flowsData, warningIsPermanent, warningIsIncomplete, flowsFetching, refetchFlowsRaw])
458
476
 
459
477
  // Filter flows based on user preferences
460
478
  // Note: namespace filtering is done server-side via the global namespace selector
@@ -765,17 +783,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
765
783
 
766
784
  const existing = aggregatedMap.get(key)
767
785
  if (existing) {
768
- // Merge connections and bytes
769
- existing.connections += flow.connections
770
- existing.bytesSent += flow.bytesSent
771
- existing.bytesRecv += flow.bytesRecv
772
- existing.flowCount += flow.flowCount
773
- if (flow.requestCount) {
774
- existing.requestCount = (existing.requestCount || 0) + flow.requestCount
775
- }
776
- if (flow.errorCount) {
777
- existing.errorCount = (existing.errorCount || 0) + flow.errorCount
778
- }
786
+ mergeFlowVolume(existing, flow)
779
787
  // Everything merged here shares the key's direction-known state, so the
780
788
  // flag is already correct on the entry that was created first.
781
789
  } else {
@@ -819,11 +827,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
819
827
 
820
828
  const existing = internetFlowsMap.get(destKey)
821
829
  if (existing) {
822
- // Merge into existing "Internet" flow
823
- existing.connections += flow.connections
824
- existing.bytesSent += flow.bytesSent
825
- existing.bytesRecv += flow.bytesRecv
826
- existing.flowCount += flow.flowCount
830
+ mergeFlowVolume(existing, flow)
827
831
  } else {
828
832
  // Create new "Internet" → destination flow
829
833
  internetFlowsMap.set(destKey, {
@@ -852,6 +856,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
852
856
  // Track totals for aggregated edges
853
857
  let addonInternetTotal = 0
854
858
  let addonToK8sTotal = 0
859
+ let addonInternetRate = 0
860
+ let addonToK8sRate = 0
855
861
  const processedFlows: AggregatedFlow[] = []
856
862
 
857
863
  // Check if destination is the kubernetes API server
@@ -868,6 +874,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
868
874
  // Internet → Addon: aggregate into single edge to group
869
875
  if (sourceIsInternet && destIsAddon) {
870
876
  addonInternetTotal += flow.connections
877
+ addonInternetRate += flow.requestRate ?? 0
871
878
  processedFlows.push({
872
879
  ...flow,
873
880
  source: {
@@ -880,6 +887,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
880
887
  // Addon → Kubernetes API: aggregate into single edge from group
881
888
  else if (sourceIsAddon && destIsK8sAPI) {
882
889
  addonToK8sTotal += flow.connections
890
+ addonToK8sRate += flow.requestRate ?? 0
883
891
  processedFlows.push({
884
892
  ...flow,
885
893
  destination: {
@@ -909,6 +917,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
909
917
  protocol: 'tcp',
910
918
  port: 0,
911
919
  connections: addonInternetTotal,
920
+ ...(addonInternetRate > 0 && { requestRate: addonInternetRate }),
912
921
  bytesSent: 0,
913
922
  bytesRecv: 0,
914
923
  flowCount: 1,
@@ -932,6 +941,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
932
941
  protocol: 'tcp',
933
942
  port: 443,
934
943
  connections: addonToK8sTotal,
944
+ ...(addonToK8sRate > 0 && { requestRate: addonToK8sRate }),
935
945
  bytesSent: 0,
936
946
  bytesRecv: 0,
937
947
  flowCount: 1,
@@ -1079,7 +1089,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1079
1089
  }
1080
1090
 
1081
1091
  return (
1082
- <TrafficFlowListProvider flows={listFlows} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
1092
+ <TrafficFlowListProvider flows={listFlows} responsesCallerOriented={flowsData?.l7ResponsesCallerOriented === true} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
1083
1093
  <div className="flex h-full w-full">
1084
1094
  {/* Sidebar */}
1085
1095
  <TrafficFilterSidebar
@@ -1207,6 +1217,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1207
1217
  </button>
1208
1218
  </Tooltip>
1209
1219
  )}
1220
+ {coverage && (
1221
+ // Not a warning: a busy cluster reaches the limit on every
1222
+ // fetch. It says which part of the window the map shows.
1223
+ <Tooltip content={`Radar reads at most ${(flowsData?.nodeFlowLimit ?? 0).toLocaleString()} of the newest flows from each node. At least one node reached that limit before the start of the ${timeRange} window, so its earlier traffic is not included. Other nodes may still show older flows.`}>
1224
+ <div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-secondary tabular-nums">
1225
+ <Clock className="w-3 h-3" /> {coverage} of {timeRange}
1226
+ </div>
1227
+ </Tooltip>
1228
+ )}
1210
1229
  <div className="flex items-center px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary tabular-nums">
1211
1230
  {flowStats.shown}/{flowStats.total}
1212
1231
  </div>
@@ -1233,7 +1252,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1233
1252
  />
1234
1253
  ) : finalFlows.length > 0 ? (
1235
1254
  <>
1236
- {flowsData?.warning && warningIsPermanent && (
1255
+ {flowsData?.warning && (
1237
1256
  // Sits below the two chip rows (both top-3) rather than beside
1238
1257
  // them: centred at that height it would cover the flow count and
1239
1258
  // the refresh control at common widths. role/aria-live because it
@@ -1245,12 +1264,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1245
1264
  >
1246
1265
  <AlertBanner
1247
1266
  variant="warning"
1248
- // Not "incomplete": every warning that reaches here is about a
1249
- // value on an edge that is shown being wrong or absent — a port
1250
- // reported as 0, UDP reported as TCP, received bytes understated.
1251
- // "Incomplete" sends the reader looking for workloads that are
1252
- // missing, which is the one thing none of these mean.
1253
- title="Some values on this map are unreliable"
1267
+ // The title follows the kind. A partial warning is about values
1268
+ // on edges that are shown — a port reported as 0, UDP as TCP, a
1269
+ // 5xx rate that failed to load — so it says "unreliable", not
1270
+ // "incomplete", which would send the reader looking for missing
1271
+ // workloads. An incomplete or transient one beside flows is
1272
+ // exactly that: a stream cut short, events lost, a node the
1273
+ // relay could not reach, a TCP query that failed — edges may
1274
+ // be missing from what is drawn.
1275
+ title={warningIsPermanent ? 'Some values on this map are unreliable' : 'Some traffic may be missing from this map'}
1254
1276
  message={flowsData.warning}
1255
1277
  />
1256
1278
  </div>
@@ -1324,7 +1346,9 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1324
1346
  headline={
1325
1347
  warningIsPermanent
1326
1348
  ? 'No traffic Radar can place on the map'
1327
- : 'Unable to fetch traffic data'
1349
+ : warningIsIncomplete
1350
+ ? 'No traffic seen, but some may be missing'
1351
+ : 'Unable to fetch traffic data'
1328
1352
  }
1329
1353
  body={flowsData.warning}
1330
1354
  className="max-w-md"
@@ -288,8 +288,10 @@ export function TrafficWizard({
288
288
  </div>
289
289
  )}
290
290
 
291
- {/* No recommendation */}
292
- {!recommendation && (
291
+ {/* No recommendation. Not when a source is installed but unusable:
292
+ its banner above is the answer, and "install Cilium with Hubble"
293
+ would tell the user to install what they already have. */}
294
+ {!recommendation && !sourcesData?.detected.some(s => s.status === 'not_found') && (
293
295
  <div className="border border-theme-border rounded-lg p-4 space-y-3">
294
296
  <p className="text-sm text-theme-text-secondary">
295
297
  No traffic source detected. Install Cilium with Hubble for traffic visibility.