@skyhook-io/radar-app 1.9.3 → 1.9.5

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.
@@ -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&rsquo;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
+ }