@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
|
@@ -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
|
|
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
|
|
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()
|
package/src/api/traffic.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|