@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
@@ -0,0 +1,190 @@
1
+ // @vitest-environment jsdom
2
+ import { act, useEffect } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
6
+ import { getRadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
7
+ import { ApiError, isNotFoundError, useApplications, useCapacityPoolDetail, useDrainPlan, usePodEnvironment } from './client'
8
+ import { getApiBase } from './config'
9
+ import { usePolicyResource } from './policy'
10
+ import { isRadarFeatureUnsupported } from './radarFeatures'
11
+
12
+ // By default the host knows the agent as v1.7.2 before /capabilities,
13
+ // /version-check and /api-resources answer; the tests settle those by seeding
14
+ // the cache.
15
+ const host = vi.hoisted(() => ({ radarVersion: 'v1.7.2' as string | undefined }))
16
+ vi.mock('../context/RadarUpgradeHost', () => ({ useRadarUpgradeHost: () => host }))
17
+
18
+ const CHI_404 = () => new Response('404 page not found\n', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
19
+ let chiRoutes: string[] = []
20
+
21
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
22
+ const requested: string[] = []
23
+ let element: HTMLDivElement
24
+ let root: ReturnType<typeof createRoot>
25
+ let client: QueryClient
26
+
27
+ beforeEach(() => {
28
+ requested.length = 0
29
+ host.radarVersion = 'v1.7.2'
30
+ chiRoutes = []
31
+ vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
32
+ const url = String(input)
33
+ requested.push(url)
34
+ if (chiRoutes.some((route) => url.includes(route))) return Promise.resolve(CHI_404())
35
+ if (url.includes('/capacity/pools/')) return Promise.resolve(Response.json({ pool: { name: 'default' } }))
36
+ if (url.endsWith('/environment')) return Promise.resolve(Response.json({ containers: [], coverage: {} }))
37
+ if (url.includes('/policy/resource/')) return Promise.resolve(Response.json({ evaluated: true, status: 'ready' }))
38
+ return new Promise<Response>(() => {})
39
+ }))
40
+ element = document.createElement('div')
41
+ root = createRoot(element)
42
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
43
+ })
44
+
45
+ afterEach(async () => {
46
+ await act(async () => root.unmount())
47
+ client.clear()
48
+ vi.unstubAllGlobals()
49
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
50
+ })
51
+
52
+ async function render(node: React.ReactNode) {
53
+ await act(async () => { root.render(<QueryClientProvider client={client}>{node}</QueryClientProvider>) })
54
+ await settle()
55
+ }
56
+
57
+ // Lets queries started by the last render run to completion.
58
+ async function settle() {
59
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
60
+ }
61
+
62
+ const asked = (suffix: string) => requested.some((url) => url.includes(suffix))
63
+
64
+ function Environment() {
65
+ const { data, error } = usePodEnvironment('shop', 'web')
66
+ return <span>{data ? 'data' : getRadarUpgradeRequirement(error) ? 'upgrade' : 'pending'}</span>
67
+ }
68
+
69
+ function Policy() {
70
+ const { data, error, isLoading, isError } = usePolicyResource('pods', 'shop', 'web')
71
+ if (isLoading) return <span>loading</span>
72
+ if (data && isError) return <span>inconsistent</span>
73
+ return <span>{data?.status ?? (getRadarUpgradeRequirement(error) ? 'upgrade' : 'pending')}</span>
74
+ }
75
+
76
+ it('fetches for real once the agent turns out to advertise the feature', async () => {
77
+ await render(<Environment />)
78
+ expect(element.textContent).toBe('upgrade')
79
+ expect(asked('/environment')).toBe(false)
80
+
81
+ await act(async () => { client.setQueryData(['capabilities'], { features: { podEnvironment: true } }) })
82
+ await settle()
83
+ await settle()
84
+ expect(asked('/environment')).toBe(true)
85
+ expect(element.textContent).toBe('data')
86
+ })
87
+
88
+ it('turns an unsupported policy answer into not installed once discovery shows no policy engine', async () => {
89
+ await render(<Policy />)
90
+ // No upgrade note yet: discovery may still withdraw it.
91
+ expect(element.textContent).toBe('loading')
92
+
93
+ await act(async () => { client.setQueryData(['api-resources'], [{ group: '', name: 'pods' }]) })
94
+ await settle()
95
+ expect(element.textContent).toBe('not_installed')
96
+ expect(asked('/policy/resource/')).toBe(false)
97
+ })
98
+
99
+ it('keeps the upgrade note when discovery shows an OpenReports policy engine', async () => {
100
+ client.setQueryData(['api-resources'], [{ group: 'openreports.io', name: 'reports' }])
101
+ await render(<Policy />)
102
+ expect(element.textContent).toBe('upgrade')
103
+ })
104
+
105
+ it('shows the upgrade note when discovery fails', async () => {
106
+ const answer = vi.mocked(fetch).getMockImplementation()!
107
+ vi.mocked(fetch).mockImplementation((input, init) =>
108
+ String(input).endsWith('/api-resources')
109
+ ? Promise.resolve(Response.json({ error: 'discovery down' }, { status: 500 }))
110
+ : answer(input, init),
111
+ )
112
+ await render(<Policy />)
113
+ await settle()
114
+ expect(element.textContent).toBe('upgrade')
115
+ })
116
+
117
+ function PoolDetail() {
118
+ const { data, error } = useCapacityPoolDetail('default')
119
+ if (data) return <span>data</span>
120
+ if (isNotFoundError(error)) return <span>pool not found</span>
121
+ return <span>{getRadarUpgradeRequirement(error) ? 'upgrade' : 'pending'}</span>
122
+ }
123
+
124
+ it('never asks an old Radar for a capacity deep link, and does not call the pool missing', async () => {
125
+ await render(<PoolDetail />)
126
+ expect(element.textContent).toBe('upgrade')
127
+ expect(asked('/capacity/')).toBe(false)
128
+ })
129
+
130
+ it("reads the router's 404 on a capacity deep link as unsupported when the version is unknown", async () => {
131
+ host.radarVersion = undefined
132
+ chiRoutes = ['/capacity/pools/']
133
+ await render(<PoolDetail />)
134
+ expect(element.textContent).toBe('upgrade')
135
+ expect(requested.filter((url) => url.includes('/capacity/pools/'))).toHaveLength(1)
136
+ })
137
+
138
+ it("prefers the agent's own version over a stale host version", async () => {
139
+ await render(<PoolDetail />)
140
+ expect(element.textContent).toBe('upgrade')
141
+
142
+ await act(async () => { client.setQueryData(['version-check', getApiBase()], { currentVersion: 'v1.12.0' }) })
143
+ await settle()
144
+ await settle()
145
+ expect(asked('/capacity/pools/default')).toBe(true)
146
+ expect(element.textContent).toBe('data')
147
+ })
148
+
149
+ const probe: { drainPlan?: ReturnType<typeof useDrainPlan> } = {}
150
+ function DrainPlanProbe() {
151
+ const drainPlan = useDrainPlan()
152
+ useEffect(() => { probe.drainPlan = drainPlan })
153
+ return null
154
+ }
155
+
156
+ it('never asks a Radar older than v1.14 for a drain plan', async () => {
157
+ host.radarVersion = 'v1.13.1'
158
+ await render(<DrainPlanProbe />)
159
+ const error = await probe.drainPlan!.mutateAsync({ name: 'worker-1', options: { deleteEmptyDirData: false, force: false } })
160
+ .catch((e: unknown) => e)
161
+ expect(isRadarFeatureUnsupported(error, 'drainPlan')).toBe(true)
162
+ expect(asked('/drain-plan')).toBe(false)
163
+ })
164
+
165
+ it("reads the router's 404 on a drain plan as unsupported, and a handler 404 as an error", async () => {
166
+ host.radarVersion = undefined
167
+ chiRoutes = ['/nodes/worker-1/drain-plan']
168
+ await render(<DrainPlanProbe />)
169
+ const options = { deleteEmptyDirData: false, force: false }
170
+ const unsupported = await probe.drainPlan!.mutateAsync({ name: 'worker-1', options }).catch((e: unknown) => e)
171
+ expect(isRadarFeatureUnsupported(unsupported, 'drainPlan')).toBe(true)
172
+
173
+ vi.mocked(fetch).mockImplementationOnce(() =>
174
+ Promise.resolve(Response.json({ error: 'nodes "worker-2" not found' }, { status: 404 })),
175
+ )
176
+ const missing = await probe.drainPlan!.mutateAsync({ name: 'worker-2', options }).catch((e: unknown) => e)
177
+ expect(missing).toBeInstanceOf(ApiError)
178
+ expect(isRadarFeatureUnsupported(missing)).toBe(false)
179
+ })
180
+
181
+ function Applications() {
182
+ const { data, error } = useApplications([])
183
+ return <span>{data ? 'data' : getRadarUpgradeRequirement(error) ? 'upgrade' : 'pending'}</span>
184
+ }
185
+
186
+ it('never asks a Radar older than v1.8 for applications', async () => {
187
+ await render(<Applications />)
188
+ expect(element.textContent).toBe('upgrade')
189
+ expect(asked('/applications')).toBe(false)
190
+ })
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { getRadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
3
+ import { ApiError } from './client'
4
+ import {
5
+ RADAR_FEATURES,
6
+ RadarFeatureUnsupportedError,
7
+ guardRadarFeature,
8
+ isRadarFeatureUnsupported,
9
+ radarFeatureSupport,
10
+ radarSpecSupport,
11
+ type RadarFeatureSpec,
12
+ shouldRetryRadarQuery,
13
+ } from './radarFeatures'
14
+
15
+ describe('radarFeatureSupport', () => {
16
+ it('trusts the advertised flag over the version', () => {
17
+ expect(radarFeatureSupport('policyResource', { features: { policyResource: true } }, 'v1.7.2')).toBe('supported')
18
+ expect(radarFeatureSupport('policyResource', { features: { policyResource: true } }, undefined)).toBe('supported')
19
+ })
20
+
21
+ it('gates a legacy agent that predates the flag by its release version', () => {
22
+ expect(radarFeatureSupport('resourceIssues', undefined, 'v1.7.2')).toBe('unsupported')
23
+ expect(radarFeatureSupport('resourceIssues', undefined, 'v1.8.0')).toBe('supported')
24
+ expect(radarFeatureSupport('podEnvironment', { features: {} }, '1.8.7')).toBe('unsupported')
25
+ expect(radarFeatureSupport('podEnvironment', { features: {} }, '1.9.0')).toBe('supported')
26
+ // v1.10 advertised a features block, just not this flag.
27
+ expect(radarFeatureSupport('policyResource', { features: { yamlReview: true } }, 'v1.9.2')).toBe('unsupported')
28
+ expect(radarFeatureSupport('policyResource', { features: { yamlReview: true } }, 'v1.12.0')).toBe('supported')
29
+ })
30
+
31
+ it('never gates on a version it cannot place', () => {
32
+ for (const version of [undefined, '', 'dev', 'unknown', 'v1.7.2-dirty', 'v1.7.2-3-gdeadbee', 'v1.10.0-rc.1', 'v1.7']) {
33
+ expect(radarFeatureSupport('policyResource', undefined, version)).toBe('unknown')
34
+ }
35
+ })
36
+
37
+ it('gates features without a flag on version alone', () => {
38
+ expect(radarFeatureSupport('drainPlan', { features: { yamlReview: true } }, 'v1.13.1')).toBe('unsupported')
39
+ expect(radarFeatureSupport('drainPlan', undefined, 'v1.14.0')).toBe('supported')
40
+ expect(radarFeatureSupport('capacity', undefined, 'dev')).toBe('unknown')
41
+ expect(radarFeatureSupport('applications', undefined, 'v1.7.6')).toBe('unsupported')
42
+ expect(radarFeatureSupport('applications', undefined, 'v1.7.7')).toBe('supported')
43
+ })
44
+
45
+ it('keeps the unflagged entries to those that predate the first flag', () => {
46
+ // radarSpecSupport treats them as served by any Radar advertising
47
+ // resourceIssues; a new entry must ship its own flag instead.
48
+ const unflagged = Object.entries(RADAR_FEATURES).filter(([, spec]) => !('flag' in spec)).map(([name]) => name)
49
+ expect(unflagged.sort()).toEqual(['applications', 'capacity', 'drainPlan', 'upgradeReadiness'])
50
+ })
51
+
52
+ it('lifts a stale host version for unflagged features once any table flag is advertised', () => {
53
+ expect(radarFeatureSupport('drainPlan', { features: { resourceIssues: true } }, 'v1.7.2')).toBe('supported')
54
+ expect(radarFeatureSupport('capacity', { features: { resourceIssues: true } }, 'v1.7.2')).toBe('supported')
55
+ expect(radarFeatureSupport('upgradeReadiness', { features: { resourceIssues: true } }, undefined)).toBe('supported')
56
+ })
57
+ })
58
+
59
+ describe('radarSpecSupport for a feature whose flag shipped with its endpoint', () => {
60
+ const driftAlerts: RadarFeatureSpec = { label: 'Drift alerts', flag: 'resourceIssues', flagShippedWithEndpoint: true }
61
+
62
+ it('reads a loaded answer without the flag as too old, whatever the version', () => {
63
+ expect(radarSpecSupport(driftAlerts, { features: {} }, 'dev')).toBe('unsupported')
64
+ expect(radarSpecSupport(driftAlerts, {}, 'v1.7.2')).toBe('unsupported')
65
+ expect(radarSpecSupport(driftAlerts, { features: { resourceIssues: true } }, 'v1.7.2')).toBe('supported')
66
+ })
67
+
68
+ it('waits for /api/capabilities before deciding, and never version-gates without a first release', () => {
69
+ expect(radarSpecSupport(driftAlerts, undefined, 'v1.7.2')).toBe('unknown')
70
+ })
71
+ })
72
+
73
+ describe('guardRadarFeature', () => {
74
+ const versions = { currentVersion: 'v1.7.2', latestVersion: 'v1.15.0' }
75
+
76
+ it('never asks a Radar known to be too old', async () => {
77
+ const request = vi.fn(() => Promise.resolve('data'))
78
+ await expect(guardRadarFeature('policyResource', 'unsupported', versions, request))
79
+ .rejects.toBeInstanceOf(RadarFeatureUnsupportedError)
80
+ expect(request).not.toHaveBeenCalled()
81
+ })
82
+
83
+ it("reads the router's unknown-route 404 as unsupported", async () => {
84
+ const error = await guardRadarFeature('policyResource', 'unknown', versions, () =>
85
+ Promise.reject(new ApiError('HTTP 404 (Not Found)', 404, {}, { unknownRoute: true })),
86
+ ).catch((e: unknown) => e)
87
+ expect(isRadarFeatureUnsupported(error, 'policyResource')).toBe(true)
88
+ expect(getRadarUpgradeRequirement(error)).toEqual({
89
+ feature: 'Policy results',
90
+ minimumVersion: 'v1.10.0',
91
+ currentVersion: 'v1.7.2',
92
+ latestVersion: 'v1.15.0',
93
+ })
94
+ })
95
+
96
+ it('passes every other failure through untouched', async () => {
97
+ const handler404 = new ApiError('pods "web" not found', 404, { error: 'pods "web" not found' })
98
+ await expect(guardRadarFeature('policyResource', 'unknown', versions, () => Promise.reject(handler404)))
99
+ .rejects.toBe(handler404)
100
+ const hubNotFound = new ApiError('HTTP 404 (Not Found)', 404)
101
+ await expect(guardRadarFeature('policyResource', 'supported', versions, () => Promise.reject(hubNotFound)))
102
+ .rejects.toBe(hubNotFound)
103
+ expect(getRadarUpgradeRequirement(hubNotFound)).toBeNull()
104
+ })
105
+
106
+ it("keeps the router's 404 an error on a Radar known to serve the feature", async () => {
107
+ const routing = new ApiError('HTTP 404 (Not Found)', 404, {}, { unknownRoute: true })
108
+ const error = await guardRadarFeature('policyResource', 'supported', versions, () => Promise.reject(routing))
109
+ .catch((e: unknown) => e)
110
+ expect(error).toBe(routing)
111
+ expect(getRadarUpgradeRequirement(error)).toBeNull()
112
+ })
113
+
114
+ it('returns the data when the request succeeds', async () => {
115
+ await expect(guardRadarFeature('policyResource', 'unknown', {}, () => Promise.resolve(42))).resolves.toBe(42)
116
+ })
117
+ })
118
+
119
+ describe('shouldRetryRadarQuery', () => {
120
+ it('does not retry an unsupported feature or a settled client error', () => {
121
+ expect(shouldRetryRadarQuery(0, new RadarFeatureUnsupportedError('resourceIssues', {}))).toBe(false)
122
+ for (const status of [400, 401, 403, 404, 409]) {
123
+ expect(shouldRetryRadarQuery(0, new ApiError('x', status))).toBe(false)
124
+ }
125
+ })
126
+
127
+ it('retries timeouts, rate limits, server errors and network failures once', () => {
128
+ for (const error of [new ApiError('x', 408), new ApiError('x', 429), new ApiError('x', 503), new TypeError('Failed to fetch')]) {
129
+ expect(shouldRetryRadarQuery(0, error)).toBe(true)
130
+ expect(shouldRetryRadarQuery(1, error)).toBe(false)
131
+ }
132
+ })
133
+ })
@@ -0,0 +1,147 @@
1
+ import type { FeatureCapabilities, RadarUpgradeRequirement } from '@skyhook-io/k8s-ui'
2
+ import { compareVersions } from '../utils/version'
3
+
4
+ // Radar Hub embeds the newest @skyhook-io/radar-app against whatever Radar is
5
+ // installed in each cluster, so these endpoints can be missing on a connected
6
+ // Radar. A Radar that advertises the feature's `features` flag is always
7
+ // trusted; otherwise the release that first served the endpoint decides.
8
+ //
9
+ // Adding an optional endpoint: advertise a flag for it in
10
+ // FeatureCapabilities (internal/k8s/capabilities.go) in the same change, add
11
+ // an entry here with that flag and `flagShippedWithEndpoint: true`, and guard
12
+ // its hook with useRadarFeature. TestFeatureFlagsHaveFrontendGates keeps the
13
+ // two lists in step. `minimumVersion` can wait for the release to be cut:
14
+ // until then the note asks for the latest Radar.
15
+ export interface RadarFeatureSpec {
16
+ /** Plural noun phrase; the upgrade note reads "<label> need Radar vX or newer." */
17
+ label: string
18
+ /** First release that served the endpoint, once known. */
19
+ minimumVersion?: string
20
+ flag?: keyof FeatureCapabilities
21
+ /**
22
+ * The flag shipped in the same release as the endpoint, so a Radar whose
23
+ * capabilities omit it is too old regardless of its version. False for the
24
+ * entries below, whose flags were added after their endpoints.
25
+ */
26
+ flagShippedWithEndpoint?: boolean
27
+ }
28
+
29
+ export const RADAR_FEATURES = {
30
+ resourceIssues: { label: 'Operational issues', minimumVersion: 'v1.8.0', flag: 'resourceIssues' },
31
+ podEnvironment: { label: 'Values from ConfigMaps and Secrets', minimumVersion: 'v1.9.0', flag: 'podEnvironment' },
32
+ policyResource: { label: 'Policy results', minimumVersion: 'v1.10.0', flag: 'policyResource' },
33
+ capacity: { label: 'Capacity views', minimumVersion: 'v1.9.0' },
34
+ upgradeReadiness: { label: 'Upgrade impact reports', minimumVersion: 'v1.9.0' },
35
+ drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' },
36
+ applications: { label: 'Applications', minimumVersion: 'v1.7.7' },
37
+ } as const satisfies Record<string, RadarFeatureSpec>
38
+
39
+ export type RadarFeature = keyof typeof RADAR_FEATURES
40
+
41
+ export type RadarFeatureSupport = 'supported' | 'unsupported' | 'unknown'
42
+
43
+ export interface RadarVersions {
44
+ currentVersion?: string
45
+ latestVersion?: string
46
+ }
47
+
48
+ // Only a plain release can be placed against a minimum. Dev, dirty and
49
+ // pre-release builds stay unknown so a custom build is never wrongly gated.
50
+ const RELEASE_VERSION = /^v?\d+\.\d+\.\d+$/
51
+
52
+ /**
53
+ * `capabilities` is the /api/capabilities answer, or undefined while it is
54
+ * still loading; only a loaded answer can prove a flag is absent.
55
+ */
56
+ export function radarSpecSupport(
57
+ spec: RadarFeatureSpec,
58
+ capabilities: { features?: FeatureCapabilities } | undefined,
59
+ currentVersion: string | undefined,
60
+ ): RadarFeatureSupport {
61
+ if (spec.flag && capabilities?.features?.[spec.flag] === true) return 'supported'
62
+ // Unflagged entries all predate the first flag added for this table, so a
63
+ // Radar that advertises it serves them, whatever version the host reported.
64
+ if (!spec.flag && capabilities?.features?.resourceIssues === true) return 'supported'
65
+ if (spec.flag && spec.flagShippedWithEndpoint && capabilities) return 'unsupported'
66
+ const version = currentVersion?.trim()
67
+ if (!spec.minimumVersion || !version || !RELEASE_VERSION.test(version)) return 'unknown'
68
+ const order = compareVersions(version, spec.minimumVersion)
69
+ if (order === null) return 'unknown'
70
+ return order < 0 ? 'unsupported' : 'supported'
71
+ }
72
+
73
+ export function radarFeatureSupport(
74
+ feature: RadarFeature,
75
+ capabilities: { features?: FeatureCapabilities } | undefined,
76
+ currentVersion: string | undefined,
77
+ ): RadarFeatureSupport {
78
+ return radarSpecSupport(RADAR_FEATURES[feature], capabilities, currentVersion)
79
+ }
80
+
81
+ export function radarUpgradeRequirement(feature: RadarFeature, versions: RadarVersions): RadarUpgradeRequirement {
82
+ const spec: RadarFeatureSpec = RADAR_FEATURES[feature]
83
+ return {
84
+ feature: spec.label,
85
+ ...(spec.minimumVersion ? { minimumVersion: spec.minimumVersion } : {}),
86
+ ...(versions.currentVersion ? { currentVersion: versions.currentVersion } : {}),
87
+ ...(versions.latestVersion ? { latestVersion: versions.latestVersion } : {}),
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The connected Radar predates `feature`. Carries `radarUpgrade` so k8s-ui
93
+ * components can render the upgrade note without importing this class.
94
+ */
95
+ export class RadarFeatureUnsupportedError extends Error {
96
+ readonly feature: RadarFeature
97
+ readonly radarUpgrade: RadarUpgradeRequirement
98
+
99
+ constructor(feature: RadarFeature, versions: RadarVersions) {
100
+ const requirement = radarUpgradeRequirement(feature, versions)
101
+ super(`${requirement.feature} need a newer Radar`)
102
+ this.name = 'RadarFeatureUnsupportedError'
103
+ this.feature = feature
104
+ this.radarUpgrade = requirement
105
+ }
106
+ }
107
+
108
+ export function isRadarFeatureUnsupported(error: unknown, feature?: RadarFeature): boolean {
109
+ return error instanceof RadarFeatureUnsupportedError && (feature === undefined || error.feature === feature)
110
+ }
111
+
112
+ /**
113
+ * Runs `request` unless the connected Radar is known to predate `feature`.
114
+ * When its version is unknown, chi's unknown-route 404 (ApiError.unknownRoute)
115
+ * is read as the same answer; on a Radar known to serve the feature that 404
116
+ * is a routing failure and stays an error.
117
+ */
118
+ export async function guardRadarFeature<T>(
119
+ feature: RadarFeature,
120
+ support: RadarFeatureSupport,
121
+ versions: RadarVersions,
122
+ request: () => Promise<T>,
123
+ ): Promise<T> {
124
+ if (support === 'unsupported') throw new RadarFeatureUnsupportedError(feature, versions)
125
+ try {
126
+ return await request()
127
+ } catch (error) {
128
+ if (support === 'unknown' && (error as { unknownRoute?: unknown } | null)?.unknownRoute === true) {
129
+ throw new RadarFeatureUnsupportedError(feature, versions)
130
+ }
131
+ throw error
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Query retry policy: an unsupported feature or a client error (other than a
137
+ * timeout or rate limit) gives the same answer on every attempt, so retrying
138
+ * only repeats the request against the cluster.
139
+ */
140
+ export function shouldRetryRadarQuery(failureCount: number, error: unknown): boolean {
141
+ if (error instanceof RadarFeatureUnsupportedError) return false
142
+ const status = (error as { status?: unknown } | null)?.status
143
+ if (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429) {
144
+ return false
145
+ }
146
+ return failureCount < 1
147
+ }
@@ -457,11 +457,7 @@ describe('retained ring fetch (the OSS-identical accumulate model)', () => {
457
457
  const flags = new Set<string>()
458
458
  const cached = ring([ev({ id: 'e-stale' })])
459
459
  mockApiFetch
460
- .mockResolvedValueOnce({
461
- ok: false,
462
- status: 400,
463
- json: () => Promise.resolve({ error: 'invalid since cursor' }),
464
- } as unknown as Response)
460
+ .mockResolvedValueOnce(Response.json({ error: 'invalid since cursor' }, { status: 400 }))
465
461
  .mockResolvedValueOnce(streamResponse([line(ev({ id: 'e-fresh' })), end({ cursor: '300' })]))
466
462
  const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
467
463
  expect(out.events.map((e) => e.id)).toEqual(['e-fresh'])
@@ -647,11 +643,7 @@ describe('retained ring fetch (the OSS-identical accumulate model)', () => {
647
643
  it('a non-400 delta failure propagates and keeps the cursor for the next poll', async () => {
648
644
  const flags = new Set<string>()
649
645
  const cached = ring([ev({ id: 'e1' })])
650
- mockApiFetch.mockResolvedValueOnce({
651
- ok: false,
652
- json: () => Promise.resolve({ error: 'boom' }),
653
- status: 503,
654
- } as unknown as Response)
646
+ mockApiFetch.mockResolvedValueOnce(Response.json({ error: 'boom' }, { status: 503 }))
655
647
  await expect(
656
648
  runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW }),
657
649
  ).rejects.toBeInstanceOf(ApiError)
@@ -17,6 +17,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
17
17
  import { LIVE_TICK_MS } from '@skyhook-io/k8s-ui'
18
18
  import { apiFetch, mergeDeltaEvents, ApiError, type UseChangesOptions } from './client'
19
19
  import { apiUrl, getApiBase } from './config'
20
+ import { readErrorBody } from './httpErrors'
20
21
  import type { TimelineEvent, TimeRange } from '../types'
21
22
 
22
23
  // The query the wrappers pass. Superset of the local store's params so most
@@ -321,7 +322,7 @@ function dedupeById(events: TimelineEvent[]): TimelineEvent[] {
321
322
  async function fetchRetainedStream(path: string, signal?: AbortSignal): Promise<RetainedWindowResult> {
322
323
  const res = await apiFetch(apiUrl(path), signal ? { signal } : undefined)
323
324
  if (!res.ok) {
324
- const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
325
+ const errorData = await readErrorBody(res)
325
326
  throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
326
327
  }
327
328
  if (!res.body) {
@@ -871,7 +872,7 @@ function normalizeCoverage(
871
872
  async function fetchRetainedOverview(range: TimelineRange): Promise<TimelineOverviewResult> {
872
873
  const res = await apiFetch(apiUrl(`/timeline/overview?from=${Math.round(range.from)}&to=${Math.round(range.to)}`))
873
874
  if (!res.ok) {
874
- const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
875
+ const errorData = await readErrorBody(res)
875
876
  throw new ApiError(errorData.error || `HTTP ${res.status}`, res.status, errorData)
876
877
  }
877
878
  const body: unknown = await res.json()
@@ -1,6 +1,7 @@
1
1
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
2
  import type { TrafficSourcesResponse, TrafficFlowsResponse } from '../types'
3
3
  import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
4
+ import { readErrorBody } from './httpErrors'
4
5
 
5
6
  async function fetchJSON<T>(path: string): Promise<T> {
6
7
  const response = await fetch(apiUrl(path), {
@@ -8,7 +9,7 @@ async function fetchJSON<T>(path: string): Promise<T> {
8
9
  headers: getAuthHeaders(),
9
10
  })
10
11
  if (!response.ok) {
11
- const error = await response.json().catch(() => ({ error: 'Unknown error' }))
12
+ const error = await readErrorBody(response)
12
13
  throw new Error(error.error || `HTTP ${response.status}`)
13
14
  }
14
15
  return response.json()
@@ -82,7 +83,7 @@ export function useSetTrafficSource() {
82
83
  body: JSON.stringify({ source }),
83
84
  })
84
85
  if (!response.ok) {
85
- const error = await response.json().catch(() => ({ error: 'Unknown error' }))
86
+ const error = await readErrorBody(response)
86
87
  throw new Error(error.error || `HTTP ${response.status}`)
87
88
  }
88
89
  return response.json()
@@ -125,7 +126,7 @@ export function useTrafficConnect() {
125
126
  headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
126
127
  })
127
128
  if (!response.ok) {
128
- const error = await response.json().catch(() => ({ error: 'Unknown error' }))
129
+ const error = await readErrorBody(response)
129
130
  throw new Error(error.error || `HTTP ${response.status}`)
130
131
  }
131
132
  return response.json()
@@ -0,0 +1,33 @@
1
+ import type { UsageDataStatus, UsageReport } from './usage-data'
2
+
3
+ // Test-only: a report and status shaped like the server's.
4
+ export function exampleReport(patch: Partial<UsageReport> = {}): UsageReport {
5
+ return {
6
+ schema: 3,
7
+ version: '1.15.0', os: 'darwin', arch: 'arm64', installMethod: 'homebrew', mode: 'local',
8
+ periodStart: '2026-09-23', periodEnd: '2026-09-24',
9
+ setup: {
10
+ authMode: 'none', timelineStorage: 'memory', mcpEnabled: true, prometheus: 'none',
11
+ costSource: 'auto', browsers: ['chrome'],
12
+ },
13
+ engagement: { sessions: 2, activeMinutes: '20-49' },
14
+ views: {}, actions: {}, mcpTools: {}, uiEvents: {}, errors: {},
15
+ clusters: {
16
+ contexts: '5-9', used: 1,
17
+ shapes: [{ kubernetesVersion: '1.33', platform: 'eks', nodes: '10-19', integrations: ['argo-cd'] }],
18
+ },
19
+ ...patch,
20
+ }
21
+ }
22
+
23
+ export function exampleStatus(patch: Partial<UsageDataStatus> = {}): UsageDataStatus {
24
+ return {
25
+ state: 'undecided', source: 'default', canChange: true,
26
+ developmentBuild: false,
27
+ preview: exampleReport(),
28
+ firstRunPrompt: false,
29
+ ask: true,
30
+ shared: false,
31
+ ...patch,
32
+ }
33
+ }