@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
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { Component, ReactNode } from 'react'
2
2
  import { AlertTriangle, RefreshCw } from 'lucide-react'
3
+ import { recordUsageEvent } from '../../api/usage-data'
3
4
 
4
5
  interface Props {
5
6
  children: ReactNode
@@ -12,6 +13,8 @@ interface Props {
12
13
  * while nothing is wrong.
13
14
  */
14
15
  resetKey: string | number
16
+ /** Fixed name of the screen inside, counted in usage stats when it crashes. */
17
+ usageLabel?: string
15
18
  }
16
19
 
17
20
  interface State {
@@ -34,6 +37,8 @@ export class ErrorBoundary extends Component<Props, State> {
34
37
 
35
38
  componentDidCatch(error: Error, info: React.ErrorInfo) {
36
39
  console.error('[ErrorBoundary]', error, info.componentStack)
40
+ // Only which screen crashed is counted, never the message.
41
+ if (this.props.usageLabel) recordUsageEvent({ type: 'ui', name: `ui_error:${this.props.usageLabel}` })
37
42
  }
38
43
 
39
44
  handleReset = () => this.setState({ hasError: false, error: null })
@@ -7,6 +7,7 @@ import { TRANSITION_BACKDROP, TRANSITION_MENU, TW_EASE_UI, overlayExitMs, overla
7
7
  import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
8
8
  import { getResourceIcon } from '../../utils/resource-icons'
9
9
  import type { SearchHit, SearchMatchedField } from '../../api/client'
10
+ import { recordUsageEvent } from '../../api/usage-data'
10
11
  import { bestScore, type CommandItem } from './command-items'
11
12
  import { SearchSyntaxHelp } from './SearchSyntaxHelp'
12
13
 
@@ -182,6 +183,21 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
182
183
 
183
184
  useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
184
185
 
186
+ // Usage data: one count per opening, one per opening that searched. Never
187
+ // the query itself.
188
+ const searchedThisOpen = useRef(false)
189
+ useEffect(() => {
190
+ if (!open) return
191
+ searchedThisOpen.current = false
192
+ recordUsageEvent({ type: 'ui', name: 'command_palette' })
193
+ }, [open])
194
+ useEffect(() => {
195
+ if (open && text.trim() && !searchedThisOpen.current) {
196
+ searchedThisOpen.current = true
197
+ recordUsageEvent({ type: 'ui', name: 'search' })
198
+ }
199
+ }, [open, text])
200
+
185
201
  // Hero autofocus parks the cursor in the field on mount (Home's primary
186
202
  // action) but must NOT pop the dropdown — landing on the page shouldn't dim
187
203
  // it behind a command palette. Suppress exactly the programmatic focus; a
@@ -527,7 +543,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
527
543
  >
528
544
  <SearchPillInput
529
545
  className={hero
530
- ? `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)]`
531
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}`}
532
548
  inputClassName={hero ? 'text-lg py-4' : undefined}
533
549
  text={text}
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, useRef, useId } from 'react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Download, X, Copy, Check, RotateCw, ArrowDownToLine, Loader2 } from 'lucide-react'
4
4
  import { useQueryClient } from '@tanstack/react-query'
@@ -13,10 +13,27 @@ import type { DesktopUpdateState } from '../../api/client'
13
13
  import { WithTooltip } from './Tooltip'
14
14
  import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
15
15
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
16
+ import { IN_CLUSTER_UPGRADE_URL } from '../../utils/version'
16
17
 
17
18
  const DISMISSED_KEY = 'radar-update-dismissed'
18
19
 
19
- export function UpdateNotification() {
20
+ // Whether the notice for this release was dismissed. The usage-data card
21
+ // shares the corner and waits for it.
22
+ export function isUpdateDismissed(latestVersion: string): boolean {
23
+ try {
24
+ return localStorage.getItem(DISMISSED_KEY) === latestVersion
25
+ } catch {
26
+ // localStorage unavailable (e.g. Safari private mode)
27
+ return false
28
+ }
29
+ }
30
+
31
+ export function UpdateNotification({ placement = 'floating' }: { placement?: 'floating' | 'settings' }) {
32
+ const inSettings = placement === 'settings'
33
+ const [expanded, setExpanded] = useState(false)
34
+ const containerRef = useRef<HTMLDivElement>(null)
35
+ const triggerRef = useRef<HTMLButtonElement>(null)
36
+ const panelId = useId()
20
37
  const queryClient = useQueryClient()
21
38
  const { data: capabilities } = useCapabilities()
22
39
  const deploymentMode = capabilities ? (capabilities.deployment?.mode ?? 'local') : undefined
@@ -29,9 +46,17 @@ export function UpdateNotification() {
29
46
  const [desktopUpdating, setDesktopUpdating] = useState(false)
30
47
  const startUpdate = useStartDesktopUpdate()
31
48
  const applyUpdate = useApplyDesktopUpdate()
32
- const { data: updateStatus } = useDesktopUpdateStatus(desktopUpdating)
33
-
34
49
  const isDesktop = versionInfo?.installMethod === 'desktop'
50
+ const { data: updateStatus } = useDesktopUpdateStatus(desktopUpdating || (inSettings && expanded && isDesktop))
51
+
52
+ useEffect(() => {
53
+ if (!expanded) return
54
+ const closeOutside = (event: PointerEvent) => {
55
+ if (!containerRef.current?.contains(event.target as Node)) setExpanded(false)
56
+ }
57
+ document.addEventListener('pointerdown', closeOutside)
58
+ return () => document.removeEventListener('pointerdown', closeOutside)
59
+ }, [expanded])
35
60
 
36
61
  // Listen for "Check for Updates" menu item in desktop app.
37
62
  useEffect(() => {
@@ -54,15 +79,8 @@ export function UpdateNotification() {
54
79
 
55
80
  // Check if this version was already dismissed
56
81
  useEffect(() => {
57
- if (versionInfo?.latestVersion) {
58
- try {
59
- const dismissedVersion = localStorage.getItem(DISMISSED_KEY)
60
- if (dismissedVersion === versionInfo.latestVersion) {
61
- setDismissed(true)
62
- }
63
- } catch {
64
- // localStorage unavailable (e.g. Safari private mode)
65
- }
82
+ if (versionInfo?.latestVersion && isUpdateDismissed(versionInfo.latestVersion)) {
83
+ setDismissed(true)
66
84
  }
67
85
  }, [versionInfo?.latestVersion])
68
86
 
@@ -106,22 +124,25 @@ export function UpdateNotification() {
106
124
 
107
125
  // Shared in-cluster viewers get a persistent Home notice instead of a
108
126
  // floating action prompt they may not be able to act on.
109
- const show = !!versionInfo?.updateAvailable && !dismissed && deploymentMode !== undefined && deploymentMode !== 'in-cluster' && deploymentMode !== 'cloud'
127
+ const available = !!versionInfo?.updateAvailable && deploymentMode !== undefined && deploymentMode !== 'cloud'
128
+ const show = available && deploymentMode !== 'in-cluster' && (inSettings ? expanded : !dismissed)
110
129
  // Presence outlives `show` by the menu exit so a dismiss fades the chip out
111
130
  // instead of snapping it away; the enter runs the same transition in reverse.
112
131
  const { shouldRender, isOpen } = useAnimatedUnmount(show, overlayExitMs('menu'))
113
- if (!shouldRender || !versionInfo) {
132
+ if ((!inSettings && !shouldRender) || (inSettings && !available) || !versionInfo) {
114
133
  return null
115
134
  }
116
135
 
117
136
  // Determine what the current effective state is
118
137
  const effectiveState: DesktopUpdateState = updateStatus?.state ?? 'idle'
119
138
 
120
- return (
139
+ const panel = shouldRender && (
121
140
  <div
141
+ id={panelId}
122
142
  inert={!show || undefined}
123
143
  className={clsx(
124
- 'fixed bottom-4 right-4 z-50 max-w-sm bg-theme-surface border border-accent/50 rounded-lg shadow-xl p-4 origin-bottom-right',
144
+ 'bg-theme-surface border border-accent/50 rounded-lg shadow-theme-lg p-4',
145
+ inSettings ? 'absolute top-full left-0 mt-2 z-10 w-[28rem] max-sm:left-4 max-sm:right-4 max-sm:w-auto origin-top-left' : 'fixed bottom-4 right-4 z-50 max-w-sm origin-bottom-right',
125
146
  TRANSITION_MENU,
126
147
  isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-1 scale-[0.97]',
127
148
  !show && 'pointer-events-none',
@@ -138,7 +159,7 @@ export function UpdateNotification() {
138
159
  </h4>
139
160
  <p className="text-xs text-theme-text-secondary mt-1">
140
161
  Radar {versionInfo.latestVersion} is available.{' '}
141
- You're on {versionInfo.currentVersion}.
162
+ {!inSettings && <>You're on {versionInfo.currentVersion}.</>}
142
163
  </p>
143
164
 
144
165
  {/* Desktop: in-app update flow */}
@@ -162,7 +183,7 @@ export function UpdateNotification() {
162
183
  onClick={handleCopyCommand}
163
184
  className="flex items-center gap-2 mt-2 px-2 py-1.5 bg-theme-elevated rounded font-mono text-theme-text-primary hover:bg-theme-surface-hover transition-colors w-full"
164
185
  >
165
- <code className="inline-code flex-1 truncate text-left text-[11px]">{versionInfo.updateCommand}</code>
186
+ <code className={clsx('inline-code flex-1 text-left text-[11px]', inSettings ? 'whitespace-pre-wrap break-all' : 'truncate')}>{versionInfo.updateCommand}</code>
166
187
  <CopyIcon copied={copied} failed={copyFailed} />
167
188
  </button>
168
189
  </WithTooltip>
@@ -197,15 +218,43 @@ export function UpdateNotification() {
197
218
  fixed on the parent already establishes the positioning context. */}
198
219
  {effectiveState !== 'downloading' && effectiveState !== 'applying' && (
199
220
  <button
200
- onClick={handleDismiss}
221
+ onClick={inSettings ? () => { setExpanded(false); triggerRef.current?.focus() } : handleDismiss}
201
222
  className="absolute top-2 right-2 p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
202
- aria-label="Dismiss"
223
+ aria-label={inSettings ? 'Close update details' : 'Dismiss'}
203
224
  >
204
225
  <X className="w-4 h-4" />
205
226
  </button>
206
227
  )}
207
228
  </div>
208
229
  )
230
+
231
+ if (!inSettings) return panel
232
+
233
+ const triggerClass = 'inline-flex items-center gap-1.5 rounded-lg border border-accent/40 bg-accent-muted px-2.5 py-1.5 text-xs font-semibold text-accent-text hover:bg-theme-hover'
234
+ if (deploymentMode === 'in-cluster') return (
235
+ <a href={IN_CLUSTER_UPGRADE_URL} target="_blank" rel="noreferrer" className={triggerClass}>
236
+ <Download aria-hidden="true" className="h-4 w-4" />
237
+ Update available
238
+ </a>
239
+ )
240
+
241
+ return (
242
+ <div ref={containerRef} className="sm:relative" onBlur={event => {
243
+ if (!event.currentTarget.contains(event.relatedTarget)) setExpanded(false)
244
+ }} onKeyDown={event => {
245
+ if (event.key !== 'Escape' || !expanded) return
246
+ event.preventDefault()
247
+ event.stopPropagation()
248
+ setExpanded(false)
249
+ triggerRef.current?.focus()
250
+ }}>
251
+ <button ref={triggerRef} type="button" className={triggerClass} aria-expanded={expanded} aria-controls={panelId} onClick={() => setExpanded(value => !value)}>
252
+ <Download aria-hidden="true" className="h-4 w-4" />
253
+ Update available
254
+ </button>
255
+ {panel}
256
+ </div>
257
+ )
209
258
  }
210
259
 
211
260
  function CopyIcon({ copied, failed }: { copied: boolean; failed: boolean }) {
@@ -15,6 +15,7 @@ import {
15
15
  AlertTriangle,
16
16
  Boxes,
17
17
  Server,
18
+ Megaphone,
18
19
  } from "lucide-react";
19
20
  import { useNamespaces, useContexts } from "../../api/client";
20
21
  import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
@@ -124,6 +125,8 @@ export interface CommandItemCallbacks {
124
125
  onSetNamespaces: (ns: string[]) => void;
125
126
  onToggleTheme: () => void;
126
127
  onShowDiagnostics?: () => void;
128
+ // Present only when release notes exist for this version.
129
+ onShowWhatsNew?: () => void;
127
130
  }
128
131
 
129
132
  const VIEW_ENTRIES: {
@@ -262,6 +265,16 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
262
265
  shortcut: "t",
263
266
  action: () => cb.onToggleTheme(),
264
267
  });
268
+ if (cb.onShowWhatsNew) {
269
+ result.push({
270
+ id: "action-whats-new",
271
+ label: "What's new",
272
+ category: "Actions",
273
+ icon: Megaphone,
274
+ action: () => cb.onShowWhatsNew?.(),
275
+ searchTerms: ["release notes", "changelog", "updates", "new features"],
276
+ });
277
+ }
265
278
  if (cb.onShowDiagnostics) {
266
279
  result.push({
267
280
  id: "action-diagnostics",
@@ -285,5 +298,6 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
285
298
  cb.onSetNamespaces,
286
299
  cb.onToggleTheme,
287
300
  cb.onShowDiagnostics,
301
+ cb.onShowWhatsNew,
288
302
  ]);
289
303
  }
@@ -0,0 +1,41 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import type { UsageDataStatus } from '../../api/usage-data'
4
+ import { exampleReport, exampleStatus } from '../../api/usage-data.fixtures'
5
+
6
+ vi.mock('../../api/usage-data', () => ({
7
+ useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }),
8
+ useMarkUsagePromptShown: () => vi.fn(),
9
+ }))
10
+
11
+ import { UsageDataAsk } from './UsageDataAsk'
12
+
13
+ function status(patch: Partial<UsageDataStatus>): UsageDataStatus {
14
+ return exampleStatus({ preview: exampleReport({ views: {} }), ...patch })
15
+ }
16
+
17
+ const render = (s: UsageDataStatus | undefined) =>
18
+ renderToString(<UsageDataAsk usageData={s} onReadMore={() => {}} />)
19
+
20
+ describe('UsageDataAsk', () => {
21
+ it('asks an undecided user who can decide', () => {
22
+ const html = render(status({}))
23
+ expect(html).toContain('Help improve Radar')
24
+ expect(html).toContain('Anonymous, with no third-party trackers.')
25
+ expect(html).toContain('See what')
26
+ expect(html).toContain('Send usage stats')
27
+ })
28
+
29
+ it('stays out of the way once answered or where the viewer cannot decide', () => {
30
+ expect(render(status({ state: 'on', source: 'user' }))).toBe('')
31
+ expect(render(status({ state: 'off', source: 'user' }))).toBe('')
32
+ expect(render(status({ state: 'off', source: 'deployment', canChange: false }))).toBe('')
33
+ expect(render(status({ state: 'off', source: 'env', canChange: false }))).toBe('')
34
+ expect(render(status({ shared: true, canChange: false, ask: false }))).toBe('')
35
+ expect(render(undefined)).toBe('')
36
+ })
37
+
38
+ it('waits when the server says it asked recently', () => {
39
+ expect(render(status({ ask: false }))).toBe('')
40
+ })
41
+ })