@skyhook-io/radar-app 1.15.0 → 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 (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  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.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +232 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +190 -0
  17. package/src/api/radarFeatures.test.ts +133 -0
  18. package/src/api/radarFeatures.ts +147 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +36 -13
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -0
@@ -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.
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, isExternalKind, isPolicyDropReason } from './trafficFilters'
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
4
 
4
5
  describe('matchesStatusRanges', () => {
5
6
  it('does not filter when nothing is selected', () => {
@@ -128,3 +129,155 @@ describe('isPolicyDropReason', () => {
128
129
  expect(isPolicyDropReason('', 0)).toBe(false)
129
130
  })
130
131
  })
132
+
133
+ describe('edge rates', () => {
134
+ it('reads the unrounded rates, so a trickle of errors is not a 100% error rate', () => {
135
+ const flow = { requestRate: 0.3, requestCount: 1, errorRate: 0.01, errorCount: 1 }
136
+ expect(errorRateOf(flow) / requestRateOf(flow)).toBeCloseTo(0.0333, 3)
137
+ })
138
+
139
+ it('falls back to the rounded counts from a server that sends no rates', () => {
140
+ expect(requestRateOf({ requestCount: 7 })).toBe(7)
141
+ expect(errorRateOf({ errorCount: 2 })).toBe(2)
142
+ expect(requestRateOf({})).toBe(0)
143
+ })
144
+
145
+ it('shows the unrounded rate for a rate-based edge, and the connection count otherwise', () => {
146
+ expect(displayVolume({ requestRate: 0.3, connections: 1 }, true)).toBe(0.3)
147
+ expect(displayVolume({ connections: 4 }, true)).toBe(4)
148
+ expect(displayVolume({ requestRate: 0.3, connections: 12 }, false)).toBe(12)
149
+ })
150
+
151
+ it('formats a rate without rounding a trickle away', () => {
152
+ expect(formatRate(0)).toBe('0')
153
+ expect(formatRate(0.004)).toBe('<0.01')
154
+ expect(formatRate(0.3)).toBe('0.30')
155
+ expect(formatRate(2.46)).toBe('2.5')
156
+ expect(formatRate(12.4)).toBe('12')
157
+ expect(formatRate(1530)).toBe('1.5K')
158
+ })
159
+ })
160
+
161
+ describe('mergeFlowVolume', () => {
162
+ it('sums every figure the graph displays, not only connections and bytes', () => {
163
+ const base = { source: { name: 'a', namespace: '', kind: 'External' }, destination: { name: 'web', namespace: 'shop', kind: 'Pod' }, protocol: 'tcp', port: 80, lastSeen: '' }
164
+ const into = { ...base, flowCount: 1, bytesSent: 10, bytesRecv: 20, connections: 2, requestCount: 2, requestRate: 2, errorRate: 0.5, errorCount: 1 }
165
+ mergeFlowVolume(into, { ...base, flowCount: 1, bytesSent: 5, bytesRecv: 5, connections: 8, requestCount: 8, requestRate: 8 })
166
+ expect(into).toMatchObject({ connections: 10, bytesSent: 15, bytesRecv: 25, flowCount: 2, requestCount: 10, requestRate: 10, errorRate: 0.5, errorCount: 1 })
167
+ })
168
+ })
169
+
170
+ describe('mergeFlowVolume latency', () => {
171
+ const base: AggregatedFlow = { source: { name: 'a', namespace: 'shop', kind: 'Pod' }, destination: { name: 'web', namespace: 'shop', kind: 'Pod' }, protocol: 'tcp', port: 80, lastSeen: '', flowCount: 1, bytesSent: 0, bytesRecv: 0, connections: 1 }
172
+
173
+ it('weights average latency by request rate, whatever the order', () => {
174
+ const fast = { ...base, requestRate: 100, avgLatencyMs: 10 }
175
+ const slow = { ...base, requestRate: 1, avgLatencyMs: 1000 }
176
+ const a = { ...fast }
177
+ mergeFlowVolume(a, slow)
178
+ const b = { ...slow }
179
+ mergeFlowVolume(b, fast)
180
+ expect(a.avgLatencyMs).toBeCloseTo(19.8, 1)
181
+ expect(b.avgLatencyMs).toBeCloseTo(19.8, 1)
182
+ })
183
+
184
+ it('ignores traffic that had no latency measured, in any merge order', () => {
185
+ const a = { ...base, requestRate: 1, avgLatencyMs: 10 }
186
+ const b = { ...base, requestRate: 100 }
187
+ const c = { ...base, requestRate: 1, avgLatencyMs: 1000 }
188
+ for (const order of [[a, b, c], [c, b, a], [a, c, b], [b, a, c]]) {
189
+ const into = { ...order[0] }
190
+ for (const f of order.slice(1)) mergeFlowVolume(into, f)
191
+ expect(into.avgLatencyMs).toBeCloseTo(505, 6)
192
+ }
193
+ })
194
+
195
+ it('keeps the weight through a copy of a merged edge', () => {
196
+ const into = { ...base, requestRate: 1, avgLatencyMs: 1000 }
197
+ mergeFlowVolume(into, { ...base, requestRate: 100 })
198
+ const collapsed = { ...into, source: { name: 'Internet', namespace: '', kind: 'Internet' } }
199
+ mergeFlowVolume(collapsed, { ...base, requestRate: 1, avgLatencyMs: 10 })
200
+ expect(collapsed.avgLatencyMs).toBeCloseTo(505, 6)
201
+ })
202
+
203
+ it('averages edges without rates equally, however many are merged', () => {
204
+ const into = { ...base, avgLatencyMs: 10 }
205
+ mergeFlowVolume(into, { ...base, avgLatencyMs: 20 })
206
+ mergeFlowVolume(into, { ...base, avgLatencyMs: 30 })
207
+ expect(into.avgLatencyMs).toBeCloseTo(20, 6)
208
+ })
209
+
210
+ it('takes the only latency there is', () => {
211
+ const into = { ...base }
212
+ mergeFlowVolume(into, { ...base, avgLatencyMs: 7 })
213
+ expect(into.avgLatencyMs).toBe(7)
214
+ })
215
+ })
216
+
217
+ describe('dedupeHTTPPairs', () => {
218
+ const ep = (name: string) => ({ name, namespace: 'shop', kind: 'Pod' })
219
+ const rec = (from: string, to: string, l7Type: string, port = 80): TrafficFlow => ({
220
+ source: ep(from), destination: ep(to), protocol: 'tcp', port, l7Protocol: 'HTTP', l7Type,
221
+ httpMethod: 'GET', httpPath: '/orders', bytesSent: 0, bytesRecv: 0, connections: 1, verdict: 'forwarded', lastSeen: '',
222
+ } as TrafficFlow)
223
+ const rows = (fs: TrafficFlow[]) => fs.map(f => `${f.source.name}>${f.destination.name}:${f.port}:${f.l7Type}`)
224
+
225
+ describe('from a server whose responses run caller → callee', () => {
226
+ it('pairs a response with its request', () => {
227
+ expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST'), rec('a', 'b', 'RESPONSE')], true))).toEqual(['a>b:80:RESPONSE'])
228
+ })
229
+ it('keeps an unanswered call the other way on the same route', () => {
230
+ expect(rows(dedupeHTTPPairs([rec('a', 'b', 'RESPONSE'), rec('b', 'a', 'REQUEST')], true))).toEqual(['a>b:80:RESPONSE', 'b>a:80:REQUEST'])
231
+ })
232
+ it('keeps an unanswered call on another port', () => {
233
+ expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST'), rec('a', 'b', 'RESPONSE'), rec('a', 'b', 'REQUEST', 8080)], true)))
234
+ .toEqual(['a>b:80:RESPONSE', 'a>b:8080:REQUEST'])
235
+ })
236
+ })
237
+
238
+ describe('from an older server whose responses run server → client', () => {
239
+ it('pairs the reversed response with its request', () => {
240
+ expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST'), rec('b', 'a', 'RESPONSE', 41732)], false))).toEqual(['b>a:41732:RESPONSE'])
241
+ })
242
+ })
243
+
244
+ it('keeps a request that got no response', () => {
245
+ expect(rows(dedupeHTTPPairs([rec('a', 'b', 'REQUEST')], true))).toEqual(['a>b:80:REQUEST'])
246
+ })
247
+ })
248
+
249
+ describe('merged latency', () => {
250
+ const base: AggregatedFlow = { source: { name: 'a', namespace: 'shop', kind: 'Pod' }, destination: { name: 'web', namespace: 'shop', kind: 'Pod' }, protocol: 'tcp', port: 80, lastSeen: '', flowCount: 1, bytesSent: 0, bytesRecv: 0, connections: 1 }
251
+
252
+ it('drops percentiles once two edges with latency are merged', () => {
253
+ const into = { ...base, avgLatencyMs: 10, latencySamples: 90, latencyP50Ms: 9, latencyP95Ms: 30, latencyP99Ms: 40 }
254
+ mergeFlowVolume(into, { ...base, avgLatencyMs: 100, latencySamples: 10, latencyP50Ms: 90, latencyP95Ms: 300, latencyP99Ms: 400 })
255
+ expect(into.latencyP50Ms).toBeUndefined()
256
+ expect(into.latencyP95Ms).toBeUndefined()
257
+ expect(into.avgLatencyMs).toBeCloseTo(19, 6)
258
+ })
259
+
260
+ it('keeps the percentiles of the only edge that had latency', () => {
261
+ const into = { ...base }
262
+ mergeFlowVolume(into, { ...base, avgLatencyMs: 10, latencySamples: 5, latencyP95Ms: 30 })
263
+ expect(into.latencyP95Ms).toBe(30)
264
+ mergeFlowVolume(into, { ...base })
265
+ expect(into.latencyP95Ms).toBe(30)
266
+ })
267
+
268
+ it('weights a response-level edge by its samples', () => {
269
+ expect(latencyWeightOf({ ...base, avgLatencyMs: 5, latencySamples: 42 })).toBe(42)
270
+ expect(latencyWeightOf({ ...base, avgLatencyMs: 5, requestRate: 3, latencySamples: 42 })).toBe(3)
271
+ expect(latencyWeightOf({ ...base })).toBe(0)
272
+ })
273
+ })
274
+
275
+ describe('coverageLabel', () => {
276
+ it('says how far back the flows reach from when they were collected', () => {
277
+ expect(coverageLabel('2026-09-30T08:37:24Z', '2026-09-30T08:39:04Z')).toBe('last 1m 40s')
278
+ expect(coverageLabel('2026-09-30T08:38:59Z', '2026-09-30T08:39:04Z')).toBe('last 5s')
279
+ })
280
+ it('is empty when the window is fully covered', () => {
281
+ expect(coverageLabel(undefined, '2026-09-30T08:39:04Z')).toBeNull()
282
+ })
283
+ })
@@ -1,3 +1,5 @@
1
+ import type { AggregatedFlow, TrafficFlow } from '../../types'
2
+
1
3
  /**
2
4
  * Does a flow fall into one of the selected HTTP status ranges?
3
5
  *
@@ -51,6 +53,115 @@ export function isRateBasedSource(source: string | undefined): boolean {
51
53
  return source === 'istio' || source === 'beyla'
52
54
  }
53
55
 
56
+ /**
57
+ * The request and 5xx rates of an edge from a rate-based source, unrounded.
58
+ *
59
+ * requestCount / errorCount carry the same figures rounded with a floor of one,
60
+ * which keeps a trickle visible but makes their ratio meaningless at low rates:
61
+ * 0.3 req/s with 0.01 err/s rounds to one of each, a 100% error rate. The counts
62
+ * are only read when the rates are absent — Radar Hub renders clusters whose
63
+ * Radar predates requestRate / errorRate, and for those they are all there is.
64
+ */
65
+ export function requestRateOf(flow: Pick<AggregatedFlow, 'requestRate' | 'requestCount'>): number {
66
+ return flow.requestRate ?? flow.requestCount ?? 0
67
+ }
68
+ export function errorRateOf(flow: Pick<AggregatedFlow, 'errorRate' | 'errorCount'>): number {
69
+ return flow.errorRate ?? flow.errorCount ?? 0
70
+ }
71
+
72
+ /**
73
+ * The volume shown for an edge. For a rate-based source `connections` is its
74
+ * request rate rounded with a floor of one — the graph sizes, sorts and filters
75
+ * on that integer — so what is printed is the unrounded rate where the edge has
76
+ * one. An edge with no HTTP rate (plain TCP) keeps its connection figure.
77
+ */
78
+ export function displayVolume(flow: Pick<AggregatedFlow, 'requestRate' | 'connections'>, isRateBased: boolean): number {
79
+ return isRateBased && flow.requestRate ? flow.requestRate : flow.connections
80
+ }
81
+
82
+ /**
83
+ * A merged edge records the weight behind its average latency on itself, so a
84
+ * later `{...flow}` copy — the Internet collapse, addon grouping — carries it.
85
+ * Client-only: the server never sends it.
86
+ */
87
+ type LatencyWeighted = AggregatedFlow & { latencyWeight?: number }
88
+
89
+ /**
90
+ * How much traffic an edge's average latency stands for: its request rate, or
91
+ * one for an edge with no rate, summed through merges. Zero when it has none.
92
+ * Used to average latencies so a slow trickle does not stand for a busy path.
93
+ */
94
+ export function latencyWeightOf(flow: AggregatedFlow): number {
95
+ const merged = (flow as LatencyWeighted).latencyWeight
96
+ if (merged !== undefined) return merged
97
+ return flow.avgLatencyMs ? (flow.requestRate || flow.latencySamples || 1) : 0
98
+ }
99
+
100
+ /**
101
+ * Fold one aggregated flow's volume into another's, for the client-side merges
102
+ * that collapse several edges into one. Every figure displayVolume or the error
103
+ * rate reads has to be summed here: a field left out keeps only the first
104
+ * merged edge's value.
105
+ */
106
+ export function mergeFlowVolume(into: AggregatedFlow, flow: AggregatedFlow): void {
107
+ // A metric-based source reports only an average latency per edge, so the
108
+ // merged edge's is theirs weighted by request rate. The weight behind a merged
109
+ // average is carried apart from the edge's request rate, which also counts
110
+ // traffic that had no latency measured: weighting by that would make the
111
+ // result depend on the order the edges arrived in.
112
+ const wInto = latencyWeightOf(into)
113
+ const wFlow = latencyWeightOf(flow)
114
+ if (wFlow > 0) {
115
+ if (wInto > 0) {
116
+ into.avgLatencyMs = (into.avgLatencyMs! * wInto + flow.avgLatencyMs! * wFlow) / (wInto + wFlow)
117
+ // Percentiles of two edges cannot be combined into a percentile of both,
118
+ // so a merged edge that had latency on both sides keeps only the average.
119
+ delete into.latencyP50Ms
120
+ delete into.latencyP95Ms
121
+ delete into.latencyP99Ms
122
+ } else {
123
+ into.avgLatencyMs = flow.avgLatencyMs
124
+ into.latencyP50Ms = flow.latencyP50Ms
125
+ into.latencyP95Ms = flow.latencyP95Ms
126
+ into.latencyP99Ms = flow.latencyP99Ms
127
+ }
128
+ }
129
+ const weighted: LatencyWeighted = into
130
+ weighted.latencyWeight = wInto + wFlow
131
+ into.connections += flow.connections
132
+ into.bytesSent += flow.bytesSent
133
+ into.bytesRecv += flow.bytesRecv
134
+ into.flowCount += flow.flowCount
135
+ if (flow.requestCount) into.requestCount = (into.requestCount || 0) + flow.requestCount
136
+ if (flow.errorCount) into.errorCount = (into.errorCount || 0) + flow.errorCount
137
+ if (flow.requestRate) into.requestRate = (into.requestRate || 0) + flow.requestRate
138
+ if (flow.errorRate) into.errorRate = (into.errorRate || 0) + flow.errorRate
139
+ }
140
+
141
+ /**
142
+ * How much of the requested window the flows cover, as a short label: "last
143
+ * 1m 40s". Null when the whole window is covered. Measured back from when the
144
+ * data was collected, not from now.
145
+ */
146
+ export function coverageLabel(coveredSince: string | undefined, collectedAt: string | undefined): string | null {
147
+ if (!coveredSince || !collectedAt) return null
148
+ const seconds = Math.max(0, Math.round((Date.parse(collectedAt) - Date.parse(coveredSince)) / 1000))
149
+ if (!Number.isFinite(seconds)) return null
150
+ const m = Math.floor(seconds / 60)
151
+ const s = seconds % 60
152
+ return `last ${m > 0 ? `${m}m ${s}s` : `${s}s`}`
153
+ }
154
+
155
+ /** A per-second rate, precise enough to tell a trickle from nothing: 0.30, 12, 1.2K. */
156
+ export function formatRate(rate: number): string {
157
+ if (rate >= 1000) return `${(rate / 1000).toFixed(1)}K`
158
+ if (rate >= 10) return rate.toFixed(0)
159
+ if (rate >= 1) return rate.toFixed(1)
160
+ if (rate >= 0.01) return rate.toFixed(2)
161
+ if (rate > 0) return '<0.01'
162
+ return '0'
163
+ }
164
+
54
165
  /**
55
166
  * Narrow a set of chosen filter values to the ones still offered.
56
167
  *
@@ -146,3 +257,33 @@ export function isPolicyDropReason(dropReasonDesc: string | undefined, deniedByC
146
257
  const code = (dropReasonDesc ?? '').toUpperCase()
147
258
  return code === 'POLICY_DENIED' || code === 'POLICY_DENY'
148
259
  }
260
+
261
+ /**
262
+ * Drop each HTTP REQUEST record that has a matching RESPONSE: the response
263
+ * carries the status and latency, so it stands for the call. A request with no
264
+ * response stays, because a missing response is worth seeing.
265
+ *
266
+ * `callerOriented` is the server saying its responses run caller → callee on
267
+ * the server's port, like their requests, so they match on endpoints, route and
268
+ * port exactly. Without it the server is a Radar that predates that — Radar Hub
269
+ * renders clusters running such builds — whose responses run server → client on
270
+ * the client's ephemeral port, so they match reversed and without the port.
271
+ * The orientation is taken from the server, never inferred from which records
272
+ * happen to be in the window.
273
+ */
274
+ export function dedupeHTTPPairs(flows: TrafficFlow[], callerOriented: boolean): TrafficFlow[] {
275
+ const isHTTP = (f: TrafficFlow, type: string) => f.l7Protocol === 'HTTP' && f.l7Type === type
276
+ const call = (from: string, to: string, f: TrafficFlow) =>
277
+ callerOriented
278
+ ? `${from}|${to}|${f.httpMethod}|${f.httpPath}|${f.port}`
279
+ : `${from}|${to}|${f.httpMethod}|${f.httpPath}`
280
+
281
+ const answered = new Set<string>()
282
+ for (const f of flows) {
283
+ if (!isHTTP(f, 'RESPONSE')) continue
284
+ answered.add(callerOriented
285
+ ? call(f.source.name, f.destination.name, f)
286
+ : call(f.destination.name, f.source.name, f))
287
+ }
288
+ return flows.filter(f => !(isHTTP(f, 'REQUEST') && answered.has(call(f.source.name, f.destination.name, f))))
289
+ }
@@ -543,7 +543,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
543
543
  >
544
544
  <SearchPillInput
545
545
  className={hero
546
- ? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]`
546
+ ? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500,var(--accent))] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500,var(--accent))_15%,transparent)]`
547
547
  : `min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-[color,background-color,border-color] duration-[140ms] ${TW_EASE_UI}`}
548
548
  inputClassName={hero ? 'text-lg py-4' : undefined}
549
549
  text={text}