@skyhook-io/radar-app 0.2.2 → 0.3.1

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