@skyhook-io/radar-app 1.9.2 → 1.9.4
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/package.json +1 -1
- package/src/App.tsx +16 -4
- package/src/api/client.ts +67 -0
- package/src/components/audit/AuditView.tsx +13 -0
- package/src/components/audit/ChecksViewTabs.test.tsx +20 -0
- package/src/components/audit/ChecksViewTabs.tsx +43 -0
- package/src/components/audit/UpgradeReadinessView.test.ts +130 -0
- package/src/components/audit/UpgradeReadinessView.tsx +699 -0
- package/src/components/capacity/CapacityView.test.tsx +21 -0
- package/src/components/capacity/shared.tsx +23 -4
- package/src/components/settings/SettingsDialog.tsx +4 -2
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -145,6 +145,8 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
145
145
|
// breakdown; a filter would only hide rows).
|
|
146
146
|
// - A GitOps detail tree spans namespaces — its controller lives in one
|
|
147
147
|
// namespace but manages workloads across many.
|
|
148
|
+
// - Upgrade impact evaluates the full readable cluster scope rather than a
|
|
149
|
+
// browsing filter.
|
|
148
150
|
// - A cluster-scoped resource kind (Nodes, PVs, ClusterRoles…) has no
|
|
149
151
|
// namespace at all.
|
|
150
152
|
// The pick itself is preserved so it re-applies when the user returns to a
|
|
@@ -170,6 +172,12 @@ function namespaceFilterDisabled(
|
|
|
170
172
|
tooltip: 'Capacity is reported across the cluster — the namespace filter doesn’t apply here.',
|
|
171
173
|
}
|
|
172
174
|
}
|
|
175
|
+
if (view === 'checks' && pathname.startsWith('/checks/upgrade')) {
|
|
176
|
+
return {
|
|
177
|
+
disabled: true,
|
|
178
|
+
tooltip: 'Upgrade impact scans every namespace you can access — the namespace filter doesn’t apply.',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
173
181
|
const segments = pathname.replace(/^\//, '').split('/')
|
|
174
182
|
if (view === 'gitops' && segments[1] === 'detail') {
|
|
175
183
|
return {
|
|
@@ -233,6 +241,8 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc
|
|
|
233
241
|
return slash >= 0 && slash < decoded.length - 1 ? decoded.slice(slash + 1) : decoded
|
|
234
242
|
}
|
|
235
243
|
|
|
244
|
+
if (view === 'checks' && pathSegments[1] === 'upgrade') return 'Upgrade impact'
|
|
245
|
+
|
|
236
246
|
// The landing view reads "Overview" rather than "Home" in the tab.
|
|
237
247
|
if (view === 'capacity') {
|
|
238
248
|
if (pathSegments[1] === 'pools') return decode(pathSegments[2] ?? '') || 'Capacity'
|
|
@@ -401,6 +411,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
401
411
|
|
|
402
412
|
// Get mainView from URL path
|
|
403
413
|
const mainView = getViewFromPath(location.pathname)
|
|
414
|
+
const upgradeReadinessRoute = location.pathname.startsWith('/checks/upgrade')
|
|
404
415
|
|
|
405
416
|
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
406
417
|
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
@@ -485,7 +496,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
485
496
|
// The host's URL for the CURRENT view, if taken over. Drives the redirect
|
|
486
497
|
// effect and the "Opening…" splash.
|
|
487
498
|
const viewTakeoverHref =
|
|
488
|
-
mainView === 'issues' || mainView === 'gitops' || mainView === 'checks'
|
|
499
|
+
(mainView === 'issues' || mainView === 'gitops' || mainView === 'checks') && !upgradeReadinessRoute
|
|
489
500
|
? takeover[mainView]
|
|
490
501
|
: undefined
|
|
491
502
|
useEffect(() => {
|
|
@@ -698,6 +709,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
698
709
|
newParams.delete('kind')
|
|
699
710
|
newParams.delete('mode')
|
|
700
711
|
newParams.delete('group')
|
|
712
|
+
newParams.delete('target')
|
|
701
713
|
// Open as a normal drawer — never inherit a stale ?full=1/tab from an
|
|
702
714
|
// expanded view we're navigating away from (only expand/drill set those).
|
|
703
715
|
newParams.delete('full')
|
|
@@ -2285,9 +2297,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2285
2297
|
/>
|
|
2286
2298
|
)}
|
|
2287
2299
|
|
|
2288
|
-
{/*
|
|
2289
|
-
|
|
2290
|
-
{mainView === 'checks' && !isViewTakenOver('checks') && (
|
|
2300
|
+
{/* Checks detail view. Cloud can take over fleet best practices while
|
|
2301
|
+
the target-specific upgrade route continues to render locally. */}
|
|
2302
|
+
{mainView === 'checks' && (!isViewTakenOver('checks') || upgradeReadinessRoute) && (
|
|
2291
2303
|
<AuditView
|
|
2292
2304
|
namespaces={namespaces}
|
|
2293
2305
|
onNavigateToResource={navigateToResourceList}
|
package/src/api/client.ts
CHANGED
|
@@ -391,6 +391,61 @@ export interface AuditResponse {
|
|
|
391
391
|
groupedChecks?: Check[];
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
export type UpgradeReadinessVerdict = 'blocked' | 'warning' | 'review' | 'no_known_blockers' | 'unknown'
|
|
395
|
+
export type UpgradeReadinessLevel = 'blocker' | 'warning' | 'review'
|
|
396
|
+
export type UpgradeReadinessCheckStatus = 'passed' | 'blocked' | 'warning' | 'review' | 'unknown' | 'not_applicable'
|
|
397
|
+
|
|
398
|
+
export interface UpgradeReadinessResourceRef {
|
|
399
|
+
group?: string
|
|
400
|
+
kind: string
|
|
401
|
+
namespace: string
|
|
402
|
+
name: string
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface UpgradeReadinessFinding {
|
|
406
|
+
ruleID: string
|
|
407
|
+
title: string
|
|
408
|
+
level: UpgradeReadinessLevel
|
|
409
|
+
resource?: UpgradeReadinessResourceRef
|
|
410
|
+
managedBy?: UpgradeReadinessResourceRef
|
|
411
|
+
evidence: { source: string; path: string; detail?: string }
|
|
412
|
+
appliesFrom?: string
|
|
413
|
+
impact: string
|
|
414
|
+
remediation: string
|
|
415
|
+
references: { title: string; url: string }[]
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export interface UpgradeReadinessCheck {
|
|
419
|
+
id: string
|
|
420
|
+
category: string
|
|
421
|
+
title: string
|
|
422
|
+
status: UpgradeReadinessCheckStatus
|
|
423
|
+
summary: string
|
|
424
|
+
evidenceNote?: string
|
|
425
|
+
caveat?: string
|
|
426
|
+
scope: string
|
|
427
|
+
inspected?: number
|
|
428
|
+
appliesFrom?: string
|
|
429
|
+
findings: UpgradeReadinessFinding[]
|
|
430
|
+
references?: { title: string; url: string }[]
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface UpgradeReadinessResponse {
|
|
434
|
+
currentVersion: string
|
|
435
|
+
targetVersion: string
|
|
436
|
+
reviewedThrough: string
|
|
437
|
+
verdict: UpgradeReadinessVerdict
|
|
438
|
+
summary: { blocked: number; warnings: number; reviews: number; passed: number; unknown: number; notApplicable: number; findings: number }
|
|
439
|
+
checks: UpgradeReadinessCheck[]
|
|
440
|
+
coverage: {
|
|
441
|
+
source: 'live'
|
|
442
|
+
state: 'complete' | 'partial' | 'no_access'
|
|
443
|
+
unavailableKinds?: string[]
|
|
444
|
+
scopedNamespaces?: string[]
|
|
445
|
+
scopedKinds?: Record<string, string[]>
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
394
449
|
export interface DashboardCertificateHealth {
|
|
395
450
|
total: number;
|
|
396
451
|
healthy: number;
|
|
@@ -486,6 +541,18 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
486
541
|
});
|
|
487
542
|
}
|
|
488
543
|
|
|
544
|
+
export function useUpgradeReadiness(target?: string) {
|
|
545
|
+
const params = new URLSearchParams()
|
|
546
|
+
if (target) params.set('target', target)
|
|
547
|
+
const query = params.toString()
|
|
548
|
+
return useQuery<UpgradeReadinessResponse>({
|
|
549
|
+
queryKey: ['upgrade-readiness', target ?? 'next'],
|
|
550
|
+
queryFn: ({ signal }) => fetchJSON(`/upgrade-readiness${query ? `?${query}` : ''}`, signal),
|
|
551
|
+
staleTime: 30000,
|
|
552
|
+
placeholderData: (previous) => previous,
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
489
556
|
// Live cluster Issues — the grouped triage queue (radar's /api/issues =
|
|
490
557
|
// internal/issues.Compose+Classify+Group). Single-cluster here; the Hub fleet
|
|
491
558
|
// view fans the same shape across clusters. Do not carry old data across query
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
|
+
import { useLocation } from 'react-router-dom'
|
|
2
3
|
import { useAudit, useAuditSettings, useUpdateAuditSettings, useCloudRole } from '../../api/client'
|
|
3
4
|
import type { SelectedResource } from '../../types'
|
|
4
5
|
import { ChecksView, PaneLoader, PageHeader, FreshnessControl, type CheckResourceRef } from '@skyhook-io/k8s-ui'
|
|
@@ -6,6 +7,8 @@ import { ShieldCheck, Settings } from 'lucide-react'
|
|
|
6
7
|
import { AuditSettingsDialog } from './AuditSettingsDialog'
|
|
7
8
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
9
|
import { useConnection } from '../../context/ConnectionContext'
|
|
10
|
+
import { ChecksViewTabs } from './ChecksViewTabs'
|
|
11
|
+
import { UpgradeReadinessView } from './UpgradeReadinessView'
|
|
9
12
|
|
|
10
13
|
interface AuditViewProps {
|
|
11
14
|
namespaces: string[]
|
|
@@ -19,6 +22,14 @@ interface AuditViewProps {
|
|
|
19
22
|
// ~/.radar settings are this cluster's "policy" and the row hide-menu writes to
|
|
20
23
|
// them.
|
|
21
24
|
export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps) {
|
|
25
|
+
const { pathname } = useLocation()
|
|
26
|
+
if (pathname.startsWith('/checks/upgrade')) {
|
|
27
|
+
return <UpgradeReadinessView namespaces={namespaces} onNavigateToResource={onNavigateToResource} />
|
|
28
|
+
}
|
|
29
|
+
return <BestPracticesView namespaces={namespaces} onNavigateToResource={onNavigateToResource} />
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps) {
|
|
22
33
|
const { data, isLoading, error, dataUpdatedAt, refetch } = useAudit(namespaces)
|
|
23
34
|
const { data: auditSettings } = useAuditSettings()
|
|
24
35
|
const updateSettings = useUpdateAuditSettings()
|
|
@@ -104,6 +115,8 @@ export function AuditView({ namespaces, onNavigateToResource }: AuditViewProps)
|
|
|
104
115
|
}
|
|
105
116
|
/>
|
|
106
117
|
|
|
118
|
+
<ChecksViewTabs />
|
|
119
|
+
|
|
107
120
|
<ChecksView
|
|
108
121
|
checks={data.groupedChecks ?? []}
|
|
109
122
|
catalog={data.checks ?? {}}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import { ChecksViewTabs } from './ChecksViewTabs'
|
|
5
|
+
|
|
6
|
+
describe('ChecksViewTabs', () => {
|
|
7
|
+
it('keeps browsing scope across tabs without leaking upgrade-only target state', () => {
|
|
8
|
+
const html = renderToStaticMarkup(
|
|
9
|
+
<MemoryRouter initialEntries={['/checks/upgrade?namespaces=shop%2Cprod&target=1.36&resource=shop%2Fapi']}>
|
|
10
|
+
<ChecksViewTabs />
|
|
11
|
+
</MemoryRouter>,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
expect(html).toContain('href="/checks?namespaces=shop%2Cprod"')
|
|
15
|
+
expect(html).toContain('href="/checks/upgrade?namespaces=shop%2Cprod&target=1.36"')
|
|
16
|
+
expect(html).not.toContain('resource=')
|
|
17
|
+
expect(html).toContain('aria-current="page"')
|
|
18
|
+
expect(html).not.toContain('role="tab"')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
import { Link, useLocation, type To } from 'react-router-dom'
|
|
3
|
+
|
|
4
|
+
export function ChecksViewTabs() {
|
|
5
|
+
const { pathname, search } = useLocation()
|
|
6
|
+
const upgrade = pathname.startsWith('/checks/upgrade')
|
|
7
|
+
const current = new URLSearchParams(search)
|
|
8
|
+
const browsing = new URLSearchParams()
|
|
9
|
+
for (const key of ['namespace', 'namespaces']) {
|
|
10
|
+
const value = current.get(key)
|
|
11
|
+
if (value) browsing.set(key, value)
|
|
12
|
+
}
|
|
13
|
+
const upgradeSearch = new URLSearchParams(browsing)
|
|
14
|
+
const target = current.get('target')
|
|
15
|
+
if (target) upgradeSearch.set('target', target)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<nav className="flex items-center gap-1 border-b border-theme-border" aria-label="Check views">
|
|
19
|
+
<ChecksTab to={{ pathname: '/checks', search: browsing.toString() }} active={!upgrade}>
|
|
20
|
+
Best practices
|
|
21
|
+
</ChecksTab>
|
|
22
|
+
<ChecksTab to={{ pathname: '/checks/upgrade', search: upgradeSearch.toString() }} active={upgrade}>
|
|
23
|
+
Upgrade impact
|
|
24
|
+
</ChecksTab>
|
|
25
|
+
</nav>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ChecksTab({ to, active, children }: { to: To; active: boolean; children: React.ReactNode }) {
|
|
30
|
+
return (
|
|
31
|
+
<Link
|
|
32
|
+
to={to}
|
|
33
|
+
aria-current={active ? 'page' : undefined}
|
|
34
|
+
className={clsx(
|
|
35
|
+
'relative px-3 py-2 text-sm font-medium transition-colors',
|
|
36
|
+
active ? 'text-theme-text-primary' : 'text-theme-text-tertiary hover:text-theme-text-secondary',
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
{active && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-accent" />}
|
|
41
|
+
</Link>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { ApiError } from '../../api/client'
|
|
4
|
+
import { UPGRADE_IMPACT_DOCS_URL, UPGRADE_IMPACT_MIN_RADAR_VERSION, UpgradeReadinessError, groupFindings, incompleteUpgradeCheckCount, issueSpecificReferences, summaryMeta, upgradeEvaluationSummary } from './UpgradeReadinessView'
|
|
5
|
+
|
|
6
|
+
describe('UpgradeReadinessError', () => {
|
|
7
|
+
it('maps an unmatched endpoint 404 to the v1.9 upgrade message', () => {
|
|
8
|
+
const html = renderToStaticMarkup(UpgradeReadinessError({ error: new ApiError('Unknown error', 404) }))
|
|
9
|
+
|
|
10
|
+
expect(html).toContain('Upgrade impact needs a newer Radar')
|
|
11
|
+
expect(html).toContain(UPGRADE_IMPACT_MIN_RADAR_VERSION)
|
|
12
|
+
expect(html).toContain('Upgrade the in-cluster Radar to enable it')
|
|
13
|
+
expect(html).not.toContain('Unable to analyze upgrade impact')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('keeps a real resource 404 on the generic error state', () => {
|
|
17
|
+
const html = renderToStaticMarkup(UpgradeReadinessError({ error: new ApiError('Cluster not found', 404) }))
|
|
18
|
+
|
|
19
|
+
expect(html).toContain('Unable to analyze upgrade impact')
|
|
20
|
+
expect(html).toContain('Cluster not found')
|
|
21
|
+
expect(html).not.toContain('needs a newer Radar')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps ordinary failures on the generic error state', () => {
|
|
25
|
+
const html = renderToStaticMarkup(UpgradeReadinessError({ error: new ApiError('boom', 500) }))
|
|
26
|
+
|
|
27
|
+
expect(html).toContain('Unable to analyze upgrade impact')
|
|
28
|
+
expect(html).toContain('boom')
|
|
29
|
+
expect(html).not.toContain('needs a newer Radar')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('upgradeEvaluationSummary', () => {
|
|
34
|
+
it('distinguishes applicable, incomplete, and not-applicable checks', () => {
|
|
35
|
+
expect(upgradeEvaluationSummary(18, {
|
|
36
|
+
blocked: 1,
|
|
37
|
+
warnings: 0,
|
|
38
|
+
reviews: 0,
|
|
39
|
+
passed: 14,
|
|
40
|
+
unknown: 1,
|
|
41
|
+
notApplicable: 2,
|
|
42
|
+
findings: 1,
|
|
43
|
+
})).toBe('18 evaluated · 16 applicable · 1 with partial evidence · 2 not applicable')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('does not imply incomplete coverage when every applicable check completed', () => {
|
|
47
|
+
expect(upgradeEvaluationSummary(10, {
|
|
48
|
+
blocked: 0,
|
|
49
|
+
warnings: 0,
|
|
50
|
+
reviews: 0,
|
|
51
|
+
passed: 10,
|
|
52
|
+
unknown: 0,
|
|
53
|
+
notApplicable: 0,
|
|
54
|
+
findings: 0,
|
|
55
|
+
})).toBe('10 evaluated · 10 applicable · 0 not applicable')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('links to the documented catalog rather than inventing a second catalog in the UI', () => {
|
|
59
|
+
expect(UPGRADE_IMPACT_DOCS_URL).toContain('/features/upgrade-impact')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('counts incomplete evidence even when a blocker takes status precedence', () => {
|
|
63
|
+
expect(incompleteUpgradeCheckCount([
|
|
64
|
+
{ status: 'blocked', caveat: 'one PDB has stale status' },
|
|
65
|
+
{ status: 'unknown' },
|
|
66
|
+
{ status: 'passed' },
|
|
67
|
+
])).toBe(2)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('groupFindings', () => {
|
|
72
|
+
it('groups repeated remediation inside a check without merging distinct action levels', () => {
|
|
73
|
+
const base = {
|
|
74
|
+
ruleID: 'admission-webhook-readiness',
|
|
75
|
+
title: 'Backend unavailable',
|
|
76
|
+
resource: { kind: 'ValidatingWebhookConfiguration', namespace: '', name: 'policy' },
|
|
77
|
+
evidence: { source: 'live', path: 'webhooks[0].clientConfig.service' },
|
|
78
|
+
impact: 'Admission is unavailable.',
|
|
79
|
+
remediation: 'Restore the backend.',
|
|
80
|
+
references: [],
|
|
81
|
+
}
|
|
82
|
+
const groups = groupFindings([
|
|
83
|
+
{ ...base, level: 'blocker', evidence: { ...base.evidence, detail: 'default/a' } },
|
|
84
|
+
{ ...base, level: 'blocker', evidence: { ...base.evidence, detail: 'default/b' } },
|
|
85
|
+
{ ...base, level: 'warning', evidence: { ...base.evidence, detail: 'default/c' } },
|
|
86
|
+
])
|
|
87
|
+
expect(groups.map((group) => group.total)).toEqual([2, 1])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('counts findings even when multiple findings do not map one-to-one to resources', () => {
|
|
91
|
+
const base = {
|
|
92
|
+
ruleID: 'renamed-control-plane-metrics',
|
|
93
|
+
title: 'Metric renamed',
|
|
94
|
+
level: 'review' as const,
|
|
95
|
+
evidence: { source: 'PrometheusRule', path: 'spec.groups' },
|
|
96
|
+
impact: 'A query stops matching.',
|
|
97
|
+
remediation: 'Update the query.',
|
|
98
|
+
references: [],
|
|
99
|
+
}
|
|
100
|
+
const groups = groupFindings([
|
|
101
|
+
{ ...base, evidence: { ...base.evidence, detail: 'old_a' } },
|
|
102
|
+
{ ...base, evidence: { ...base.evidence, detail: 'old_b' } },
|
|
103
|
+
])
|
|
104
|
+
expect(groups[0].total).toBe(2)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('summaryMeta', () => {
|
|
109
|
+
it('keeps partial coverage explicit even when a confirmed blocker takes precedence', () => {
|
|
110
|
+
const meta = summaryMeta('blocked', '1.36', '1.36', {
|
|
111
|
+
blocked: 1,
|
|
112
|
+
warnings: 0,
|
|
113
|
+
reviews: 0,
|
|
114
|
+
unknown: 2,
|
|
115
|
+
}, 'partial')
|
|
116
|
+
expect(meta.body).toContain('Evidence coverage is also incomplete')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('issueSpecificReferences', () => {
|
|
121
|
+
it('keeps check-wide sources at the parent while preserving issue-specific sources', () => {
|
|
122
|
+
const checkReferences = [{ title: 'Admission webhook good practices', url: 'https://example.com/admission' }]
|
|
123
|
+
const findingReferences = [
|
|
124
|
+
...checkReferences,
|
|
125
|
+
{ title: 'Matching requests: matchPolicy', url: 'https://example.com/admission#match-policy' },
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
expect(issueSpecificReferences(checkReferences, findingReferences)).toEqual([findingReferences[1]])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import {
|
|
5
|
+
AlertCircle,
|
|
6
|
+
AlertTriangle,
|
|
7
|
+
BookOpen,
|
|
8
|
+
CheckCircle2,
|
|
9
|
+
CircleDashed,
|
|
10
|
+
CircleMinus,
|
|
11
|
+
ExternalLink,
|
|
12
|
+
FileSearch,
|
|
13
|
+
ShieldAlert,
|
|
14
|
+
Wrench,
|
|
15
|
+
} from 'lucide-react'
|
|
16
|
+
import {
|
|
17
|
+
Badge,
|
|
18
|
+
Collapse,
|
|
19
|
+
CollapseChevron,
|
|
20
|
+
EmptyState,
|
|
21
|
+
FreshnessControl,
|
|
22
|
+
PageHeader,
|
|
23
|
+
PaneLoader,
|
|
24
|
+
SelectMenu,
|
|
25
|
+
} from '@skyhook-io/k8s-ui'
|
|
26
|
+
import {
|
|
27
|
+
ApiError,
|
|
28
|
+
useUpgradeReadiness,
|
|
29
|
+
type UpgradeReadinessCheck,
|
|
30
|
+
type UpgradeReadinessCheckStatus,
|
|
31
|
+
type UpgradeReadinessFinding,
|
|
32
|
+
type UpgradeReadinessResponse,
|
|
33
|
+
type UpgradeReadinessVerdict,
|
|
34
|
+
} from '../../api/client'
|
|
35
|
+
import type { SelectedResource } from '../../types'
|
|
36
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
37
|
+
import { ChecksViewTabs } from './ChecksViewTabs'
|
|
38
|
+
|
|
39
|
+
interface UpgradeReadinessViewProps {
|
|
40
|
+
namespaces: string[]
|
|
41
|
+
onNavigateToResource: (resource: SelectedResource) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FINDING_CAP = 8
|
|
45
|
+
export const UPGRADE_IMPACT_DOCS_URL = 'https://radarhq.io/docs/features/upgrade-impact'
|
|
46
|
+
export const UPGRADE_IMPACT_MIN_RADAR_VERSION = 'v1.9.0'
|
|
47
|
+
|
|
48
|
+
const statusMeta: Record<UpgradeReadinessCheckStatus, {
|
|
49
|
+
label: string
|
|
50
|
+
icon: typeof CheckCircle2
|
|
51
|
+
iconClass: string
|
|
52
|
+
badgeSeverity: 'error' | 'warning' | 'info' | 'success' | 'neutral'
|
|
53
|
+
}> = {
|
|
54
|
+
blocked: { label: 'Blocked', icon: AlertCircle, iconClass: 'text-red-600 dark:text-red-400', badgeSeverity: 'error' },
|
|
55
|
+
warning: { label: 'Warning', icon: AlertTriangle, iconClass: 'text-amber-600 dark:text-amber-400', badgeSeverity: 'warning' },
|
|
56
|
+
review: { label: 'Review', icon: FileSearch, iconClass: 'text-blue-600 dark:text-blue-400', badgeSeverity: 'info' },
|
|
57
|
+
unknown: { label: 'Incomplete', icon: CircleDashed, iconClass: 'text-theme-text-tertiary', badgeSeverity: 'neutral' },
|
|
58
|
+
passed: { label: 'Passed', icon: CheckCircle2, iconClass: 'text-emerald-600 dark:text-emerald-400', badgeSeverity: 'success' },
|
|
59
|
+
not_applicable: { label: 'Not applicable', icon: CircleMinus, iconClass: 'text-theme-text-disabled', badgeSeverity: 'neutral' },
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const statusOrder: Record<UpgradeReadinessCheckStatus, number> = {
|
|
63
|
+
blocked: 0,
|
|
64
|
+
warning: 1,
|
|
65
|
+
review: 2,
|
|
66
|
+
unknown: 3,
|
|
67
|
+
passed: 4,
|
|
68
|
+
not_applicable: 5,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const checkPriority: Record<string, number> = {
|
|
72
|
+
'control-plane-upgrade-path': 0,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function UpgradeReadinessView({ namespaces, onNavigateToResource }: UpgradeReadinessViewProps) {
|
|
76
|
+
const location = useLocation()
|
|
77
|
+
const navigate = useNavigate()
|
|
78
|
+
const requestedTarget = new URLSearchParams(location.search).get('target') ?? undefined
|
|
79
|
+
const { data, isLoading, isFetching, isPlaceholderData, error, dataUpdatedAt, refetch } = useUpgradeReadiness(requestedTarget)
|
|
80
|
+
const { connection } = useConnection()
|
|
81
|
+
const targetOptions = useMemo(
|
|
82
|
+
() => buildTargetOptions(data?.currentVersion, data?.reviewedThrough, requestedTarget ?? data?.targetVersion),
|
|
83
|
+
[data?.currentVersion, data?.reviewedThrough, data?.targetVersion, requestedTarget],
|
|
84
|
+
)
|
|
85
|
+
const sortedChecks = useMemo(
|
|
86
|
+
() => [...(data?.checks ?? [])].sort((a, b) =>
|
|
87
|
+
statusOrder[a.status] - statusOrder[b.status]
|
|
88
|
+
|| (checkPriority[a.id] ?? 1) - (checkPriority[b.id] ?? 1)
|
|
89
|
+
|| a.category.localeCompare(b.category)
|
|
90
|
+
|| a.title.localeCompare(b.title),
|
|
91
|
+
),
|
|
92
|
+
[data?.checks],
|
|
93
|
+
)
|
|
94
|
+
const resetTarget = () => {
|
|
95
|
+
const params = new URLSearchParams(location.search)
|
|
96
|
+
params.delete('target')
|
|
97
|
+
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isLoading) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex-1 flex flex-col min-h-0 p-4 gap-4">
|
|
103
|
+
<ChecksViewTabs />
|
|
104
|
+
<PaneLoader label="Analyzing upgrade impact…" className="flex-1" />
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
if (error || !data) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex-1 flex flex-col min-h-0 p-4 gap-4">
|
|
111
|
+
<ChecksViewTabs />
|
|
112
|
+
<div className="flex flex-1 items-center justify-center">
|
|
113
|
+
<UpgradeReadinessError error={error} onResetTarget={resetTarget} />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const setTarget = (target: string) => {
|
|
120
|
+
const params = new URLSearchParams(location.search)
|
|
121
|
+
params.set('target', target)
|
|
122
|
+
navigate({ pathname: location.pathname, search: params.toString() })
|
|
123
|
+
}
|
|
124
|
+
const unsupportedTarget = compareMinor(data.targetVersion, data.reviewedThrough) > 0
|
|
125
|
+
const scopedKinds = Object.entries(data.coverage.scopedKinds ?? {})
|
|
126
|
+
const showingPreviousTarget = Boolean(isPlaceholderData && requestedTarget && requestedTarget !== data.targetVersion)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div aria-busy={isFetching} className="flex-1 flex flex-col min-h-0 p-4 gap-4 overflow-auto">
|
|
130
|
+
<PageHeader
|
|
131
|
+
icon={ShieldAlert}
|
|
132
|
+
title="Upgrade impact"
|
|
133
|
+
description={`Find configuration and compatibility changes before upgrading Kubernetes ${data.currentVersion} → ${data.targetVersion}.`}
|
|
134
|
+
actions={
|
|
135
|
+
<>
|
|
136
|
+
{data.coverage.state !== 'no_access' && (
|
|
137
|
+
<div className="flex items-center gap-2">
|
|
138
|
+
<span className="text-xs text-theme-text-tertiary">Target</span>
|
|
139
|
+
{targetOptions.length > 1 ? (
|
|
140
|
+
<SelectMenu
|
|
141
|
+
value={requestedTarget ?? data.targetVersion}
|
|
142
|
+
options={targetOptions}
|
|
143
|
+
onChange={setTarget}
|
|
144
|
+
ariaLabel="Target Kubernetes version"
|
|
145
|
+
className="w-24"
|
|
146
|
+
/>
|
|
147
|
+
) : (
|
|
148
|
+
<span className="rounded-md bg-theme-elevated px-2.5 py-1.5 text-xs font-medium tabular-nums text-theme-text-primary">
|
|
149
|
+
{data.targetVersion}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
<FreshnessControl
|
|
155
|
+
mode="snapshot"
|
|
156
|
+
dataUpdatedAt={dataUpdatedAt}
|
|
157
|
+
onRefresh={() => refetch()}
|
|
158
|
+
connectionState={connection.state}
|
|
159
|
+
isFetching={isFetching}
|
|
160
|
+
/>
|
|
161
|
+
</>
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
<ChecksViewTabs />
|
|
166
|
+
|
|
167
|
+
{showingPreviousTarget && (
|
|
168
|
+
<CoverageNotice
|
|
169
|
+
headline={`Analyzing Kubernetes ${requestedTarget}`}
|
|
170
|
+
body={`Results for Kubernetes ${data.targetVersion} remain visible until the new scan completes.`}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{data.coverage.state !== 'no_access' && (
|
|
175
|
+
<UpgradeSummary
|
|
176
|
+
verdict={data.verdict}
|
|
177
|
+
target={data.targetVersion}
|
|
178
|
+
reviewedThrough={data.reviewedThrough}
|
|
179
|
+
summary={data.summary}
|
|
180
|
+
coverageState={data.coverage.state}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{unsupportedTarget && data.coverage.state !== 'no_access' && (
|
|
185
|
+
<CoverageNotice
|
|
186
|
+
headline={`Coverage ends at Kubernetes ${data.reviewedThrough}`}
|
|
187
|
+
body={`Known results are shown, but checks added after ${data.reviewedThrough} are not included for ${data.targetVersion}. Update Radar when a release with ${data.targetVersion} coverage is available, then run this scan again.`}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
{data.coverage.state !== 'no_access' && (data.coverage.scopedNamespaces?.length ?? 0) > 0 && (
|
|
191
|
+
<CoverageNotice
|
|
192
|
+
headline={`Scoped to ${data.coverage.scopedNamespaces!.length} ${data.coverage.scopedNamespaces!.length === 1 ? 'namespace' : 'namespaces'}`}
|
|
193
|
+
body={`Results do not cover namespaced configuration outside ${formatNamespaceScope(data.coverage.scopedNamespaces!)}.`}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
{namespaces.length > 0 && !data.coverage.scopedNamespaces?.length && scopedKinds.length === 0 && data.coverage.state !== 'no_access' && (
|
|
197
|
+
<CoverageNotice
|
|
198
|
+
headline="Cluster-wide upgrade scan"
|
|
199
|
+
body="The current namespace browsing filter does not limit upgrade checks. Radar evaluates every namespace your identity can read."
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
202
|
+
{data.coverage.state !== 'no_access' && (data.coverage.unavailableKinds?.length ?? 0) > 0 && (
|
|
203
|
+
<CoverageNotice
|
|
204
|
+
headline="Some live resources were unavailable"
|
|
205
|
+
body={`Radar could not inspect: ${(data.coverage.unavailableKinds ?? []).join(', ')}. Affected checks are marked incomplete.`}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
{data.coverage.state !== 'no_access' && scopedKinds.length > 0 && (
|
|
209
|
+
<CoverageNotice
|
|
210
|
+
headline={`${scopedKinds.length} cached ${scopedKinds.length === 1 ? 'resource kind has' : 'resource kinds have'} narrower coverage`}
|
|
211
|
+
body={`Informer evidence is namespace-limited for ${formatScopedKinds(scopedKinds)}. Radar does not treat absence outside those per-kind scopes as proof.`}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
{data.coverage.state === 'partial' && !data.coverage.scopedNamespaces?.length && !data.coverage.unavailableKinds?.length && scopedKinds.length === 0 && (
|
|
215
|
+
<CoverageNotice headline="Some evidence is incomplete" body="Rows marked Partial evidence explain what Radar could not verify." />
|
|
216
|
+
)}
|
|
217
|
+
{data.coverage.state === 'no_access' ? (
|
|
218
|
+
<section className="shrink-0">
|
|
219
|
+
<EmptyState
|
|
220
|
+
tone="neutral"
|
|
221
|
+
icon={FileSearch}
|
|
222
|
+
headline="Upgrade checks need namespace access"
|
|
223
|
+
body="Radar cannot inspect workload configuration in this cluster for your current identity. Ask a cluster administrator for read access, then refresh this page."
|
|
224
|
+
/>
|
|
225
|
+
</section>
|
|
226
|
+
) : (
|
|
227
|
+
<section className="shrink-0 overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
228
|
+
<div className="flex items-center justify-between border-b-subtle px-4 py-3">
|
|
229
|
+
<div>
|
|
230
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">Checks for Kubernetes {data.targetVersion}</h2>
|
|
231
|
+
<p className="mt-0.5 text-xs text-theme-text-tertiary">Results are ordered by required action. Expand a row for evidence and remediation.</p>
|
|
232
|
+
</div>
|
|
233
|
+
<span className="text-right text-xs tabular-nums text-theme-text-tertiary">{upgradeEvaluationSummary(data.checks.length, data.summary, incompleteUpgradeCheckCount(data.checks))}</span>
|
|
234
|
+
</div>
|
|
235
|
+
<CoverageMethodology data={data} />
|
|
236
|
+
<div className="hidden grid-cols-[minmax(230px,0.9fr)_minmax(320px,1.6fr)_160px] gap-4 border-b-subtle bg-theme-elevated px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary md:grid">
|
|
237
|
+
<span>Check</span>
|
|
238
|
+
<span>Result</span>
|
|
239
|
+
<span>Status</span>
|
|
240
|
+
</div>
|
|
241
|
+
<div className="divide-y divide-theme-border">
|
|
242
|
+
{sortedChecks.map((check) => (
|
|
243
|
+
<CheckRow key={check.id} check={check} onNavigateToResource={onNavigateToResource} />
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
</section>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function UpgradeReadinessError({ error, onResetTarget }: { error: unknown; onResetTarget?: () => void }) {
|
|
253
|
+
const missingEndpoint = error instanceof ApiError
|
|
254
|
+
&& error.status === 404
|
|
255
|
+
&& error.message === 'Unknown error'
|
|
256
|
+
if (missingEndpoint) {
|
|
257
|
+
return (
|
|
258
|
+
<EmptyState
|
|
259
|
+
tone="neutral"
|
|
260
|
+
icon={ShieldAlert}
|
|
261
|
+
headline="Upgrade impact needs a newer Radar"
|
|
262
|
+
body={
|
|
263
|
+
<>
|
|
264
|
+
This cluster’s Radar predates Upgrade impact (added in Radar {UPGRADE_IMPACT_MIN_RADAR_VERSION}).
|
|
265
|
+
<br />
|
|
266
|
+
Upgrade the in-cluster Radar to enable it.
|
|
267
|
+
</>
|
|
268
|
+
}
|
|
269
|
+
/>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<EmptyState
|
|
275
|
+
tone="neutral"
|
|
276
|
+
icon={AlertTriangle}
|
|
277
|
+
headline="Unable to analyze upgrade impact"
|
|
278
|
+
body={error instanceof ApiError ? error.message : 'Failed to analyze upgrade impact.'}
|
|
279
|
+
action={error instanceof ApiError && error.status === 400 && onResetTarget ? (
|
|
280
|
+
<button type="button" onClick={onResetTarget} className="btn-brand px-3 py-1.5 text-xs font-medium">
|
|
281
|
+
Use next Kubernetes version
|
|
282
|
+
</button>
|
|
283
|
+
) : undefined}
|
|
284
|
+
/>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function incompleteUpgradeCheckCount(checks: Pick<UpgradeReadinessCheck, 'status' | 'caveat'>[]) {
|
|
289
|
+
return checks.filter((check) => check.status === 'unknown' || Boolean(check.caveat)).length
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function upgradeEvaluationSummary(total: number, summary: UpgradeReadinessResponse['summary'], incomplete = summary.unknown) {
|
|
293
|
+
const applicable = Math.max(0, total - summary.notApplicable)
|
|
294
|
+
const partialEvidenceLabel = incomplete > 0 ? ` · ${incomplete} with partial evidence` : ''
|
|
295
|
+
return `${total} evaluated · ${applicable} applicable${partialEvidenceLabel} · ${summary.notApplicable} not applicable`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function CoverageMethodology({ data }: { data: UpgradeReadinessResponse }) {
|
|
299
|
+
const [open, setOpen] = useState(false)
|
|
300
|
+
const unavailable = data.coverage.unavailableKinds ?? []
|
|
301
|
+
const scopedKinds = Object.entries(data.coverage.scopedKinds ?? {})
|
|
302
|
+
return (
|
|
303
|
+
<div className="border-b-subtle bg-theme-base/30">
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
aria-expanded={open}
|
|
307
|
+
onClick={() => setOpen((value) => !value)}
|
|
308
|
+
className="flex w-full items-center justify-between gap-4 px-4 py-2.5 text-left transition-colors hover:bg-theme-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-theme-text-primary/20"
|
|
309
|
+
>
|
|
310
|
+
<span className="inline-flex min-w-0 items-center gap-2 text-xs font-medium text-theme-text-secondary">
|
|
311
|
+
<FileSearch className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
312
|
+
Coverage and methodology
|
|
313
|
+
</span>
|
|
314
|
+
<span className="flex min-w-0 items-center gap-2 text-xs text-theme-text-tertiary">
|
|
315
|
+
<span className="hidden truncate sm:inline">Reviewed through Kubernetes {data.reviewedThrough}</span>
|
|
316
|
+
<CollapseChevron open={open} className="h-3.5 w-3.5 shrink-0" />
|
|
317
|
+
</span>
|
|
318
|
+
</button>
|
|
319
|
+
<Collapse open={open} mountLazily>
|
|
320
|
+
<div id="upgrade-coverage-methodology" className="space-y-2 border-t border-theme-border px-4 py-3 text-xs leading-5 text-theme-text-secondary">
|
|
321
|
+
<p>
|
|
322
|
+
Radar evaluated the checks relevant to Kubernetes {data.currentVersion} → {data.targetVersion}. Release-specific checks outside this upgrade path are excluded instead of being counted as passed or not applicable.
|
|
323
|
+
</p>
|
|
324
|
+
<p>
|
|
325
|
+
Results use live cluster resources and the row-specific evidence scope shown below. {unavailable.length > 0
|
|
326
|
+
? `Radar could not inspect ${unavailable.join(', ')}; affected checks are marked incomplete.`
|
|
327
|
+
: scopedKinds.length > 0
|
|
328
|
+
? `Cached evidence has per-kind namespace ceilings for ${formatScopedKinds(scopedKinds)}.`
|
|
329
|
+
: data.coverage.state === 'complete'
|
|
330
|
+
? 'Every required evidence source for the displayed checks was readable.'
|
|
331
|
+
: 'Coverage is partial; affected rows explain which evidence was missing, unreadable, or outside the scan scope.'}
|
|
332
|
+
</p>
|
|
333
|
+
<a
|
|
334
|
+
href={UPGRADE_IMPACT_DOCS_URL}
|
|
335
|
+
target="_blank"
|
|
336
|
+
rel="noopener noreferrer"
|
|
337
|
+
className="inline-flex items-center gap-1 text-accent-text hover:underline"
|
|
338
|
+
>
|
|
339
|
+
View the complete check catalog and evidence model <ExternalLink className="h-3 w-3" />
|
|
340
|
+
</a>
|
|
341
|
+
</div>
|
|
342
|
+
</Collapse>
|
|
343
|
+
</div>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function UpgradeSummary({ verdict, target, reviewedThrough, summary, coverageState }: {
|
|
348
|
+
verdict: UpgradeReadinessVerdict
|
|
349
|
+
target: string
|
|
350
|
+
reviewedThrough: string
|
|
351
|
+
summary: { blocked: number; warnings: number; reviews: number; passed: number; unknown: number; notApplicable: number }
|
|
352
|
+
coverageState: 'complete' | 'partial'
|
|
353
|
+
}) {
|
|
354
|
+
const meta = summaryMeta(verdict, target, reviewedThrough, summary, coverageState)
|
|
355
|
+
const Icon = meta.icon
|
|
356
|
+
return (
|
|
357
|
+
<div className={clsx('flex flex-col gap-3 rounded-xl border px-4 py-3 sm:flex-row sm:items-center sm:justify-between', meta.className)}>
|
|
358
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
359
|
+
<div className="rounded-full bg-theme-surface p-2 shadow-theme-sm"><Icon className={clsx('h-5 w-5', meta.iconClass)} /></div>
|
|
360
|
+
<div className="min-w-0">
|
|
361
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">{meta.headline}</h2>
|
|
362
|
+
<p className="mt-0.5 text-xs text-theme-text-secondary">{meta.body}</p>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-11 text-xs tabular-nums text-theme-text-secondary sm:pl-0">
|
|
366
|
+
{summary.blocked > 0 && <SummaryCount tone="error" value={summary.blocked} label="blocked" />}
|
|
367
|
+
{summary.warnings > 0 && <SummaryCount tone="warning" value={summary.warnings} label="warning" />}
|
|
368
|
+
{summary.reviews > 0 && <SummaryCount tone="info" value={summary.reviews} label="review" />}
|
|
369
|
+
{summary.passed > 0 && <SummaryCount tone="success" value={summary.passed} label="passed" />}
|
|
370
|
+
{summary.unknown > 0 && <SummaryCount tone="neutral" value={summary.unknown} label="incomplete" />}
|
|
371
|
+
{summary.notApplicable > 0 && <span>{summary.notApplicable} not applicable</span>}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function SummaryCount({ tone, value, label }: { tone: 'error' | 'warning' | 'info' | 'success' | 'neutral'; value: number; label: string }) {
|
|
378
|
+
const dot = { error: 'bg-red-500', warning: 'bg-amber-500', info: 'bg-blue-500', success: 'bg-emerald-500', neutral: 'bg-theme-text-tertiary' }[tone]
|
|
379
|
+
return <span className="inline-flex items-center gap-1.5"><span className={clsx('h-1.5 w-1.5 rounded-full', dot)} />{value} {label}</span>
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function summaryMeta(
|
|
383
|
+
verdict: UpgradeReadinessVerdict,
|
|
384
|
+
target: string,
|
|
385
|
+
reviewedThrough: string,
|
|
386
|
+
summary: { blocked: number; warnings: number; reviews: number; unknown: number },
|
|
387
|
+
coverageState: 'complete' | 'partial',
|
|
388
|
+
) {
|
|
389
|
+
if (verdict === 'blocked') return {
|
|
390
|
+
headline: `${summary.blocked} upgrade ${summary.blocked === 1 ? 'blocker' : 'blockers'} found`,
|
|
391
|
+
body: `Resolve blocked checks before moving the control plane to Kubernetes ${target}.${coverageState === 'partial' ? ' Evidence coverage is also incomplete.' : ''}`,
|
|
392
|
+
icon: AlertCircle, iconClass: 'text-red-600 dark:text-red-400', className: 'border-red-500/30 bg-red-500/5',
|
|
393
|
+
}
|
|
394
|
+
if (verdict === 'warning') return {
|
|
395
|
+
headline: `${summary.warnings} upgrade ${summary.warnings === 1 ? 'warning needs' : 'warnings need'} attention`,
|
|
396
|
+
body: `Resolve or explicitly accept these risks before scheduling Kubernetes ${target}.${coverageState === 'partial' ? ' Evidence coverage is also incomplete.' : ''}`,
|
|
397
|
+
icon: AlertTriangle, iconClass: 'text-amber-600 dark:text-amber-400', className: 'border-amber-500/30 bg-amber-500/5',
|
|
398
|
+
}
|
|
399
|
+
if (verdict === 'review') return {
|
|
400
|
+
headline: `${summary.reviews} upgrade ${summary.reviews === 1 ? 'item needs' : 'items need'} review`,
|
|
401
|
+
body: `Verify the affected configuration before scheduling Kubernetes ${target}.${coverageState === 'partial' ? ' Evidence coverage is also incomplete.' : ''}`,
|
|
402
|
+
icon: FileSearch, iconClass: 'text-blue-600 dark:text-blue-400', className: 'border-blue-500/30 bg-blue-500/5',
|
|
403
|
+
}
|
|
404
|
+
if (verdict === 'unknown') return {
|
|
405
|
+
headline: 'Upgrade evidence is incomplete',
|
|
406
|
+
body: summary.unknown > 0
|
|
407
|
+
? `${summary.unknown} ${summary.unknown === 1 ? 'check is' : 'checks are'} incomplete, so this is not a readiness guarantee.`
|
|
408
|
+
: coverageState !== 'complete'
|
|
409
|
+
? 'No blocker was found in the evidence Radar could inspect; this is not a cluster-wide readiness guarantee.'
|
|
410
|
+
: `Checks are reviewed through Kubernetes ${reviewedThrough}, not ${target}.`,
|
|
411
|
+
icon: CircleDashed, iconClass: 'text-theme-text-tertiary', className: 'border-theme-border bg-theme-elevated',
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
headline: `No blockers found for Kubernetes ${target}`,
|
|
415
|
+
body: 'Every applicable reviewed check passed. This result covers the evidence sources listed below.',
|
|
416
|
+
icon: CheckCircle2, iconClass: 'text-emerald-600 dark:text-emerald-400', className: 'border-emerald-500/30 bg-emerald-500/5',
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function CoverageNotice({ headline, body }: { headline: string; body: string }) {
|
|
421
|
+
return (
|
|
422
|
+
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs">
|
|
423
|
+
<FileSearch className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
424
|
+
<div><span className="font-medium text-theme-text-secondary">{headline}.</span> <span className="text-theme-text-tertiary">{body}</span></div>
|
|
425
|
+
</div>
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function CheckRow({ check, onNavigateToResource }: { check: UpgradeReadinessCheck; onNavigateToResource: (resource: SelectedResource) => void }) {
|
|
430
|
+
const meta = statusMeta[check.status]
|
|
431
|
+
const Icon = meta.icon
|
|
432
|
+
const [open, setOpen] = useState(false)
|
|
433
|
+
const detailID = `upgrade-check-${check.id}`
|
|
434
|
+
const findingGroups = groupFindings(check.findings)
|
|
435
|
+
const label = evidenceLabel(check)
|
|
436
|
+
const checkReferences = check.references ?? []
|
|
437
|
+
return (
|
|
438
|
+
<div>
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
aria-expanded={open}
|
|
442
|
+
onClick={() => setOpen((value) => !value)}
|
|
443
|
+
className="grid w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-theme-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-theme-text-primary/20 md:grid-cols-[minmax(230px,0.9fr)_minmax(320px,1.6fr)_160px] md:gap-4"
|
|
444
|
+
>
|
|
445
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
446
|
+
<Icon className={clsx('h-4 w-4 shrink-0', meta.iconClass)} />
|
|
447
|
+
<div className="min-w-0">
|
|
448
|
+
<div className="truncate text-sm font-medium text-theme-text-primary">{check.title}</div>
|
|
449
|
+
<div className="text-[11px] text-theme-text-tertiary">{check.category}</div>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
<div className="min-w-0 pl-6 md:pl-0">
|
|
453
|
+
<div className="text-xs leading-5 text-theme-text-secondary">{check.summary}</div>
|
|
454
|
+
</div>
|
|
455
|
+
<div className="flex min-w-0 items-center justify-between gap-2 pl-6 md:pl-0">
|
|
456
|
+
<div className="min-w-0">
|
|
457
|
+
<Badge severity={meta.badgeSeverity}>{meta.label}</Badge>
|
|
458
|
+
{label && <div className="mt-1 text-[11px] text-theme-text-tertiary">{label}</div>}
|
|
459
|
+
{check.caveat && (
|
|
460
|
+
<div className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
|
|
461
|
+
<AlertTriangle className="h-3 w-3 shrink-0" />
|
|
462
|
+
Partial evidence
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
<CollapseChevron open={open} className="h-4 w-4" />
|
|
467
|
+
</div>
|
|
468
|
+
</button>
|
|
469
|
+
<Collapse open={open} mountLazily>
|
|
470
|
+
<div id={detailID} className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
|
|
471
|
+
{check.findings.length > 0 && (
|
|
472
|
+
<div>
|
|
473
|
+
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 text-[11px] text-theme-text-tertiary">
|
|
474
|
+
<span className="font-medium uppercase tracking-wide">Issue types</span>
|
|
475
|
+
<span>{findingGroups.length} {findingGroups.length === 1 ? 'type' : 'types'} · {check.findings.length} {check.findings.length === 1 ? 'finding' : 'findings'}</span>
|
|
476
|
+
</div>
|
|
477
|
+
<div className="divide-y divide-theme-border overflow-hidden rounded-lg border border-theme-border bg-theme-surface">
|
|
478
|
+
{findingGroups.map((group, index) => (
|
|
479
|
+
<FindingGroupRow
|
|
480
|
+
key={group.key}
|
|
481
|
+
id={`${detailID}-issue-${index}`}
|
|
482
|
+
group={group}
|
|
483
|
+
checkReferences={checkReferences}
|
|
484
|
+
onNavigateToResource={onNavigateToResource}
|
|
485
|
+
/>
|
|
486
|
+
))}
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
<div className={clsx('flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-theme-text-tertiary', check.findings.length > 0 && 'mt-3 border-t-subtle pt-3')}>
|
|
491
|
+
{check.caveat && <span className="inline-flex items-start gap-1.5 text-amber-700 dark:text-amber-300"><AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />Coverage note: {check.caveat}</span>}
|
|
492
|
+
{check.evidenceNote && <span className="inline-flex items-start gap-1.5"><FileSearch className="mt-0.5 h-3.5 w-3.5 shrink-0" />Evidence scope: {check.evidenceNote}</span>}
|
|
493
|
+
<span className="inline-flex items-center gap-1.5"><FileSearch className="h-3.5 w-3.5" />Check scope: {check.scope}</span>
|
|
494
|
+
{checkReferences.map((reference) => <ReferenceLink key={reference.url} reference={reference} />)}
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
</Collapse>
|
|
498
|
+
</div>
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function groupFindings(findings: UpgradeReadinessFinding[]) {
|
|
503
|
+
const groups = new Map<string, UpgradeReadinessFinding[]>()
|
|
504
|
+
for (const finding of findings) {
|
|
505
|
+
const key = findingGroupKey(finding)
|
|
506
|
+
const grouped = groups.get(key)
|
|
507
|
+
if (grouped) grouped.push(finding)
|
|
508
|
+
else groups.set(key, [finding])
|
|
509
|
+
}
|
|
510
|
+
return [...groups.entries()].map(([key, grouped]) => ({ key, findings: grouped, total: grouped.length }))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function FindingGroupRow({ id, group, checkReferences, onNavigateToResource }: {
|
|
514
|
+
id: string
|
|
515
|
+
group: ReturnType<typeof groupFindings>[number]
|
|
516
|
+
checkReferences: NonNullable<UpgradeReadinessCheck['references']>
|
|
517
|
+
onNavigateToResource: (resource: SelectedResource) => void
|
|
518
|
+
}) {
|
|
519
|
+
const [open, setOpen] = useState(false)
|
|
520
|
+
const [showAll, setShowAll] = useState(false)
|
|
521
|
+
const finding = group.findings[0]
|
|
522
|
+
const references = issueSpecificReferences(checkReferences, finding.references)
|
|
523
|
+
const level = findingLevelMeta(finding.level)
|
|
524
|
+
const preview = group.findings.slice(0, FINDING_CAP)
|
|
525
|
+
const remaining = group.findings.slice(FINDING_CAP)
|
|
526
|
+
return (
|
|
527
|
+
<article>
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
aria-expanded={open}
|
|
531
|
+
onClick={() => setOpen((value) => !value)}
|
|
532
|
+
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-theme-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-theme-text-primary/20"
|
|
533
|
+
>
|
|
534
|
+
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
|
535
|
+
<Badge severity={level.severity}>{level.label}</Badge>
|
|
536
|
+
<span className="truncate text-xs font-medium text-theme-text-primary">{finding.title}</span>
|
|
537
|
+
</span>
|
|
538
|
+
<span className="flex shrink-0 items-center gap-2 text-[11px] text-theme-text-tertiary">
|
|
539
|
+
{group.total} {group.total === 1 ? 'finding' : 'findings'}
|
|
540
|
+
<CollapseChevron open={open} className="h-3.5 w-3.5" />
|
|
541
|
+
</span>
|
|
542
|
+
</button>
|
|
543
|
+
<Collapse open={open} mountLazily>
|
|
544
|
+
<div id={id} className="border-t border-theme-border bg-theme-base/30 px-3 py-3">
|
|
545
|
+
<div className="grid gap-3 lg:grid-cols-2">
|
|
546
|
+
<FindingDetail icon={AlertTriangle} label="Impact">{finding.impact}</FindingDetail>
|
|
547
|
+
<FindingDetail icon={Wrench} label="Remediation">{finding.remediation}</FindingDetail>
|
|
548
|
+
</div>
|
|
549
|
+
{(finding.appliesFrom || references.length > 0) && (
|
|
550
|
+
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 border-t-subtle pt-2 text-[11px] text-theme-text-tertiary">
|
|
551
|
+
{finding.appliesFrom && <span>Applies from Kubernetes {finding.appliesFrom}</span>}
|
|
552
|
+
{references.map((reference) => <ReferenceLink key={reference.url} reference={reference} />)}
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
<div className="mt-3 divide-y divide-theme-border/70 border-t-subtle">
|
|
556
|
+
{preview.map((item, index) => (
|
|
557
|
+
<CompactFindingRow key={findingKey(item, index)} finding={item} onNavigateToResource={onNavigateToResource} />
|
|
558
|
+
))}
|
|
559
|
+
{remaining.length > 0 && (
|
|
560
|
+
<Collapse open={showAll} mountLazily>
|
|
561
|
+
<div id={`${id}-remaining`} className="divide-y divide-theme-border/70">
|
|
562
|
+
{remaining.map((item, index) => (
|
|
563
|
+
<CompactFindingRow key={findingKey(item, index + FINDING_CAP)} finding={item} onNavigateToResource={onNavigateToResource} />
|
|
564
|
+
))}
|
|
565
|
+
</div>
|
|
566
|
+
</Collapse>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
{remaining.length > 0 && (
|
|
570
|
+
<button
|
|
571
|
+
type="button"
|
|
572
|
+
aria-expanded={showAll}
|
|
573
|
+
onClick={() => setShowAll((value) => !value)}
|
|
574
|
+
className="mt-2 inline-flex w-fit items-center gap-1 rounded px-1 py-1 text-xs font-medium text-accent-text hover:underline"
|
|
575
|
+
>
|
|
576
|
+
{showAll ? 'Show fewer findings' : `Show all ${group.total} findings`}
|
|
577
|
+
</button>
|
|
578
|
+
)}
|
|
579
|
+
</div>
|
|
580
|
+
</Collapse>
|
|
581
|
+
</article>
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function issueSpecificReferences(
|
|
586
|
+
checkReferences: NonNullable<UpgradeReadinessCheck['references']>,
|
|
587
|
+
findingReferences: UpgradeReadinessFinding['references'],
|
|
588
|
+
) {
|
|
589
|
+
const checkReferenceURLs = new Set(checkReferences.map((reference) => reference.url))
|
|
590
|
+
return findingReferences.filter((reference) => !checkReferenceURLs.has(reference.url))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function CompactFindingRow({ finding, onNavigateToResource }: {
|
|
594
|
+
finding: UpgradeReadinessFinding
|
|
595
|
+
onNavigateToResource: (resource: SelectedResource) => void
|
|
596
|
+
}) {
|
|
597
|
+
const resourceLabel = finding.resource && `${finding.resource.namespace ? `${finding.resource.namespace}/` : ''}${finding.resource.name}`
|
|
598
|
+
return (
|
|
599
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-[11px] text-theme-text-tertiary">
|
|
600
|
+
{finding.resource && (
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
onClick={() => onNavigateToResource({ ...finding.resource!, group: finding.resource!.group ?? '' })}
|
|
604
|
+
className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-theme-hover"
|
|
605
|
+
>
|
|
606
|
+
<Badge kind={finding.resource.kind} size="sm">{finding.resource.kind}</Badge>
|
|
607
|
+
<span className="max-w-[280px] truncate text-xs font-medium text-accent-text">{resourceLabel}</span>
|
|
608
|
+
</button>
|
|
609
|
+
)}
|
|
610
|
+
<span className="inline-flex min-w-0 items-center gap-1"><FileSearch className="h-3 w-3 shrink-0" /><code className="truncate">{finding.evidence.path}</code>{finding.evidence.detail ? ` · ${finding.evidence.detail}` : ''}</span>
|
|
611
|
+
{finding.managedBy && <span>Managed by {finding.managedBy.kind} {finding.managedBy.namespace ? `${finding.managedBy.namespace}/` : ''}{finding.managedBy.name}</span>}
|
|
612
|
+
</div>
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function formatNamespaceScope(namespaces: string[]) {
|
|
617
|
+
if (namespaces.length <= 3) return namespaces.join(', ')
|
|
618
|
+
return `${namespaces.slice(0, 3).join(', ')} and ${namespaces.length - 3} more`
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function formatScopedKinds(entries: [string, string[]][]) {
|
|
622
|
+
const labels = entries
|
|
623
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
624
|
+
.map(([kind, namespaces]) => `${kind} (${formatNamespaceScope(namespaces)})`)
|
|
625
|
+
if (labels.length <= 3) return labels.join(', ')
|
|
626
|
+
return `${labels.slice(0, 3).join(', ')} and ${labels.length - 3} more`
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function evidenceLabel(check: UpgradeReadinessCheck) {
|
|
630
|
+
if (check.findings.length > 0) return `${check.findings.length} ${check.findings.length === 1 ? 'finding' : 'findings'}`
|
|
631
|
+
if (check.inspected !== undefined && check.inspected > 0) return `${check.inspected} inspected`
|
|
632
|
+
return undefined
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function FindingDetail({ icon: Icon, label, children }: { icon: typeof AlertTriangle; label: string; children: React.ReactNode }) {
|
|
636
|
+
return (
|
|
637
|
+
<div className="flex gap-2">
|
|
638
|
+
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
639
|
+
<div><div className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</div><p className="mt-0.5 text-xs leading-5 text-theme-text-secondary">{children}</p></div>
|
|
640
|
+
</div>
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function ReferenceLink({ reference }: { reference: { title: string; url: string } }) {
|
|
645
|
+
return <a href={reference.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-accent-text hover:underline"><BookOpen className="h-3 w-3" />{reference.title}<ExternalLink className="h-2.5 w-2.5" /></a>
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function findingKey(finding: UpgradeReadinessFinding, index: number) {
|
|
649
|
+
const resource = finding.resource
|
|
650
|
+
return `${finding.ruleID}:${resource?.group ?? ''}:${resource?.kind ?? ''}:${resource?.namespace ?? ''}:${resource?.name ?? ''}:${finding.evidence.path}:${index}`
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function findingGroupKey(finding: UpgradeReadinessFinding) {
|
|
654
|
+
return JSON.stringify([
|
|
655
|
+
finding.ruleID,
|
|
656
|
+
finding.title,
|
|
657
|
+
finding.level,
|
|
658
|
+
finding.impact,
|
|
659
|
+
finding.remediation,
|
|
660
|
+
finding.appliesFrom,
|
|
661
|
+
finding.references,
|
|
662
|
+
])
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function findingLevelMeta(level: UpgradeReadinessFinding['level']) {
|
|
666
|
+
switch (level) {
|
|
667
|
+
case 'blocker':
|
|
668
|
+
return { label: 'Blocker', severity: 'error' as const }
|
|
669
|
+
case 'warning':
|
|
670
|
+
return { label: 'Warning', severity: 'warning' as const }
|
|
671
|
+
default:
|
|
672
|
+
return { label: 'Review', severity: 'info' as const }
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function buildTargetOptions(current?: string, reviewed?: string, selected?: string) {
|
|
677
|
+
const values = new Set<string>()
|
|
678
|
+
const currentParts = parseMinor(current)
|
|
679
|
+
const reviewedParts = parseMinor(reviewed)
|
|
680
|
+
if (currentParts && reviewedParts && currentParts.major === reviewedParts.major) {
|
|
681
|
+
for (let minor = currentParts.minor + 1; minor <= reviewedParts.minor && minor <= currentParts.minor + 20; minor++) {
|
|
682
|
+
values.add(`${currentParts.major}.${minor}`)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (selected) values.add(selected)
|
|
686
|
+
return [...values].sort(compareMinor).map((value) => ({ value, label: value }))
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function parseMinor(value?: string) {
|
|
690
|
+
const match = value?.match(/^v?(\d+)\.(\d+)/)
|
|
691
|
+
return match ? { major: Number(match[1]), minor: Number(match[2]) } : undefined
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function compareMinor(a: string, b: string) {
|
|
695
|
+
const av = parseMinor(a)
|
|
696
|
+
const bv = parseMinor(b)
|
|
697
|
+
if (!av || !bv) return a.localeCompare(b)
|
|
698
|
+
return av.major === bv.major ? av.minor - bv.minor : av.major - bv.major
|
|
699
|
+
}
|
|
@@ -24,6 +24,8 @@ import type {
|
|
|
24
24
|
} from "@skyhook-io/k8s-ui";
|
|
25
25
|
import { CapacityView } from "./CapacityView";
|
|
26
26
|
import { updateDemandSearchParam } from "./CapacityDemand";
|
|
27
|
+
import { integrationBlock } from "./shared";
|
|
28
|
+
import { ApiError } from "../../api/client";
|
|
27
29
|
|
|
28
30
|
vi.mock("../../context/ConnectionContext", () => ({
|
|
29
31
|
useConnection: () => ({ connection: { state: "connected" } }),
|
|
@@ -2285,3 +2287,22 @@ describe("CapacityView activity", () => {
|
|
|
2285
2287
|
expect(html).not.toContain("All · ≥3");
|
|
2286
2288
|
});
|
|
2287
2289
|
});
|
|
2290
|
+
|
|
2291
|
+
describe("integrationBlock version skew", () => {
|
|
2292
|
+
it("maps a top-level 404 to the pre-v1.9.0 upgrade message, never 'Unknown error'", () => {
|
|
2293
|
+
const html = renderToString(
|
|
2294
|
+
<>{integrationBlock(undefined, new ApiError("404 page not found", 404), false, "Loading")}</>,
|
|
2295
|
+
);
|
|
2296
|
+
expect(html).toContain("Capacity needs a newer Radar");
|
|
2297
|
+
expect(html).toContain("v1.9.0");
|
|
2298
|
+
expect(html).not.toContain("Capacity unavailable");
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
it("keeps ordinary failures on the generic error state", () => {
|
|
2302
|
+
const html = renderToString(
|
|
2303
|
+
<>{integrationBlock(undefined, new ApiError("boom", 500), false, "Loading")}</>,
|
|
2304
|
+
);
|
|
2305
|
+
expect(html).toContain("Capacity unavailable");
|
|
2306
|
+
expect(html).not.toContain("needs a newer Radar");
|
|
2307
|
+
});
|
|
2308
|
+
});
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
import {
|
|
52
52
|
isCapacityCursorInvalidError,
|
|
53
53
|
isForbiddenError,
|
|
54
|
+
isNotFoundError,
|
|
54
55
|
useCapacityPools,
|
|
55
56
|
} from "../../api/client";
|
|
56
57
|
import type { SelectedResource } from "../../types";
|
|
@@ -1340,7 +1341,7 @@ export function EmptyState({
|
|
|
1340
1341
|
}: {
|
|
1341
1342
|
icon: ComponentType<{ className?: string }>;
|
|
1342
1343
|
title: string;
|
|
1343
|
-
detail:
|
|
1344
|
+
detail: ReactNode;
|
|
1344
1345
|
action?: ReactNode;
|
|
1345
1346
|
}) {
|
|
1346
1347
|
return (
|
|
@@ -1517,9 +1518,27 @@ export function integrationBlock(
|
|
|
1517
1518
|
if (!response && isLoading)
|
|
1518
1519
|
return <PaneLoader label={loadingLabel} className="min-h-0 flex-1" />;
|
|
1519
1520
|
if (!response && error) {
|
|
1520
|
-
|
|
1521
|
-
<DeniedCapacityState detail={errorMessage(error)}
|
|
1522
|
-
|
|
1521
|
+
if (isForbiddenError(error))
|
|
1522
|
+
return <DeniedCapacityState detail={errorMessage(error)} />;
|
|
1523
|
+
// A 404 on the top-level capacity routes means the route doesn't exist on
|
|
1524
|
+
// this Radar — a pre-v1.9.0 binary behind a version-skewed host (Radar
|
|
1525
|
+
// Cloud), not a failure. The embedded OSS binary can never hit this.
|
|
1526
|
+
if (isNotFoundError(error))
|
|
1527
|
+
return (
|
|
1528
|
+
<EmptyState
|
|
1529
|
+
icon={Gauge}
|
|
1530
|
+
title="Capacity needs a newer Radar"
|
|
1531
|
+
detail={
|
|
1532
|
+
<>
|
|
1533
|
+
This cluster’s Radar predates the Capacity view (added in
|
|
1534
|
+
Radar v1.9.0).
|
|
1535
|
+
<br />
|
|
1536
|
+
Upgrade the in-cluster Radar to enable it.
|
|
1537
|
+
</>
|
|
1538
|
+
}
|
|
1539
|
+
/>
|
|
1540
|
+
);
|
|
1541
|
+
return (
|
|
1523
1542
|
<EmptyState
|
|
1524
1543
|
icon={AlertTriangle}
|
|
1525
1544
|
title="Capacity unavailable"
|
|
@@ -964,8 +964,10 @@ function AIUnavailableNotice() {
|
|
|
964
964
|
<div className="rounded-md border border-theme-border bg-theme-elevated/50 p-3">
|
|
965
965
|
<p className="text-sm font-medium text-theme-text-primary">No supported agent CLI found</p>
|
|
966
966
|
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
967
|
-
Install <span className="text-theme-text-secondary">Claude Code</span
|
|
968
|
-
<span className="text-theme-text-secondary">Codex</span>,
|
|
967
|
+
Install <span className="text-theme-text-secondary">Claude Code</span>,{' '}
|
|
968
|
+
<span className="text-theme-text-secondary">Codex</span>, or{' '}
|
|
969
|
+
<span className="text-theme-text-secondary">Cursor</span> (
|
|
970
|
+
<span className="font-mono">cursor-agent</span>), then restart Radar — this tab
|
|
969
971
|
will show the agent, model, and effort controls.
|
|
970
972
|
</p>
|
|
971
973
|
</div>
|