@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.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
+
const panel = shouldRender && (
|
|
121
140
|
<div
|
|
141
|
+
id={panelId}
|
|
122
142
|
inert={!show || undefined}
|
|
123
143
|
className={clsx(
|
|
124
|
-
'
|
|
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=
|
|
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=
|
|
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
|
+
})
|