@skyhook-io/radar-app 1.15.0 → 1.16.1
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 +14 -11
- 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.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +247 -201
- 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 +223 -0
- package/src/api/radarFeatures.test.ts +142 -0
- package/src/api/radarFeatures.ts +148 -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/components/CloudFunnelButton.tsx +9 -9
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +5 -6
- 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/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/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/home/HomeView.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- 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/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/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +382 -521
- 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 +20 -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/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +57 -12
- package/src/components/workload/WorkloadView.tsx +44 -6
- 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
|
@@ -0,0 +1,223 @@
|
|
|
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, useWorkloadHistory } 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
|
+
})
|
|
191
|
+
|
|
192
|
+
function History() {
|
|
193
|
+
const { data, error } = useWorkloadHistory('deployments', 'shop', 'web', 'apps')
|
|
194
|
+
return <span>{data ? 'data' : isRadarFeatureUnsupported(error, 'workloadHistory') ? 'fallback' : 'pending'}</span>
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('never asks a Radar whose capabilities omit workload history', async () => {
|
|
198
|
+
client.setQueryData(['capabilities'], { features: { policyResource: true } })
|
|
199
|
+
await render(<History />)
|
|
200
|
+
expect(element.textContent).toBe('fallback')
|
|
201
|
+
expect(asked('/history')).toBe(false)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("reads the router's 404 on workload history as unsupported before capabilities answer", async () => {
|
|
205
|
+
host.radarVersion = undefined
|
|
206
|
+
chiRoutes = ['/workloads/deployments/shop/web/history']
|
|
207
|
+
await render(<History />)
|
|
208
|
+
expect(element.textContent).toBe('fallback')
|
|
209
|
+
expect(requested.filter((url) => url.includes('/history'))).toHaveLength(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('asks again once capabilities confirm workload history after a probe hit an older Radar', async () => {
|
|
213
|
+
host.radarVersion = undefined
|
|
214
|
+
chiRoutes = ['/workloads/deployments/shop/web/history']
|
|
215
|
+
await render(<History />)
|
|
216
|
+
expect(element.textContent).toBe('fallback')
|
|
217
|
+
|
|
218
|
+
chiRoutes = []
|
|
219
|
+
await act(async () => { client.setQueryData(['capabilities'], { features: { workloadHistory: true } }) })
|
|
220
|
+
await settle()
|
|
221
|
+
await settle()
|
|
222
|
+
expect(requested.filter((url) => url.includes('/history'))).toHaveLength(2)
|
|
223
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
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('workload history, whose flag shipped with its endpoint', () => {
|
|
60
|
+
it('trusts a loaded capabilities answer, and probes before it arrives', () => {
|
|
61
|
+
expect(radarFeatureSupport('workloadHistory', { features: { workloadHistory: true } }, undefined)).toBe('supported')
|
|
62
|
+
expect(radarFeatureSupport('workloadHistory', { features: { policyResource: true } }, 'v1.15.0')).toBe('unsupported')
|
|
63
|
+
expect(radarFeatureSupport('workloadHistory', { features: {} }, 'dev')).toBe('unsupported')
|
|
64
|
+
expect(radarFeatureSupport('workloadHistory', undefined, 'v1.15.0')).toBe('unknown')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('radarSpecSupport for a feature whose flag shipped with its endpoint', () => {
|
|
69
|
+
const driftAlerts: RadarFeatureSpec = { label: 'Drift alerts', flag: 'resourceIssues', flagShippedWithEndpoint: true }
|
|
70
|
+
|
|
71
|
+
it('reads a loaded answer without the flag as too old, whatever the version', () => {
|
|
72
|
+
expect(radarSpecSupport(driftAlerts, { features: {} }, 'dev')).toBe('unsupported')
|
|
73
|
+
expect(radarSpecSupport(driftAlerts, {}, 'v1.7.2')).toBe('unsupported')
|
|
74
|
+
expect(radarSpecSupport(driftAlerts, { features: { resourceIssues: true } }, 'v1.7.2')).toBe('supported')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('waits for /api/capabilities before deciding, and never version-gates without a first release', () => {
|
|
78
|
+
expect(radarSpecSupport(driftAlerts, undefined, 'v1.7.2')).toBe('unknown')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('guardRadarFeature', () => {
|
|
83
|
+
const versions = { currentVersion: 'v1.7.2', latestVersion: 'v1.15.0' }
|
|
84
|
+
|
|
85
|
+
it('never asks a Radar known to be too old', async () => {
|
|
86
|
+
const request = vi.fn(() => Promise.resolve('data'))
|
|
87
|
+
await expect(guardRadarFeature('policyResource', 'unsupported', versions, request))
|
|
88
|
+
.rejects.toBeInstanceOf(RadarFeatureUnsupportedError)
|
|
89
|
+
expect(request).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("reads the router's unknown-route 404 as unsupported", async () => {
|
|
93
|
+
const error = await guardRadarFeature('policyResource', 'unknown', versions, () =>
|
|
94
|
+
Promise.reject(new ApiError('HTTP 404 (Not Found)', 404, {}, { unknownRoute: true })),
|
|
95
|
+
).catch((e: unknown) => e)
|
|
96
|
+
expect(isRadarFeatureUnsupported(error, 'policyResource')).toBe(true)
|
|
97
|
+
expect(getRadarUpgradeRequirement(error)).toEqual({
|
|
98
|
+
feature: 'Policy results',
|
|
99
|
+
minimumVersion: 'v1.10.0',
|
|
100
|
+
currentVersion: 'v1.7.2',
|
|
101
|
+
latestVersion: 'v1.15.0',
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('passes every other failure through untouched', async () => {
|
|
106
|
+
const handler404 = new ApiError('pods "web" not found', 404, { error: 'pods "web" not found' })
|
|
107
|
+
await expect(guardRadarFeature('policyResource', 'unknown', versions, () => Promise.reject(handler404)))
|
|
108
|
+
.rejects.toBe(handler404)
|
|
109
|
+
const hubNotFound = new ApiError('HTTP 404 (Not Found)', 404)
|
|
110
|
+
await expect(guardRadarFeature('policyResource', 'supported', versions, () => Promise.reject(hubNotFound)))
|
|
111
|
+
.rejects.toBe(hubNotFound)
|
|
112
|
+
expect(getRadarUpgradeRequirement(hubNotFound)).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("keeps the router's 404 an error on a Radar known to serve the feature", async () => {
|
|
116
|
+
const routing = new ApiError('HTTP 404 (Not Found)', 404, {}, { unknownRoute: true })
|
|
117
|
+
const error = await guardRadarFeature('policyResource', 'supported', versions, () => Promise.reject(routing))
|
|
118
|
+
.catch((e: unknown) => e)
|
|
119
|
+
expect(error).toBe(routing)
|
|
120
|
+
expect(getRadarUpgradeRequirement(error)).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns the data when the request succeeds', async () => {
|
|
124
|
+
await expect(guardRadarFeature('policyResource', 'unknown', {}, () => Promise.resolve(42))).resolves.toBe(42)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('shouldRetryRadarQuery', () => {
|
|
129
|
+
it('does not retry an unsupported feature or a settled client error', () => {
|
|
130
|
+
expect(shouldRetryRadarQuery(0, new RadarFeatureUnsupportedError('resourceIssues', {}))).toBe(false)
|
|
131
|
+
for (const status of [400, 401, 403, 404, 409]) {
|
|
132
|
+
expect(shouldRetryRadarQuery(0, new ApiError('x', status))).toBe(false)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('retries timeouts, rate limits, server errors and network failures once', () => {
|
|
137
|
+
for (const error of [new ApiError('x', 408), new ApiError('x', 429), new ApiError('x', 503), new TypeError('Failed to fetch')]) {
|
|
138
|
+
expect(shouldRetryRadarQuery(0, error)).toBe(true)
|
|
139
|
+
expect(shouldRetryRadarQuery(1, error)).toBe(false)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true },
|
|
38
|
+
} as const satisfies Record<string, RadarFeatureSpec>
|
|
39
|
+
|
|
40
|
+
export type RadarFeature = keyof typeof RADAR_FEATURES
|
|
41
|
+
|
|
42
|
+
export type RadarFeatureSupport = 'supported' | 'unsupported' | 'unknown'
|
|
43
|
+
|
|
44
|
+
export interface RadarVersions {
|
|
45
|
+
currentVersion?: string
|
|
46
|
+
latestVersion?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Only a plain release can be placed against a minimum. Dev, dirty and
|
|
50
|
+
// pre-release builds stay unknown so a custom build is never wrongly gated.
|
|
51
|
+
const RELEASE_VERSION = /^v?\d+\.\d+\.\d+$/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `capabilities` is the /api/capabilities answer, or undefined while it is
|
|
55
|
+
* still loading; only a loaded answer can prove a flag is absent.
|
|
56
|
+
*/
|
|
57
|
+
export function radarSpecSupport(
|
|
58
|
+
spec: RadarFeatureSpec,
|
|
59
|
+
capabilities: { features?: FeatureCapabilities } | undefined,
|
|
60
|
+
currentVersion: string | undefined,
|
|
61
|
+
): RadarFeatureSupport {
|
|
62
|
+
if (spec.flag && capabilities?.features?.[spec.flag] === true) return 'supported'
|
|
63
|
+
// Unflagged entries all predate the first flag added for this table, so a
|
|
64
|
+
// Radar that advertises it serves them, whatever version the host reported.
|
|
65
|
+
if (!spec.flag && capabilities?.features?.resourceIssues === true) return 'supported'
|
|
66
|
+
if (spec.flag && spec.flagShippedWithEndpoint && capabilities) return 'unsupported'
|
|
67
|
+
const version = currentVersion?.trim()
|
|
68
|
+
if (!spec.minimumVersion || !version || !RELEASE_VERSION.test(version)) return 'unknown'
|
|
69
|
+
const order = compareVersions(version, spec.minimumVersion)
|
|
70
|
+
if (order === null) return 'unknown'
|
|
71
|
+
return order < 0 ? 'unsupported' : 'supported'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function radarFeatureSupport(
|
|
75
|
+
feature: RadarFeature,
|
|
76
|
+
capabilities: { features?: FeatureCapabilities } | undefined,
|
|
77
|
+
currentVersion: string | undefined,
|
|
78
|
+
): RadarFeatureSupport {
|
|
79
|
+
return radarSpecSupport(RADAR_FEATURES[feature], capabilities, currentVersion)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function radarUpgradeRequirement(feature: RadarFeature, versions: RadarVersions): RadarUpgradeRequirement {
|
|
83
|
+
const spec: RadarFeatureSpec = RADAR_FEATURES[feature]
|
|
84
|
+
return {
|
|
85
|
+
feature: spec.label,
|
|
86
|
+
...(spec.minimumVersion ? { minimumVersion: spec.minimumVersion } : {}),
|
|
87
|
+
...(versions.currentVersion ? { currentVersion: versions.currentVersion } : {}),
|
|
88
|
+
...(versions.latestVersion ? { latestVersion: versions.latestVersion } : {}),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The connected Radar predates `feature`. Carries `radarUpgrade` so k8s-ui
|
|
94
|
+
* components can render the upgrade note without importing this class.
|
|
95
|
+
*/
|
|
96
|
+
export class RadarFeatureUnsupportedError extends Error {
|
|
97
|
+
readonly feature: RadarFeature
|
|
98
|
+
readonly radarUpgrade: RadarUpgradeRequirement
|
|
99
|
+
|
|
100
|
+
constructor(feature: RadarFeature, versions: RadarVersions) {
|
|
101
|
+
const requirement = radarUpgradeRequirement(feature, versions)
|
|
102
|
+
super(`${requirement.feature} need a newer Radar`)
|
|
103
|
+
this.name = 'RadarFeatureUnsupportedError'
|
|
104
|
+
this.feature = feature
|
|
105
|
+
this.radarUpgrade = requirement
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isRadarFeatureUnsupported(error: unknown, feature?: RadarFeature): boolean {
|
|
110
|
+
return error instanceof RadarFeatureUnsupportedError && (feature === undefined || error.feature === feature)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Runs `request` unless the connected Radar is known to predate `feature`.
|
|
115
|
+
* When its version is unknown, chi's unknown-route 404 (ApiError.unknownRoute)
|
|
116
|
+
* is read as the same answer; on a Radar known to serve the feature that 404
|
|
117
|
+
* is a routing failure and stays an error.
|
|
118
|
+
*/
|
|
119
|
+
export async function guardRadarFeature<T>(
|
|
120
|
+
feature: RadarFeature,
|
|
121
|
+
support: RadarFeatureSupport,
|
|
122
|
+
versions: RadarVersions,
|
|
123
|
+
request: () => Promise<T>,
|
|
124
|
+
): Promise<T> {
|
|
125
|
+
if (support === 'unsupported') throw new RadarFeatureUnsupportedError(feature, versions)
|
|
126
|
+
try {
|
|
127
|
+
return await request()
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (support === 'unknown' && (error as { unknownRoute?: unknown } | null)?.unknownRoute === true) {
|
|
130
|
+
throw new RadarFeatureUnsupportedError(feature, versions)
|
|
131
|
+
}
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Query retry policy: an unsupported feature or a client error (other than a
|
|
138
|
+
* timeout or rate limit) gives the same answer on every attempt, so retrying
|
|
139
|
+
* only repeats the request against the cluster.
|
|
140
|
+
*/
|
|
141
|
+
export function shouldRetryRadarQuery(failureCount: number, error: unknown): boolean {
|
|
142
|
+
if (error instanceof RadarFeatureUnsupportedError) return false
|
|
143
|
+
const status = (error as { status?: unknown } | null)?.status
|
|
144
|
+
if (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
return failureCount < 1
|
|
148
|
+
}
|
|
@@ -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()
|
|
@@ -291,7 +291,7 @@ export function CloudFunnelButton() {
|
|
|
291
291
|
content={
|
|
292
292
|
<>
|
|
293
293
|
<span className="block font-semibold">Meet Radar Cloud</span>
|
|
294
|
-
See all your clusters in one place, share with your team, and get alerts and automatic AI investigations
|
|
294
|
+
See all your clusters in one place, share with your team, and get alerts and automatic AI investigations.
|
|
295
295
|
</>
|
|
296
296
|
}
|
|
297
297
|
delay={100}
|
|
@@ -665,7 +665,7 @@ function PitchBody({ lane, freeTier }: { lane: 'driver' | 'wizard'; freeTier?: s
|
|
|
665
665
|
// skimmable. Alerts and investigations lead: they are the two things the
|
|
666
666
|
// OSS binary cannot do at all. Team, fleet and history follow.
|
|
667
667
|
const highlights = [
|
|
668
|
-
{ icon: Bell, lead: 'Alerts', rest: '
|
|
668
|
+
{ icon: Bell, lead: 'Alerts', rest: ' to Slack or any webhook. One per failure, not per crash-looping pod.' },
|
|
669
669
|
{ icon: Sparkles, lead: 'AI investigations', rest: ': root cause with evidence, automatic on alerts or on demand' },
|
|
670
670
|
{ icon: Users, lead: 'Your team', rest: ': share any view with just a link, scoped by your existing RBAC' },
|
|
671
671
|
{ icon: Globe, lead: 'Every cluster', rest: ': the whole fleet in one view and one MCP endpoint' },
|
|
@@ -678,7 +678,7 @@ function PitchBody({ lane, freeTier }: { lane: 'driver' | 'wizard'; freeTier?: s
|
|
|
678
678
|
Close the laptop. Radar keeps watching.
|
|
679
679
|
</h3>
|
|
680
680
|
<p className="text-[14px] leading-relaxed text-theme-text-secondary mb-1.5">
|
|
681
|
-
Radar Cloud is the hosted side of Radar:
|
|
681
|
+
Radar Cloud is the hosted side of Radar: alerts, AI root cause and every cluster in one place, for
|
|
682
682
|
you and your team.
|
|
683
683
|
</p>
|
|
684
684
|
<p className="text-[14px] leading-relaxed text-theme-text-secondary mb-6">
|
|
@@ -724,9 +724,10 @@ function PitchBody({ lane, freeTier }: { lane: 'driver' | 'wizard'; freeTier?: s
|
|
|
724
724
|
<section>
|
|
725
725
|
<h4 className="text-[12.5px] font-semibold text-theme-text-primary mb-0.5">Investigations</h4>
|
|
726
726
|
<p className="text-[12px] leading-relaxed text-theme-text-secondary">
|
|
727
|
-
In our public benchmark,
|
|
728
|
-
kubectl, and
|
|
729
|
-
on demand from any issue, with no CLI or API
|
|
727
|
+
In our public benchmark, the same agent diagnosed faults 3× faster on average with Radar than with
|
|
728
|
+
plain kubectl, and Radar answered 1.6–3.6× faster on average than the other AI SRE tools we tested.
|
|
729
|
+
Run it automatically on the alert rules you choose, or on demand from any issue, with no CLI or API
|
|
730
|
+
key.{' '}
|
|
730
731
|
<a href={BENCHMARK_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
|
|
731
732
|
See the benchmarks →
|
|
732
733
|
</a>
|
|
@@ -735,9 +736,8 @@ function PitchBody({ lane, freeTier }: { lane: 'driver' | 'wizard'; freeTier?: s
|
|
|
735
736
|
<section>
|
|
736
737
|
<h4 className="text-[12.5px] font-semibold text-theme-text-primary mb-0.5">What it costs</h4>
|
|
737
738
|
<p className="text-[12px] leading-relaxed text-theme-text-secondary">
|
|
738
|
-
Radar Cloud is {freeLine},
|
|
739
|
-
|
|
740
|
-
and audit logs.{' '}
|
|
739
|
+
Radar Cloud is {freeLine}, including 100 investigations a month. Paid plans add investigations for
|
|
740
|
+
every cluster, with optional $1 overage, plus enterprise features like SSO, SCIM and audit logs.{' '}
|
|
741
741
|
<a href={PRICING_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
|
|
742
742
|
See pricing →
|
|
743
743
|
</a>
|