@skyhook-io/radar-app 1.8.3 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +256 -94
  3. package/src/RadarApp.tsx +4 -1
  4. package/src/api/client.metrics.test.ts +106 -0
  5. package/src/api/client.ts +147 -20
  6. package/src/components/ConnectionErrorView.tsx +1 -1
  7. package/src/components/ContextSwitcher.tsx +5 -1
  8. package/src/components/NamespaceSwitcher.tsx +21 -300
  9. package/src/components/applications/ApplicationsView.tsx +22 -7
  10. package/src/components/audit/AuditView.tsx +11 -2
  11. package/src/components/cost/CostView.tsx +12 -2
  12. package/src/components/gitops/GitOpsView.tsx +22 -7
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +4 -4
  17. package/src/components/home/ClusterHealthCard.tsx +6 -1
  18. package/src/components/home/HomeView.tsx +20 -7
  19. package/src/components/home/mcpToolCatalog.ts +1 -1
  20. package/src/components/issues/IssuesPane.tsx +29 -18
  21. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  22. package/src/components/resources/ResourcesView.tsx +3 -0
  23. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  25. package/src/components/timeline/TimelineView.tsx +26 -2
  26. package/src/components/traffic/TrafficView.tsx +17 -10
  27. package/src/components/ui/Markdown.tsx +2 -2
  28. package/src/components/ui/Omnibar.tsx +1 -1
  29. package/src/components/workload/WorkloadView.tsx +5 -1
  30. package/src/filter/FilterLocationBridge.tsx +30 -0
  31. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  32. package/src/index.ts +15 -0
@@ -0,0 +1,1342 @@
1
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
3
+ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
4
+ import { clsx } from 'clsx'
5
+ import {
6
+ AlertTriangle,
7
+ Anchor,
8
+ ArrowLeft,
9
+ ArrowLeftRight,
10
+ Check,
11
+ CheckCircle2,
12
+ ChevronDown,
13
+ Code,
14
+ FileText,
15
+ GitCompare,
16
+ Link2,
17
+ Package,
18
+ Settings,
19
+ } from 'lucide-react'
20
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
21
+ import {
22
+ useCloudRole,
23
+ useHelmHooksDiff,
24
+ useHelmManifestDiff,
25
+ useHelmNotesDiff,
26
+ useHelmRelease,
27
+ useHelmResourceDiff,
28
+ useHelmValuesDiff,
29
+ } from '../../api/client'
30
+ import type { HelmHook, HelmRevision, HooksDiff, ResourceDiff } from '../../types'
31
+ import { getHelmStatusColor, getKindBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
32
+ import { formatDate } from './helm-utils'
33
+ import { DiffLine, hasDiffBodyChange } from './ManifestDiffViewer'
34
+ import { RoleGatedPanel } from './RoleGatedPanel'
35
+ import { Tooltip } from '../ui/Tooltip'
36
+
37
+ type DiffTone = 'success' | 'warning' | 'error' | 'info' | 'neutral'
38
+
39
+ interface DiffStats {
40
+ changed: boolean
41
+ additions: number
42
+ removals: number
43
+ hunks: number
44
+ }
45
+
46
+ interface ParsedReleaseParam {
47
+ namespace: string
48
+ name: string
49
+ }
50
+
51
+ export function HelmCompareRoute() {
52
+ const navigate = useNavigate()
53
+ const location = useLocation()
54
+ const [searchParams, setSearchParams] = useSearchParams()
55
+ const { canAtLeast } = useCloudRole()
56
+ const canViewSensitive = canAtLeast('member')
57
+
58
+ const releaseRef = parseReleaseParam(searchParams.get('release'))
59
+ const storageNamespace = searchParams.get('releaseStorage') || undefined
60
+ const helmNamespace = storageNamespace || releaseRef?.namespace || ''
61
+ const releaseName = releaseRef?.name || ''
62
+ const revision1Param = parsePositiveInt(searchParams.get('revision1'))
63
+ const revision2Param = parsePositiveInt(searchParams.get('revision2'))
64
+
65
+ const releaseQuery = useHelmRelease(helmNamespace, releaseName)
66
+ const release = releaseQuery.data
67
+ const revisions = useMemo(
68
+ () => [...(release?.history || [])].sort((a, b) => a.revision - b.revision),
69
+ [release?.history],
70
+ )
71
+ const defaultRightRevision = revision2Param || release?.revision || revisions.at(-1)?.revision || 0
72
+ const defaultLeftRevision = revision1Param || previousRevision(revisions, defaultRightRevision) || revisions.at(0)?.revision || 0
73
+ const revision1 = defaultLeftRevision
74
+ const revision2 = defaultRightRevision
75
+ const pairReady = Boolean(helmNamespace && releaseName && revision1 > 0 && revision2 > 0 && revision1 !== revision2)
76
+ const diffEnabled = canViewSensitive && pairReady
77
+
78
+ const left = revisions.find((r) => r.revision === revision1)
79
+ const right = revisions.find((r) => r.revision === revision2)
80
+
81
+ const manifestDiff = useHelmManifestDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
82
+ const valuesDiff = useHelmValuesDiff(helmNamespace, releaseName, revision1, revision2, false, diffEnabled)
83
+ const notesDiff = useHelmNotesDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
84
+ const hooksDiff = useHelmHooksDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
85
+ const resourceDiff = useHelmResourceDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
86
+
87
+ const updateRevision = useCallback(
88
+ (key: 'revision1' | 'revision2', value: number) => {
89
+ const params = new URLSearchParams(searchParams)
90
+ params.set(key, String(value))
91
+ setSearchParams(params, { replace: true })
92
+ },
93
+ [searchParams, setSearchParams],
94
+ )
95
+
96
+ const swapRevisions = useCallback(() => {
97
+ if (!revision1 || !revision2) return
98
+ const params = new URLSearchParams(searchParams)
99
+ params.set('revision1', String(revision2))
100
+ params.set('revision2', String(revision1))
101
+ setSearchParams(params, { replace: true })
102
+ }, [revision1, revision2, searchParams, setSearchParams])
103
+
104
+ const backToRelease = useCallback(() => {
105
+ const params = new URLSearchParams()
106
+ const globalNamespaces = searchParams.get('namespaces')
107
+ if (globalNamespaces) params.set('namespaces', globalNamespaces)
108
+ if (releaseRef) params.set('release', `${releaseRef.namespace}/${releaseRef.name}`)
109
+ if (storageNamespace) params.set('releaseStorage', storageNamespace)
110
+ navigate({ pathname: '/helm', search: params.toString() })
111
+ }, [navigate, releaseRef, searchParams, storageNamespace])
112
+
113
+ useEffect(() => {
114
+ if (!location.hash) return
115
+ const sectionId = decodeURIComponent(location.hash.slice(1))
116
+ let cancelled = false
117
+ let attempts = 0
118
+ let timeout: number | undefined
119
+ const scroll = () => {
120
+ if (cancelled) return
121
+ document.getElementById(sectionId)?.scrollIntoView({ block: 'start' })
122
+ attempts += 1
123
+ if (attempts < 8) timeout = window.setTimeout(scroll, 75)
124
+ }
125
+ timeout = window.setTimeout(scroll, 0)
126
+ return () => {
127
+ cancelled = true
128
+ if (timeout) window.clearTimeout(timeout)
129
+ }
130
+ }, [location.hash, releaseName, revision1, revision2])
131
+
132
+ if (!releaseRef) {
133
+ return (
134
+ <div className="flex h-full flex-col items-center justify-center gap-3 bg-theme-base p-8 text-center">
135
+ <div className="text-sm font-medium text-theme-text-primary">This Helm compare link is missing a release.</div>
136
+ <button onClick={() => navigate('/helm')} className="btn-brand rounded-lg px-3 py-1.5 text-xs font-medium">
137
+ Back to Helm
138
+ </button>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ if (releaseQuery.isLoading && !release) {
144
+ return <PaneLoader label="Loading release..." className="h-full bg-theme-base" />
145
+ }
146
+
147
+ if (releaseQuery.error && !release) {
148
+ return (
149
+ <div className="flex h-full flex-col items-center justify-center gap-3 bg-theme-base p-8 text-center">
150
+ <AlertTriangle className="h-8 w-8 text-red-500" />
151
+ <div className="text-sm font-medium text-theme-text-primary">Release not found</div>
152
+ <div className="max-w-lg text-xs text-theme-text-secondary">
153
+ {releaseQuery.error instanceof Error ? releaseQuery.error.message : 'Radar could not load this Helm release.'}
154
+ </div>
155
+ <button onClick={backToRelease} className="rounded-lg border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover">
156
+ Back to release
157
+ </button>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ return (
163
+ <div className="flex h-full min-w-0 flex-col bg-theme-base">
164
+ <header className="shrink-0 border-b border-theme-border bg-theme-surface/95 px-4 py-3">
165
+ <div className="flex w-full flex-wrap items-start justify-between gap-3">
166
+ <div className="min-w-0">
167
+ <button
168
+ type="button"
169
+ onClick={backToRelease}
170
+ className="mb-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
171
+ >
172
+ <ArrowLeft className="h-3.5 w-3.5" />
173
+ Back to release
174
+ </button>
175
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
176
+ <Package className="h-5 w-5 shrink-0 text-purple-400" />
177
+ <h1 className="truncate text-lg font-semibold text-theme-text-primary">{releaseName}</h1>
178
+ {release && (
179
+ <span className={clsx('badge', getHelmStatusColor(release.status))}>{release.status}</span>
180
+ )}
181
+ {storageNamespace && storageNamespace !== releaseRef.namespace && (
182
+ <span className={clsx('badge', SEVERITY_BADGE.neutral)}>stored in {storageNamespace}</span>
183
+ )}
184
+ </div>
185
+ <div className="mt-1 text-sm text-theme-text-tertiary">{releaseRef.namespace}</div>
186
+ </div>
187
+
188
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border border-theme-border bg-theme-base/70 p-2">
189
+ <RevisionSelect
190
+ label="From"
191
+ value={revision1}
192
+ revisions={revisions}
193
+ onChange={(rev) => updateRevision('revision1', rev)}
194
+ />
195
+ <Tooltip content="Swap revisions" position="bottom">
196
+ <button
197
+ type="button"
198
+ onClick={swapRevisions}
199
+ disabled={!pairReady}
200
+ aria-label="Swap revisions"
201
+ className="rounded-md border border-theme-border bg-theme-elevated p-1.5 text-theme-text-secondary shadow-theme-sm transition-colors hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
202
+ >
203
+ <ArrowLeftRight className="h-4 w-4" />
204
+ </button>
205
+ </Tooltip>
206
+ <RevisionSelect
207
+ label="To"
208
+ value={revision2}
209
+ revisions={revisions}
210
+ onChange={(rev) => updateRevision('revision2', rev)}
211
+ />
212
+ </div>
213
+ </div>
214
+ </header>
215
+
216
+ <RoleGatedPanel min="member" feature="release revision comparison">
217
+ <div className="min-h-0 flex-1 overflow-y-auto">
218
+ <div className={clsx('grid w-full grid-cols-1 gap-4 px-4 py-4', pairReady && 'xl:grid-cols-[220px_minmax(0,1fr)]')}>
219
+ {pairReady && (
220
+ <nav className="hidden xl:block">
221
+ <div className="sticky top-4 rounded-xl border border-theme-border bg-theme-surface p-2 shadow-theme-sm">
222
+ <div className="px-2 pb-2 text-[11px] font-medium uppercase text-theme-text-tertiary">Compare</div>
223
+ {[
224
+ ['summary', 'Summary'],
225
+ ['manifest', 'Manifest'],
226
+ ['resources', 'Resources'],
227
+ ['values', 'Values'],
228
+ ['hooks', 'Hooks'],
229
+ ['notes', 'Notes'],
230
+ ].map(([id, label]) => (
231
+ <a
232
+ key={id}
233
+ href={`#${id}`}
234
+ onClick={(event) => scrollCompareSection(event, id)}
235
+ className="block rounded-md px-2 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
236
+ >
237
+ {label}
238
+ </a>
239
+ ))}
240
+ </div>
241
+ </nav>
242
+ )}
243
+
244
+ <main className="min-w-0 space-y-4">
245
+ {!pairReady ? (
246
+ <div className="card-inner-lg flex items-start gap-3">
247
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
248
+ <div>
249
+ <div className="text-sm font-medium text-theme-text-primary">Pick two different revisions to compare</div>
250
+ <div className="mt-1 text-sm text-theme-text-secondary">Use the revision selectors above to choose a source and target revision.</div>
251
+ </div>
252
+ </div>
253
+ ) : (
254
+ <>
255
+ <section id="summary" className="scroll-mt-4 space-y-4">
256
+ <CompareSummary
257
+ left={left}
258
+ right={right}
259
+ revision1={revision1}
260
+ revision2={revision2}
261
+ manifestDiff={manifestDiff.data?.diff}
262
+ manifestLoading={manifestDiff.isLoading}
263
+ manifestError={manifestDiff.error}
264
+ valuesDiff={valuesDiff.data?.diff}
265
+ valuesLoading={valuesDiff.isLoading}
266
+ valuesError={valuesDiff.error}
267
+ notesDiff={notesDiff.data?.diff}
268
+ notesLoading={notesDiff.isLoading}
269
+ notesError={notesDiff.error}
270
+ hooksDiff={hooksDiff.data}
271
+ hooksLoading={hooksDiff.isLoading}
272
+ hooksError={hooksDiff.error}
273
+ resourceDiff={resourceDiff.data}
274
+ resourceLoading={resourceDiff.isLoading}
275
+ resourceError={resourceDiff.error}
276
+ />
277
+ </section>
278
+
279
+ <DiffSection
280
+ id="manifest"
281
+ icon={Code}
282
+ title="Rendered manifest diff"
283
+ description="Rendered Kubernetes YAML is the ground truth for what Helm would apply between these revisions."
284
+ diff={manifestDiff.data?.diff || ''}
285
+ isLoading={manifestDiff.isLoading}
286
+ error={manifestDiff.error}
287
+ emptyLabel="No rendered manifest changes found."
288
+ />
289
+
290
+ <ResourceInventoryDiffSection
291
+ diff={resourceDiff.data}
292
+ isLoading={resourceDiff.isLoading}
293
+ error={resourceDiff.error}
294
+ left={left}
295
+ right={right}
296
+ revision1={revision1}
297
+ revision2={revision2}
298
+ />
299
+
300
+ <DiffSection
301
+ id="values"
302
+ icon={Settings}
303
+ title="User-supplied values diff"
304
+ description="Only values explicitly supplied to the release are compared here; computed chart defaults can still affect the rendered manifest."
305
+ diff={valuesDiff.data?.diff || ''}
306
+ isLoading={valuesDiff.isLoading}
307
+ error={valuesDiff.error}
308
+ emptyLabel="No user-supplied value changes found."
309
+ />
310
+
311
+ <HooksDiffSection diff={hooksDiff.data} isLoading={hooksDiff.isLoading} error={hooksDiff.error} />
312
+
313
+ <DiffSection
314
+ id="notes"
315
+ icon={FileText}
316
+ title="Release notes diff"
317
+ description="NOTES.txt output can reveal chart-level instructions that changed without changing live Kubernetes objects."
318
+ diff={notesDiff.data?.diff || ''}
319
+ isLoading={notesDiff.isLoading}
320
+ error={notesDiff.error}
321
+ emptyLabel="No release notes changes found."
322
+ />
323
+
324
+ </>
325
+ )}
326
+ </main>
327
+ </div>
328
+ </div>
329
+ </RoleGatedPanel>
330
+ </div>
331
+ )
332
+ }
333
+
334
+ function RevisionSelect({
335
+ label,
336
+ value,
337
+ revisions,
338
+ onChange,
339
+ }: {
340
+ label: string
341
+ value: number
342
+ revisions: HelmRevision[]
343
+ onChange: (revision: number) => void
344
+ }) {
345
+ const [open, setOpen] = useState(false)
346
+ const [activeIndex, setActiveIndex] = useState(0)
347
+ const rootRef = useRef<HTMLDivElement>(null)
348
+ const listboxId = useId()
349
+ const selected = revisions.find((revision) => revision.revision === value)
350
+ const selectedIndex = Math.max(0, revisions.findIndex((revision) => revision.revision === value))
351
+
352
+ useEffect(() => {
353
+ if (!open) return
354
+ const close = () => setOpen(false)
355
+ const onDown = (event: globalThis.MouseEvent) => {
356
+ if (!rootRef.current?.contains(event.target as Node)) close()
357
+ }
358
+ const onKey = (event: KeyboardEvent) => {
359
+ if (event.key === 'Escape') close()
360
+ }
361
+ document.addEventListener('mousedown', onDown)
362
+ document.addEventListener('keydown', onKey)
363
+ window.addEventListener('resize', close)
364
+ return () => {
365
+ document.removeEventListener('mousedown', onDown)
366
+ document.removeEventListener('keydown', onKey)
367
+ window.removeEventListener('resize', close)
368
+ }
369
+ }, [open])
370
+
371
+ useEffect(() => {
372
+ if (open) setActiveIndex(selectedIndex)
373
+ }, [open, selectedIndex])
374
+
375
+ useEffect(() => {
376
+ if (!open) return
377
+ const revision = revisions[activeIndex]
378
+ if (!revision) return
379
+ document.getElementById(`${listboxId}-${revision.revision}`)?.scrollIntoView({ block: 'nearest' })
380
+ }, [activeIndex, listboxId, open, revisions])
381
+
382
+ const selectRevision = (revision: number) => {
383
+ setOpen(false)
384
+ if (revision !== value) onChange(revision)
385
+ }
386
+
387
+ const moveActive = (delta: number) => {
388
+ if (revisions.length === 0) return
389
+ setActiveIndex((current) => {
390
+ const next = current + delta
391
+ if (next < 0) return revisions.length - 1
392
+ if (next >= revisions.length) return 0
393
+ return next
394
+ })
395
+ }
396
+
397
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
398
+ if (revisions.length === 0) return
399
+ if (event.key === 'ArrowDown') {
400
+ event.preventDefault()
401
+ if (!open) {
402
+ setOpen(true)
403
+ setActiveIndex(selectedIndex)
404
+ } else {
405
+ moveActive(1)
406
+ }
407
+ } else if (event.key === 'ArrowUp') {
408
+ event.preventDefault()
409
+ if (!open) {
410
+ setOpen(true)
411
+ setActiveIndex(selectedIndex)
412
+ } else {
413
+ moveActive(-1)
414
+ }
415
+ } else if (event.key === 'Enter' && open) {
416
+ event.preventDefault()
417
+ selectRevision(revisions[activeIndex]?.revision || value)
418
+ }
419
+ }
420
+
421
+ return (
422
+ <div ref={rootRef} className="relative flex items-center gap-2 text-xs text-theme-text-tertiary">
423
+ <span>{label}</span>
424
+ <button
425
+ type="button"
426
+ onClick={() => setOpen((next) => !next)}
427
+ onKeyDown={onKeyDown}
428
+ disabled={revisions.length === 0}
429
+ aria-haspopup="listbox"
430
+ aria-expanded={open}
431
+ aria-controls={listboxId}
432
+ aria-activedescendant={open ? `${listboxId}-${revisions[activeIndex]?.revision || value}` : undefined}
433
+ aria-label={`${label} revision`}
434
+ className={clsx(
435
+ 'inline-flex max-w-[34rem] items-center gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 py-1.5 text-left text-sm font-medium text-theme-text-primary shadow-theme-sm transition-colors',
436
+ revisions.length === 0 ? 'cursor-not-allowed opacity-60' : 'hover:bg-theme-hover',
437
+ )}
438
+ >
439
+ <span className="min-w-0 truncate">{selected ? formatRevisionOption(selected) : 'No revisions'}</span>
440
+ <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
441
+ </button>
442
+ {open && (
443
+ <div
444
+ id={listboxId}
445
+ role="listbox"
446
+ className="absolute right-0 top-full z-50 mt-1 max-h-80 w-[min(36rem,calc(100vw-2rem))] overflow-y-auto rounded-lg border border-theme-border bg-theme-surface p-1 shadow-theme-lg"
447
+ >
448
+ {revisions.map((revision, index) => {
449
+ const selectedRevision = revision.revision === value
450
+ const activeRevision = index === activeIndex
451
+ return (
452
+ <button
453
+ key={revision.revision}
454
+ id={`${listboxId}-${revision.revision}`}
455
+ type="button"
456
+ role="option"
457
+ aria-selected={selectedRevision}
458
+ onMouseEnter={() => setActiveIndex(index)}
459
+ onClick={() => selectRevision(revision.revision)}
460
+ className={clsx(
461
+ 'flex w-full min-w-0 items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
462
+ selectedRevision ? 'selection selection-ring' : activeRevision ? 'bg-theme-hover' : 'hover:bg-theme-hover',
463
+ )}
464
+ >
465
+ <span className="w-14 shrink-0 font-medium text-theme-text-primary">rev {revision.revision}</span>
466
+ <span className={clsx('badge-sm shrink-0', getHelmStatusColor(revision.status))}>{revision.status}</span>
467
+ <span className="min-w-0 flex-1 truncate text-theme-text-secondary">{revision.chart}</span>
468
+ {selectedRevision && <Check className="h-4 w-4 shrink-0 text-accent" />}
469
+ </button>
470
+ )
471
+ })}
472
+ </div>
473
+ )}
474
+ </div>
475
+ )
476
+ }
477
+
478
+ function formatRevisionOption(revision: HelmRevision): string {
479
+ return `rev ${revision.revision} - ${revision.status} - ${revision.chart}`
480
+ }
481
+
482
+ function CompareSummary({
483
+ left,
484
+ right,
485
+ revision1,
486
+ revision2,
487
+ manifestDiff,
488
+ manifestLoading,
489
+ manifestError,
490
+ valuesDiff,
491
+ valuesLoading,
492
+ valuesError,
493
+ notesDiff,
494
+ notesLoading,
495
+ notesError,
496
+ hooksDiff,
497
+ hooksLoading,
498
+ hooksError,
499
+ resourceDiff,
500
+ resourceLoading,
501
+ resourceError,
502
+ }: {
503
+ left?: HelmRevision
504
+ right?: HelmRevision
505
+ revision1: number
506
+ revision2: number
507
+ manifestDiff?: string
508
+ manifestLoading: boolean
509
+ manifestError: unknown
510
+ valuesDiff?: string
511
+ valuesLoading: boolean
512
+ valuesError: unknown
513
+ notesDiff?: string
514
+ notesLoading: boolean
515
+ notesError: unknown
516
+ hooksDiff?: HooksDiff
517
+ hooksLoading: boolean
518
+ hooksError: unknown
519
+ resourceDiff?: ResourceDiff
520
+ resourceLoading: boolean
521
+ resourceError: unknown
522
+ }) {
523
+ const manifestStats = diffStats(manifestDiff || '')
524
+ const valuesStats = diffStats(valuesDiff || '')
525
+ const notesStats = diffStats(notesDiff || '')
526
+ const hookChanged = hooksDiff ? hooksDiff.added.length + hooksDiff.removed.length + hooksDiff.modified.length : 0
527
+ const resourceChanged = resourceDiff ? resourceDiff.added.length + resourceDiff.removed.length + resourceDiff.modified.length : 0
528
+ const resourceParseErrors = resourceDiff?.parseErrorCount || 0
529
+
530
+ return (
531
+ <div className="card-inner-lg">
532
+ <div className="mb-4">
533
+ <div>
534
+ <div className="flex flex-wrap items-center gap-2">
535
+ <GitCompare className="h-4 w-4 text-theme-text-secondary" />
536
+ <h2 className="text-base font-semibold text-theme-text-primary">Revision {revision1} -&gt; {revision2}</h2>
537
+ </div>
538
+ <p className="mt-1 text-sm text-theme-text-secondary">
539
+ Start with the rendered manifest diff below, then use resources, values, hooks, and notes as supporting evidence.
540
+ </p>
541
+ </div>
542
+ </div>
543
+
544
+ <div className="flex flex-wrap items-center gap-2">
545
+ <span className="text-xs font-medium uppercase text-theme-text-tertiary">Evidence</span>
546
+ <SignalPill
547
+ label="Manifest"
548
+ loading={manifestLoading}
549
+ error={manifestError}
550
+ tone={manifestStats.changed ? 'info' : 'neutral'}
551
+ value={manifestStats.changed ? `${manifestStats.additions} add / ${manifestStats.removals} remove` : 'same'}
552
+ sectionId="manifest"
553
+ />
554
+ <SignalPill
555
+ label="Resources"
556
+ loading={resourceLoading}
557
+ error={resourceError}
558
+ tone={resourceParseErrors > 0 ? 'warning' : resourceChanged > 0 ? 'warning' : 'neutral'}
559
+ value={resourceDiff ? (resourceParseErrors > 0 ? `partial, ${resourceChanged} changed` : resourceChanged > 0 ? `${resourceChanged} changed` : 'same') : 'same'}
560
+ sectionId="resources"
561
+ />
562
+ <SignalPill
563
+ label="Values"
564
+ loading={valuesLoading}
565
+ error={valuesError}
566
+ tone={valuesStats.changed ? 'info' : 'neutral'}
567
+ value={valuesStats.changed ? `${valuesStats.additions} add / ${valuesStats.removals} remove` : 'same'}
568
+ sectionId="values"
569
+ />
570
+ <SignalPill
571
+ label="Hooks"
572
+ loading={hooksLoading}
573
+ error={hooksError}
574
+ tone={hookChanged > 0 ? 'warning' : 'neutral'}
575
+ value={hooksDiff ? `${hookChanged} changed` : 'same'}
576
+ sectionId="hooks"
577
+ />
578
+ <SignalPill
579
+ label="Notes"
580
+ loading={notesLoading}
581
+ error={notesError}
582
+ tone={notesStats.changed ? 'info' : 'neutral'}
583
+ value={notesStats.changed ? `${notesStats.additions} add / ${notesStats.removals} remove` : 'same'}
584
+ sectionId="notes"
585
+ />
586
+ </div>
587
+
588
+ <div className="mt-4">
589
+ <div className="mb-2 text-xs font-medium uppercase text-theme-text-tertiary">Release metadata</div>
590
+ <MetadataDiffTable
591
+ revision1={revision1}
592
+ revision2={revision2}
593
+ rows={[
594
+ { label: 'Chart', left: left?.chart, right: right?.chart },
595
+ { label: 'Status', left: left?.status, right: right?.status, status: true },
596
+ { label: 'App version', left: left?.appVersion, right: right?.appVersion },
597
+ {
598
+ label: 'Updated',
599
+ left: left?.updated ? formatDate(left.updated) : undefined,
600
+ right: right?.updated ? formatDate(right.updated) : undefined,
601
+ },
602
+ ]}
603
+ />
604
+ </div>
605
+ </div>
606
+ )
607
+ }
608
+
609
+ interface MetadataDiffRow {
610
+ label: string
611
+ left?: string
612
+ right?: string
613
+ status?: boolean
614
+ }
615
+
616
+ function MetadataDiffTable({ revision1, revision2, rows }: { revision1: number; revision2: number; rows: MetadataDiffRow[] }) {
617
+ return (
618
+ <div className="overflow-hidden rounded-lg border border-theme-border bg-theme-base/50">
619
+ <div className="hidden grid-cols-[7.5rem_minmax(0,1fr)_1.5rem_minmax(0,1fr)_5rem] items-center gap-3 border-b border-theme-border bg-theme-surface/70 px-3 py-2 text-xs font-medium text-theme-text-tertiary md:grid">
620
+ <span>Field</span>
621
+ <span>Rev {revision1}</span>
622
+ <span />
623
+ <span>Rev {revision2}</span>
624
+ <span className="text-right">Diff</span>
625
+ </div>
626
+ {rows.map((row) => <MetadataDiffTableRow key={row.label} row={row} />)}
627
+ </div>
628
+ )
629
+ }
630
+
631
+ function MetadataDiffTableRow({ row }: { row: MetadataDiffRow }) {
632
+ const changed = (row.left || '') !== (row.right || '')
633
+ return (
634
+ <div
635
+ className={clsx(
636
+ 'grid grid-cols-1 gap-1 border-t border-theme-border px-3 py-2.5 text-sm first:border-t-0 md:grid-cols-[7.5rem_minmax(0,1fr)_1.5rem_minmax(0,1fr)_5rem] md:items-center md:gap-3',
637
+ changed && 'bg-accent-muted/30',
638
+ )}
639
+ >
640
+ <div className="font-medium text-theme-text-tertiary md:text-theme-text-secondary">{row.label}</div>
641
+ <MetadataValue value={row.left} status={row.status} muted />
642
+ <div className="hidden text-center text-theme-text-tertiary md:block">-&gt;</div>
643
+ <MetadataValue value={row.right} status={row.status} />
644
+ <div className="pt-1 md:pt-0 md:text-right">
645
+ <span className={clsx('badge-sm', changed ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
646
+ {changed ? 'changed' : 'same'}
647
+ </span>
648
+ </div>
649
+ </div>
650
+ )
651
+ }
652
+
653
+ function MetadataValue({ value, status = false, muted = false }: { value?: string; status?: boolean; muted?: boolean }) {
654
+ if (status && value) {
655
+ return <span className={clsx('badge-sm w-fit', getHelmStatusColor(value))}>{value}</span>
656
+ }
657
+ return (
658
+ <div className={clsx('min-w-0 whitespace-normal break-words', muted ? 'text-theme-text-secondary' : 'text-theme-text-primary')}>
659
+ {value || '-'}
660
+ </div>
661
+ )
662
+ }
663
+
664
+ function SignalPill({
665
+ label,
666
+ value,
667
+ tone,
668
+ loading,
669
+ error,
670
+ sectionId,
671
+ }: {
672
+ label: string
673
+ value: string
674
+ tone: DiffTone
675
+ loading: boolean
676
+ error: unknown
677
+ sectionId: string
678
+ }) {
679
+ const displayTone: DiffTone = error ? 'warning' : loading ? 'neutral' : tone
680
+ const displayValue = error ? 'failed' : loading ? 'loading' : value
681
+ return (
682
+ <a
683
+ href={`#${sectionId}`}
684
+ onClick={(event) => scrollCompareSection(event, sectionId)}
685
+ className="inline-flex items-center gap-2 rounded-md border border-theme-border bg-theme-surface px-2.5 py-1.5 text-sm transition-colors hover:bg-theme-elevated"
686
+ >
687
+ <span className="font-medium text-theme-text-secondary">{label}</span>
688
+ <span className={clsx('badge-sm', SEVERITY_BADGE[displayTone])}>{displayValue}</span>
689
+ </a>
690
+ )
691
+ }
692
+
693
+ function scrollCompareSection(event: ReactMouseEvent<HTMLAnchorElement>, sectionId: string) {
694
+ event.preventDefault()
695
+ document.getElementById(sectionId)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
696
+ const nextUrl = `${window.location.pathname}${window.location.search}#${encodeURIComponent(sectionId)}`
697
+ window.history.replaceState(null, '', nextUrl)
698
+ }
699
+
700
+ function DiffSection({
701
+ id,
702
+ icon: Icon,
703
+ title,
704
+ description,
705
+ diff,
706
+ isLoading,
707
+ error,
708
+ emptyLabel,
709
+ }: {
710
+ id: string
711
+ icon: typeof Code
712
+ title: string
713
+ description: string
714
+ diff: string
715
+ isLoading: boolean
716
+ error: unknown
717
+ emptyLabel: string
718
+ }) {
719
+ const stats = diffStats(diff)
720
+ return (
721
+ <section id={id} className="card-inner-lg scroll-mt-4">
722
+ <SectionHeader icon={Icon} title={title} description={description}>
723
+ {!isLoading && !error && (
724
+ <div className="flex flex-wrap gap-1.5">
725
+ <span className={clsx('badge-sm', stats.changed ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
726
+ {stats.changed ? `${stats.hunks} hunks` : 'same'}
727
+ </span>
728
+ {stats.changed && (
729
+ <>
730
+ <span className={clsx('badge-sm', SEVERITY_BADGE.success)}>+{stats.additions}</span>
731
+ <span className={clsx('badge-sm', SEVERITY_BADGE.error)}>-{stats.removals}</span>
732
+ </>
733
+ )}
734
+ </div>
735
+ )}
736
+ </SectionHeader>
737
+
738
+ {isLoading ? (
739
+ <PaneLoader label="Computing diff..." className="h-36" />
740
+ ) : error ? (
741
+ <ErrorState error={error} />
742
+ ) : !stats.changed ? (
743
+ <EmptyDiffState label={emptyLabel} />
744
+ ) : (
745
+ <div className="mt-3 max-h-[620px] overflow-auto rounded-lg border border-theme-border bg-theme-base/60 font-mono text-xs">
746
+ <div className="min-w-max p-3">
747
+ {diff.split('\n').map((line, index) => (
748
+ <DiffLine key={index} line={line} />
749
+ ))}
750
+ </div>
751
+ </div>
752
+ )}
753
+ </section>
754
+ )
755
+ }
756
+
757
+ function HooksDiffSection({ diff, isLoading, error }: { diff?: HooksDiff; isLoading: boolean; error: unknown }) {
758
+ const changed = diff ? diff.added.length + diff.removed.length + diff.modified.length : 0
759
+ return (
760
+ <section id="hooks" className="card-inner-lg scroll-mt-4">
761
+ <SectionHeader
762
+ icon={Anchor}
763
+ title="Hooks diff"
764
+ description="Helm hooks can fail before or after normal resources, so changed hooks are called out separately from the rendered manifest."
765
+ >
766
+ {!isLoading && !error && diff && (
767
+ <div className="flex flex-wrap gap-1.5">
768
+ <span className={clsx('badge-sm', diff.modified.length ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
769
+ {diff.modified.length} modified
770
+ </span>
771
+ <span className={clsx('badge-sm', diff.added.length ? SEVERITY_BADGE.success : SEVERITY_BADGE.neutral)}>
772
+ {diff.added.length} added
773
+ </span>
774
+ <span className={clsx('badge-sm', diff.removed.length ? SEVERITY_BADGE.error : SEVERITY_BADGE.neutral)}>
775
+ {diff.removed.length} removed
776
+ </span>
777
+ </div>
778
+ )}
779
+ </SectionHeader>
780
+
781
+ {isLoading ? (
782
+ <PaneLoader label="Comparing hooks..." className="h-32" />
783
+ ) : error ? (
784
+ <ErrorState error={error} />
785
+ ) : !diff || changed === 0 ? (
786
+ <EmptyDiffState label="No hook changes found." />
787
+ ) : (
788
+ <div className="mt-3 space-y-3">
789
+ <HookGroup title="Modified hooks" tone="info" hooks={diff.modified} />
790
+ <HookGroup title="Added hooks" tone="success" hooks={diff.added} />
791
+ <HookGroup title="Removed hooks" tone="error" hooks={diff.removed} />
792
+ </div>
793
+ )}
794
+ </section>
795
+ )
796
+ }
797
+
798
+ function HookGroup({ title, tone, hooks }: { title: string; tone: DiffTone; hooks: HelmHook[] }) {
799
+ if (hooks.length === 0) return null
800
+ return (
801
+ <div>
802
+ <div className="mb-2 flex items-center gap-2 text-sm font-medium text-theme-text-primary">
803
+ <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{hooks.length}</span>
804
+ {title}
805
+ </div>
806
+ <div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
807
+ {hooks.map((hook) => (
808
+ <div key={hookKey(hook)} className="rounded-lg border border-theme-border bg-theme-base/50 p-3">
809
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
810
+ <span className={clsx('badge-sm', getKindBadgeColor(hook.kind))}>{hook.kind}</span>
811
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{hook.name}</span>
812
+ {hook.status && <span className={clsx('badge-sm', getHelmStatusColor(hook.status))}>{hook.status}</span>}
813
+ {hook.manifestChanged && <span className={clsx('badge-sm', SEVERITY_BADGE.info)}>manifest changed</span>}
814
+ </div>
815
+ <div className="mt-2 flex flex-wrap gap-1.5 text-xs text-theme-text-tertiary">
816
+ {hook.namespace && <span>{hook.namespace}</span>}
817
+ {hook.events.map((event) => <span key={event} className="badge-sm bg-theme-elevated text-theme-text-secondary">{event}</span>)}
818
+ {hook.weight !== 0 && <span>weight {hook.weight}</span>}
819
+ </div>
820
+ </div>
821
+ ))}
822
+ </div>
823
+ </div>
824
+ )
825
+ }
826
+
827
+ function ResourceInventoryDiffSection({
828
+ diff,
829
+ isLoading,
830
+ error,
831
+ left,
832
+ right,
833
+ revision1,
834
+ revision2,
835
+ }: {
836
+ diff?: ResourceDiff
837
+ isLoading: boolean
838
+ error: unknown
839
+ left?: HelmRevision
840
+ right?: HelmRevision
841
+ revision1: number
842
+ revision2: number
843
+ }) {
844
+ const changed = diff ? diff.added.length + diff.removed.length + diff.modified.length : 0
845
+ const identityOverlap = diff ? diff.modified.length + diff.unchanged.length : 0
846
+ const chartChanged = Boolean(left && right && left.chart !== right.chart)
847
+ const lowPairingConfidence = Boolean(diff && chartChanged && changed > 0 && identityOverlap === 0)
848
+ const parseWarning = Boolean(diff?.parseErrorCount)
849
+
850
+ return (
851
+ <section id="resources" className="card-inner-lg scroll-mt-4">
852
+ <SectionHeader
853
+ icon={Link2}
854
+ title="Rendered resources"
855
+ description="Compact index of rendered Kubernetes objects. Use the manifest diff for exact YAML."
856
+ >
857
+ {!isLoading && !error && diff && (
858
+ <div className="flex flex-wrap gap-1.5">
859
+ <span className={clsx('badge-sm', diff.modified.length ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
860
+ {diff.modified.length} modified
861
+ </span>
862
+ <span className={clsx('badge-sm', diff.added.length ? SEVERITY_BADGE.success : SEVERITY_BADGE.neutral)}>
863
+ {diff.added.length} added
864
+ </span>
865
+ <span className={clsx('badge-sm', diff.removed.length ? SEVERITY_BADGE.error : SEVERITY_BADGE.neutral)}>
866
+ {diff.removed.length} removed
867
+ </span>
868
+ {parseWarning && (
869
+ <span className={clsx('badge-sm', SEVERITY_BADGE.warning)}>
870
+ {diff.parseErrorCount} unparsed
871
+ </span>
872
+ )}
873
+ </div>
874
+ )}
875
+ </SectionHeader>
876
+
877
+ {isLoading ? (
878
+ <PaneLoader label="Comparing rendered resources..." className="h-32" />
879
+ ) : error ? (
880
+ <ErrorState error={error} />
881
+ ) : !diff ? (
882
+ <EmptyDiffState label="No rendered resource changes found." />
883
+ ) : (
884
+ <div className="mt-3 space-y-3">
885
+ {parseWarning && (
886
+ <div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-theme-text-secondary">
887
+ <div className="flex items-start gap-2">
888
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
889
+ <div>
890
+ <div className="font-medium text-theme-text-primary">Rendered resource list may be incomplete.</div>
891
+ <div className="mt-1">
892
+ Radar could not parse {diff.parseErrorCount} rendered manifest document{diff.parseErrorCount === 1 ? '' : 's'} for
893
+ resource grouping. Use the rendered manifest diff above as the source of truth.
894
+ </div>
895
+ </div>
896
+ </div>
897
+ </div>
898
+ )}
899
+ {lowPairingConfidence && (
900
+ <LowPairingConfidenceNotice added={diff.added} removed={diff.removed} />
901
+ )}
902
+ {changed === 0 ? (
903
+ <EmptyDiffState label={parseWarning ? 'No rendered resource changes found in parsed documents.' : 'No rendered resource changes found.'} />
904
+ ) : lowPairingConfidence ? (
905
+ <div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
906
+ <ResourceChangeList title="Added" tone="success" resources={diff.added} initialLimit={8} />
907
+ <ResourceChangeList title="Removed" tone="error" resources={diff.removed} initialLimit={8} />
908
+ </div>
909
+ ) : (
910
+ <>
911
+ <ModifiedResourceTable changes={diff.modified} revision1={revision1} revision2={revision2} />
912
+ <ResourceChangeList title="Added" tone="success" resources={diff.added} />
913
+ <ResourceChangeList title="Removed" tone="error" resources={diff.removed} />
914
+ </>
915
+ )}
916
+ </div>
917
+ )}
918
+ </section>
919
+ )
920
+ }
921
+
922
+ function ModifiedResourceTable({
923
+ changes,
924
+ revision1,
925
+ revision2,
926
+ }: {
927
+ changes: ResourceDiff['modified']
928
+ revision1: number
929
+ revision2: number
930
+ }) {
931
+ if (changes.length === 0) return null
932
+ return (
933
+ <div>
934
+ <div className="mb-2 flex items-center gap-2 text-sm font-medium text-theme-text-primary">
935
+ <span className={clsx('badge-sm', SEVERITY_BADGE.info)}>{changes.length}</span>
936
+ Modified in place
937
+ </div>
938
+ <div className="space-y-3">
939
+ {changes.map((change) => (
940
+ <ModifiedResourceRows key={resourceKey(change)} change={change} revision1={revision1} revision2={revision2} />
941
+ ))}
942
+ </div>
943
+ </div>
944
+ )
945
+ }
946
+
947
+ function ModifiedResourceRows({
948
+ change,
949
+ revision1,
950
+ revision2,
951
+ }: {
952
+ change: ResourceDiff['modified'][number]
953
+ revision1: number
954
+ revision2: number
955
+ }) {
956
+ const explicitFields = change.fields.filter((field) => {
957
+ const oldValue = formatDiffValue(field.oldValue, field.path)
958
+ const newValue = formatDiffValue(field.newValue, field.path)
959
+ return !isGenericContentChange(field, oldValue, newValue)
960
+ })
961
+ const visibleFields = explicitFields.slice(0, 8)
962
+ const hiddenCount = Math.max(0, explicitFields.length - visibleFields.length) + Math.max(0, change.fieldCount - change.fields.length)
963
+
964
+ return (
965
+ <div className="overflow-hidden rounded-lg border border-theme-border bg-theme-base/50">
966
+ <div className="flex min-w-0 flex-wrap items-center gap-2 border-b border-theme-border px-3 py-2">
967
+ <span className={clsx('badge-sm shrink-0', getKindBadgeColor(change.kind))}>{change.kind}</span>
968
+ <ResourceName resource={change} />
969
+ </div>
970
+ {visibleFields.length === 0 ? (
971
+ <div className="px-3 py-2 text-xs text-theme-text-secondary">
972
+ {change.summary && change.summary !== 'resource changed'
973
+ ? change.summary
974
+ : 'Rendered manifest changed; field-level summary unavailable.'}
975
+ </div>
976
+ ) : (
977
+ <div className="overflow-x-auto">
978
+ <table className="w-full min-w-[720px] border-collapse text-left text-xs">
979
+ <thead className="bg-theme-surface text-theme-text-tertiary">
980
+ <tr>
981
+ <th scope="col" className="w-[42%] px-3 py-2 font-medium">
982
+ Field
983
+ </th>
984
+ <th scope="col" className="w-[29%] px-3 py-2 font-medium">
985
+ Rev {revision1}
986
+ </th>
987
+ <th scope="col" className="w-[29%] px-3 py-2 font-medium">
988
+ Rev {revision2}
989
+ </th>
990
+ </tr>
991
+ </thead>
992
+ <tbody className="divide-y divide-theme-border">
993
+ {visibleFields.map((field, index) => (
994
+ <ModifiedFieldRow key={`${field.path}-${index}`} field={field} />
995
+ ))}
996
+ </tbody>
997
+ </table>
998
+ </div>
999
+ )}
1000
+ {hiddenCount > 0 && (
1001
+ <div className="border-t border-theme-border px-3 py-2 text-xs text-theme-text-tertiary">
1002
+ +{hiddenCount} more changed field{hiddenCount === 1 ? '' : 's'} in the manifest diff
1003
+ </div>
1004
+ )}
1005
+ </div>
1006
+ )
1007
+ }
1008
+
1009
+ function ModifiedFieldRow({ field }: { field: ResourceDiff['modified'][number]['fields'][number] }) {
1010
+ const oldValue = formatDiffValue(field.oldValue, field.path)
1011
+ const newValue = formatDiffValue(field.newValue, field.path)
1012
+ const oldTooltip = formatFullDiffValue(field.oldValue, field.path)
1013
+ const newTooltip = formatFullDiffValue(field.newValue, field.path)
1014
+ return (
1015
+ <tr className="align-top">
1016
+ <td className="px-3 py-2">
1017
+ <Tooltip content={field.path} wrapperClassName="min-w-0 max-w-full">
1018
+ <code className="break-words font-mono text-theme-text-secondary">{formatPathLabel(field.path)}</code>
1019
+ </Tooltip>
1020
+ </td>
1021
+ <td className="px-3 py-2 text-theme-text-secondary">
1022
+ <Tooltip content={oldTooltip} wrapperClassName="min-w-0 max-w-full">
1023
+ <span className="block break-words">{oldValue}</span>
1024
+ </Tooltip>
1025
+ </td>
1026
+ <td className="px-3 py-2 font-medium text-theme-text-primary">
1027
+ <Tooltip content={newTooltip} wrapperClassName="min-w-0 max-w-full">
1028
+ <span className="block break-words">{newValue}</span>
1029
+ </Tooltip>
1030
+ </td>
1031
+ </tr>
1032
+ )
1033
+ }
1034
+
1035
+ function LowPairingConfidenceNotice({
1036
+ added,
1037
+ removed,
1038
+ }: {
1039
+ added: ResourceDiff['added']
1040
+ removed: ResourceDiff['removed']
1041
+ }) {
1042
+ return (
1043
+ <div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-theme-text-secondary">
1044
+ <div className="flex items-start gap-2">
1045
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
1046
+ <div className="min-w-0">
1047
+ <div className="font-medium text-theme-text-primary">Resource identities do not overlap.</div>
1048
+ <div className="mt-1">
1049
+ The chart changed and Radar cannot pair old and new resources by name. Use the manifest diff above for the cause; the rows below are
1050
+ identity context.
1051
+ </div>
1052
+ <div className="mt-2 grid grid-cols-1 gap-2 text-xs md:grid-cols-2">
1053
+ <div>
1054
+ <span className="font-medium text-theme-text-primary">Added:</span>{' '}
1055
+ <span>{summarizeResourceKinds(added)}</span>
1056
+ </div>
1057
+ <div>
1058
+ <span className="font-medium text-theme-text-primary">Removed:</span>{' '}
1059
+ <span>{summarizeResourceKinds(removed)}</span>
1060
+ </div>
1061
+ </div>
1062
+ </div>
1063
+ </div>
1064
+ </div>
1065
+ )
1066
+ }
1067
+
1068
+ function ResourceChangeList({
1069
+ title,
1070
+ tone,
1071
+ resources,
1072
+ initialLimit = 12,
1073
+ }: {
1074
+ title: string
1075
+ tone: DiffTone
1076
+ resources: ResourceDiff['added']
1077
+ initialLimit?: number
1078
+ }) {
1079
+ const [expanded, setExpanded] = useState(false)
1080
+ if (resources.length === 0) return null
1081
+ const visible = expanded ? resources : resources.slice(0, initialLimit)
1082
+ return (
1083
+ <div>
1084
+ <div className="mb-2 flex items-center gap-2 text-sm font-medium text-theme-text-primary">
1085
+ <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{resources.length}</span>
1086
+ {title}
1087
+ </div>
1088
+ <div className="grid grid-cols-1 gap-1.5 2xl:grid-cols-2">
1089
+ {visible.map((resource) => (
1090
+ <ResourceChangeRow key={resourceKey(resource)} resource={resource} />
1091
+ ))}
1092
+ </div>
1093
+ {resources.length > initialLimit && (
1094
+ <button
1095
+ type="button"
1096
+ onClick={() => setExpanded((value) => !value)}
1097
+ className="mt-2 rounded-md px-2 py-1 text-xs font-medium text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
1098
+ >
1099
+ {expanded ? 'Show fewer' : `Show ${resources.length - visible.length} more`}
1100
+ </button>
1101
+ )}
1102
+ </div>
1103
+ )
1104
+ }
1105
+
1106
+ function ResourceChangeRow({ resource }: { resource: ResourceDiff['added'][number] }) {
1107
+ return (
1108
+ <div className="flex min-w-0 items-start gap-2 rounded-md border border-theme-border bg-theme-base/50 px-2 py-1.5">
1109
+ <span className={clsx('badge-sm shrink-0', getKindBadgeColor(resource.kind))}>{resource.kind}</span>
1110
+ <ResourceName resource={resource} />
1111
+ </div>
1112
+ )
1113
+ }
1114
+
1115
+ function ResourceName({ resource }: { resource: ResourceDiff['added'][number] }) {
1116
+ return (
1117
+ <div className="min-w-0 flex-1">
1118
+ <div className="break-words text-sm font-medium leading-snug text-theme-text-primary">{resource.name}</div>
1119
+ {resource.namespace && <div className="mt-0.5 text-xs text-theme-text-tertiary">{resource.namespace}</div>}
1120
+ </div>
1121
+ )
1122
+ }
1123
+
1124
+ function isGenericContentChange(
1125
+ field: ResourceDiff['modified'][number]['fields'][number],
1126
+ oldValue: string,
1127
+ newValue: string,
1128
+ ): boolean {
1129
+ return field.path === 'resource' && oldValue === 'changed' && newValue === 'changed'
1130
+ }
1131
+
1132
+ function summarizeResourceKinds(resources: ResourceDiff['added']): string {
1133
+ if (resources.length === 0) return 'none'
1134
+ const counts = new Map<string, number>()
1135
+ for (const resource of resources) {
1136
+ counts.set(resource.kind, (counts.get(resource.kind) || 0) + 1)
1137
+ }
1138
+ return [...counts.entries()]
1139
+ .sort(([left], [right]) => left.localeCompare(right))
1140
+ .map(([kind, count]) => `${count} ${kind}`)
1141
+ .join(', ')
1142
+ }
1143
+
1144
+ function SectionHeader({
1145
+ icon: Icon,
1146
+ title,
1147
+ description,
1148
+ children,
1149
+ }: {
1150
+ icon: typeof Code
1151
+ title: string
1152
+ description: string
1153
+ children?: ReactNode
1154
+ }) {
1155
+ return (
1156
+ <div className="flex flex-wrap items-start justify-between gap-3">
1157
+ <div className="min-w-0">
1158
+ <div className="flex items-center gap-2">
1159
+ <Icon className="h-4 w-4 text-theme-text-secondary" />
1160
+ <h3 className="text-base font-semibold text-theme-text-primary">{title}</h3>
1161
+ </div>
1162
+ <p className="mt-1 text-sm text-theme-text-secondary">{description}</p>
1163
+ </div>
1164
+ {children}
1165
+ </div>
1166
+ )
1167
+ }
1168
+
1169
+ function EmptyDiffState({ label }: { label: string }) {
1170
+ return (
1171
+ <div className="mt-3 flex items-center gap-2 rounded-lg border border-theme-border bg-theme-base/50 px-3 py-3 text-sm text-theme-text-secondary">
1172
+ <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
1173
+ {label}
1174
+ </div>
1175
+ )
1176
+ }
1177
+
1178
+ function ErrorState({ error }: { error: unknown }) {
1179
+ return (
1180
+ <div className="mt-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-3 text-sm text-theme-text-secondary">
1181
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
1182
+ <div>{error instanceof Error ? error.message : 'Failed to load this comparison surface.'}</div>
1183
+ </div>
1184
+ )
1185
+ }
1186
+
1187
+ function parseReleaseParam(value: string | null): ParsedReleaseParam | null {
1188
+ if (!value) return null
1189
+ const separator = value.indexOf('/')
1190
+ if (separator <= 0 || separator === value.length - 1) return null
1191
+ return {
1192
+ namespace: value.slice(0, separator),
1193
+ name: value.slice(separator + 1),
1194
+ }
1195
+ }
1196
+
1197
+ function parsePositiveInt(value: string | null): number | undefined {
1198
+ if (!value) return undefined
1199
+ const parsed = Number(value)
1200
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
1201
+ }
1202
+
1203
+ function previousRevision(revisions: HelmRevision[], revision: number): number | undefined {
1204
+ const lower = revisions.filter((item) => item.revision < revision)
1205
+ return lower.at(-1)?.revision
1206
+ }
1207
+
1208
+ function diffStats(diff: string): DiffStats {
1209
+ let additions = 0
1210
+ let removals = 0
1211
+ let hunks = 0
1212
+ for (const line of diff.split('\n')) {
1213
+ if (line.startsWith('@@')) hunks += 1
1214
+ else if (line.startsWith('+') && !line.startsWith('+++')) additions += 1
1215
+ else if (line.startsWith('-') && !line.startsWith('---')) removals += 1
1216
+ }
1217
+ return { changed: hasDiffBodyChange(diff), additions, removals, hunks }
1218
+ }
1219
+
1220
+ function resourceKey(resource: ResourceDiff['added'][number]): string {
1221
+ return `${resource.apiVersion || ''}/${resource.kind}/${resource.namespace || ''}/${resource.name}`
1222
+ }
1223
+
1224
+ function hookKey(hook: HelmHook): string {
1225
+ return `${hook.namespace || ''}/${hook.kind}/${hook.name}/${hook.events.join(',')}`
1226
+ }
1227
+
1228
+ function formatPathLabel(path: string): string {
1229
+ return path
1230
+ .replace(/\[\*\]/g, '')
1231
+ .replace(/\./g, ' / ')
1232
+ }
1233
+
1234
+ function formatDiffValue(value: unknown, path?: string): string {
1235
+ if (value === null || value === undefined) return 'none'
1236
+ if (typeof value === 'string') return truncate(value)
1237
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
1238
+ const structured = formatStructuredDiffValue(value, path)
1239
+ if (structured) return truncate(structured)
1240
+ try {
1241
+ return truncate(JSON.stringify(value))
1242
+ } catch {
1243
+ return truncate(String(value))
1244
+ }
1245
+ }
1246
+
1247
+ function formatFullDiffValue(value: unknown, path?: string): string {
1248
+ if (value === null || value === undefined) return 'none'
1249
+ if (typeof value === 'string') return value
1250
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
1251
+ const structured = formatStructuredDiffValue(value, path)
1252
+ if (structured) return structured
1253
+ try {
1254
+ return JSON.stringify(value, null, 2)
1255
+ } catch {
1256
+ return String(value)
1257
+ }
1258
+ }
1259
+
1260
+ function formatStructuredDiffValue(value: unknown, path?: string): string | undefined {
1261
+ if (!isRecord(value)) return undefined
1262
+ if (path?.includes('Probe')) return formatProbeValue(value)
1263
+ return undefined
1264
+ }
1265
+
1266
+ function formatProbeValue(value: Record<string, unknown>): string | undefined {
1267
+ const handler = formatProbeHandler(value)
1268
+ if (!handler) return undefined
1269
+ const details: string[] = []
1270
+ const periodSeconds = numberField(value, 'periodSeconds')
1271
+ const timeoutSeconds = numberField(value, 'timeoutSeconds')
1272
+ const failureThreshold = numberField(value, 'failureThreshold')
1273
+ if (periodSeconds && periodSeconds > 0) details.push(`period ${periodSeconds}s`)
1274
+ if (timeoutSeconds && timeoutSeconds > 0) details.push(`timeout ${timeoutSeconds}s`)
1275
+ if (failureThreshold && failureThreshold > 0) details.push(`failure threshold ${failureThreshold}`)
1276
+ return details.length ? `${handler} (${details.join(', ')})` : handler
1277
+ }
1278
+
1279
+ function formatProbeHandler(value: Record<string, unknown>): string | undefined {
1280
+ const normalizedHandler = stringField(value, 'handler')
1281
+ if (normalizedHandler) return formatNormalizedProbeHandler(normalizedHandler)
1282
+ if (isRecord(value.httpGet)) {
1283
+ const method = stringField(value.httpGet, 'scheme') || 'HTTP'
1284
+ const path = stringField(value.httpGet, 'path') || '/'
1285
+ const port = value.httpGet.port
1286
+ return `${method} GET ${path}${port !== undefined ? ` on ${String(port)}` : ''}`
1287
+ }
1288
+ if (isRecord(value.tcpSocket)) {
1289
+ const port = value.tcpSocket.port
1290
+ return `TCP socket${port !== undefined ? ` on ${String(port)}` : ''}`
1291
+ }
1292
+ if (isRecord(value.grpc)) {
1293
+ const port = value.grpc.port
1294
+ const service = stringField(value.grpc, 'service')
1295
+ return `gRPC${service ? ` ${service}` : ''}${port !== undefined ? ` on ${String(port)}` : ''}`
1296
+ }
1297
+ if (isRecord(value.exec) && Array.isArray(value.exec.command)) {
1298
+ return `exec ${value.exec.command.map(String).join(' ')}`
1299
+ }
1300
+ return undefined
1301
+ }
1302
+
1303
+ function formatNormalizedProbeHandler(handler: string): string {
1304
+ if (handler.startsWith('httpGet:')) {
1305
+ const rest = handler.slice('httpGet:'.length)
1306
+ const schemeSeparator = rest.indexOf(':')
1307
+ const scheme = schemeSeparator >= 0 ? rest.slice(0, schemeSeparator) : ''
1308
+ const target = schemeSeparator >= 0 ? rest.slice(schemeSeparator + 1) : rest
1309
+ const slashIndex = target.indexOf('/')
1310
+ const port = slashIndex >= 0 ? target.slice(0, slashIndex) : target
1311
+ const path = slashIndex >= 0 ? target.slice(slashIndex) : '/'
1312
+ return `${scheme || 'HTTP'} GET ${path}${port ? ` on ${port}` : ''}`
1313
+ }
1314
+ if (handler.startsWith('tcpSocket:')) {
1315
+ const port = handler.slice('tcpSocket:'.length)
1316
+ return `TCP socket${port ? ` on ${port}` : ''}`
1317
+ }
1318
+ if (handler.startsWith('grpc:')) {
1319
+ const target = handler.slice('grpc:'.length)
1320
+ const [port, service] = target.split('/', 2)
1321
+ return `gRPC${service ? ` ${service}` : ''}${port ? ` on ${port}` : ''}`
1322
+ }
1323
+ return handler
1324
+ }
1325
+
1326
+ function isRecord(value: unknown): value is Record<string, unknown> {
1327
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1328
+ }
1329
+
1330
+ function stringField(value: Record<string, unknown>, field: string): string | undefined {
1331
+ const raw = value[field]
1332
+ return typeof raw === 'string' ? raw : undefined
1333
+ }
1334
+
1335
+ function numberField(value: Record<string, unknown>, field: string): number | undefined {
1336
+ const raw = value[field]
1337
+ return typeof raw === 'number' ? raw : undefined
1338
+ }
1339
+
1340
+ function truncate(value: string): string {
1341
+ return value.length > 96 ? `${value.slice(0, 93)}...` : value
1342
+ }