@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
@@ -1,25 +1,30 @@
1
1
  import { useState, useCallback, useEffect, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
- import { PaneLoader } from '@skyhook-io/k8s-ui'
3
+ import { FetchResult, useDockReservedHeight, compareVersions } from '@skyhook-io/k8s-ui'
4
+ import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
4
5
  import { TRANSITION_DRAWER } from '../../utils/animation'
5
6
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
6
- import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2 } from 'lucide-react'
7
+ import { X, Copy, Check, RefreshCw, Package, Code, History, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2, GitBranch, AlertTriangle, RotateCcw, Clock, GitCompare, ExternalLink, ChevronRight, SlidersHorizontal, Eye, Loader2 } from 'lucide-react'
8
+ import yaml from 'yaml'
9
+ import { useNavigate } from 'react-router-dom'
7
10
  import { clsx } from 'clsx'
8
- import { useHelmRelease, useHelmManifest, useHelmValues, useHelmManifestDiff, useHelmUpgradeInfo, useHelmUninstall, upgradeWithProgress, rollbackWithProgress } from '../../api/client'
11
+ import { useHelmRelease, useHelmManifest, useHelmValues, useHelmUpgradeInfo, useHelmReleaseVersions, useHelmUninstall, upgradeWithProgress, rollbackWithProgress, useHelmPreviewValues } from '../../api/client'
12
+ import { ValuesDiffPreview } from './ValuesDiffPreview'
9
13
  import { useQueryClient } from '@tanstack/react-query'
10
14
  import { ConfirmDialog } from '../ui/ConfirmDialog'
15
+ import { Tooltip } from '../ui/Tooltip'
11
16
  import { Markdown } from '../ui/Markdown'
12
- import type { SelectedHelmRelease, HelmHook, ChartDependency } from '../../types'
13
- import type { NavigateToResource } from '../../utils/navigation'
17
+ import type { SelectedHelmRelease, HelmHook, ChartDependency, HelmOperation, HelmOperationInsight, HelmOwnedResource, HookDiagnostic, HookLogEvidence, UpgradeInfo, ValuesPreviewResponse } from '../../types'
18
+ import { apiVersionToGroup, kindToPlural, type NavigateToResource } from '../../utils/navigation'
14
19
  import { formatDate } from './helm-utils'
15
- import { getHelmStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
20
+ import { getHelmStatusColor, getKindBadgeColor, getResourceStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
16
21
  import { useCanHelmAct, useCloudRole } from '../../api/client'
17
22
  import { RoleGatedPanel } from './RoleGatedPanel'
18
23
  import { RevisionHistory } from './RevisionHistory'
19
24
  import { ManifestViewer } from './ManifestViewer'
20
25
  import { ValuesViewer } from './ValuesViewer'
21
26
  import { OwnedResources } from './OwnedResources'
22
- import { ManifestDiffViewer } from './ManifestDiffViewer'
27
+ import { TrackChartSourceDialog } from './TrackChartSourceDialog'
23
28
 
24
29
  interface HelmReleaseDrawerProps {
25
30
  release: SelectedHelmRelease
@@ -27,27 +32,102 @@ interface HelmReleaseDrawerProps {
27
32
  onNavigateToResource?: NavigateToResource
28
33
  /** Controls slide-in/out animation (driven by useAnimatedUnmount) */
29
34
  isOpen?: boolean
35
+ /** Right inset in px so the drawer sits beside a docked side panel (AI), not under it. */
36
+ rightInset?: number
30
37
  }
31
38
 
32
- type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks' | 'diff'
39
+ type TabId = 'overview' | 'history' | 'manifest' | 'values' | 'resources' | 'hooks'
40
+
41
+ interface UpgradePreviewRequest {
42
+ targetVersion: string
43
+ values: Record<string, unknown>
44
+ repositoryName?: string
45
+ }
46
+
47
+ interface ParsedUpgradeValues {
48
+ values: Record<string, unknown> | null
49
+ error: string | null
50
+ }
51
+
52
+ type UpgradeSourceIssue = NonNullable<UpgradeInfo['sourceIssue']>
53
+ type ActionableUpgradeSourceIssue = Exclude<UpgradeSourceIssue, 'ambiguous_repository'>
54
+
55
+ function getUpgradeSourceIssue(upgradeInfo: UpgradeInfo): UpgradeSourceIssue | undefined {
56
+ return upgradeInfo.sourceIssue
57
+ }
58
+
59
+ function getUpgradeSourceIssueLabel(issue: UpgradeSourceIssue) {
60
+ switch (issue) {
61
+ case 'repo_index_error':
62
+ return 'upgrade source blocked'
63
+ case 'ambiguous_repository':
64
+ return 'upgrade source ambiguous'
65
+ case 'untracked':
66
+ return 'upgrade source not tracked'
67
+ }
68
+ }
69
+
70
+ function getUpgradeSourceIssueTooltip(issue: UpgradeSourceIssue, error: string | undefined, canHelmWrite: boolean, disabledReason: string | undefined) {
71
+ if (!canHelmWrite) {
72
+ return disabledReason ?? error ?? 'Helm actions are disabled.'
73
+ }
74
+ switch (issue) {
75
+ case 'repo_index_error':
76
+ return 'A configured Helm repo index failed. Fix or refresh that repo, or register an OCI prefix if this chart came from OCI.'
77
+ case 'ambiguous_repository':
78
+ return 'Multiple configured Helm repos match this chart. Fix the repo list or source metadata so Radar can identify one source.'
79
+ case 'untracked':
80
+ return "Radar can't tell where this chart was installed from. Register an OCI chart source to track upgrades."
81
+ }
82
+ }
83
+
84
+ function parseUpgradeValuesYaml(raw: string): ParsedUpgradeValues {
85
+ try {
86
+ const parsed = yaml.parse(raw)
87
+ if (parsed === null || parsed === undefined) return { values: {}, error: null }
88
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
89
+ return { values: null, error: 'Values must be a YAML mapping (key: value), not a list or single value' }
90
+ }
91
+ return { values: parsed as Record<string, unknown>, error: null }
92
+ } catch (err) {
93
+ return { values: null, error: err instanceof Error ? err.message : 'Invalid YAML' }
94
+ }
95
+ }
96
+
97
+ export function isUpgradeSourceIssueActionable(issue: UpgradeInfo['sourceIssue']): issue is ActionableUpgradeSourceIssue {
98
+ return Boolean(issue && issue !== 'ambiguous_repository')
99
+ }
33
100
 
34
101
  const MIN_WIDTH = 500
35
102
  const MAX_WIDTH_PERCENT = 0.8
36
103
  const DEFAULT_WIDTH = 1000
37
104
 
38
- export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOpen = true }: HelmReleaseDrawerProps) {
105
+ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOpen = true, rightInset = 0 }: HelmReleaseDrawerProps) {
106
+ const navigate = useNavigate()
39
107
  const [activeTab, setActiveTab] = useState<TabId>('overview')
40
108
  const [copied, setCopied] = useState<string | null>(null)
41
109
  const [drawerWidth, setDrawerWidth] = useState(DEFAULT_WIDTH)
110
+ const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? DEFAULT_WIDTH : window.innerWidth))
42
111
  const [isResizing, setIsResizing] = useState(false)
43
112
  const [selectedRevision, setSelectedRevision] = useState<number | undefined>(undefined)
44
113
  const [showAllValues, setShowAllValues] = useState(false)
45
- const [diffRevisions, setDiffRevisions] = useState<{ rev1: number; rev2: number } | null>(null)
46
114
  const [rollbackRevision, setRollbackRevision] = useState<number | null>(null)
47
115
  const [showUninstallConfirm, setShowUninstallConfirm] = useState(false)
48
116
  const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
117
+ const [showTrackSource, setShowTrackSource] = useState(false)
118
+ const [selectedVersion, setSelectedVersion] = useState<string | null>(null)
119
+ const [adjustValues, setAdjustValues] = useState(false)
120
+ const [renderAdjustValues, setRenderAdjustValues] = useState(false)
121
+ const [editedUpgradeYaml, setEditedUpgradeYaml] = useState('')
122
+ const [upgradeYamlError, setUpgradeYamlError] = useState<string | null>(null)
123
+ const [upgradeValuesSeeded, setUpgradeValuesSeeded] = useState(false)
124
+ const [upgradePreview, setUpgradePreview] = useState<ValuesPreviewResponse | null>(null)
125
+ const [upgradePreviewRequest, setUpgradePreviewRequest] = useState<UpgradePreviewRequest | null>(null)
126
+ const [showUpgradePreview, setShowUpgradePreview] = useState(false)
49
127
  const resizeStartX = useRef(0)
50
128
  const resizeStartWidth = useRef(DEFAULT_WIDTH)
129
+ const targetVersionRef = useRef('')
130
+ const editedUpgradeYamlRef = useRef('')
51
131
  const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
52
132
  // Cloud viewers can't view release manifests / values / diffs
53
133
  // (backend gate at requireCloudRole('member')). Skip the queries
@@ -55,16 +135,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
55
135
  // transient error state under the role-gated panel.
56
136
  const { canAtLeast } = useCloudRole()
57
137
  const canViewSensitive = canAtLeast('member')
138
+ const helmNamespace = release.storageNamespace || release.namespace
58
139
 
59
- const { data: releaseDetail, isLoading, refetch: refetchRelease } = useHelmRelease(
60
- release.namespace,
140
+ const { data: releaseDetail, isLoading, error: releaseError, refetch: refetchRelease } = useHelmRelease(
141
+ helmNamespace,
61
142
  release.name
62
143
  )
63
144
  const [refetch, isRefreshAnimating] = useRefreshAnimation(refetchRelease)
64
145
 
65
146
  // Fetch manifest for selected revision (or latest)
66
147
  const { data: manifest, isLoading: manifestLoading } = useHelmManifest(
67
- release.namespace,
148
+ helmNamespace,
68
149
  release.name,
69
150
  selectedRevision,
70
151
  canViewSensitive,
@@ -72,35 +153,61 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
72
153
 
73
154
  // Fetch values
74
155
  const { data: values, isLoading: valuesLoading } = useHelmValues(
75
- release.namespace,
156
+ helmNamespace,
76
157
  release.name,
77
158
  showAllValues,
78
159
  canViewSensitive,
160
+ selectedRevision,
79
161
  )
80
-
81
- // Fetch diff if comparing revisions
82
- const { data: diffData, isLoading: diffLoading } = useHelmManifestDiff(
83
- release.namespace,
162
+ const {
163
+ data: upgradeValues,
164
+ isLoading: upgradeValuesLoading,
165
+ error: upgradeValuesError,
166
+ } = useHelmValues(
167
+ helmNamespace,
84
168
  release.name,
85
- diffRevisions?.rev1 || 0,
86
- diffRevisions?.rev2 || 0,
87
- canViewSensitive,
169
+ false,
170
+ canViewSensitive && showUpgradeConfirm && adjustValues,
88
171
  )
89
172
 
90
173
  // Lazy check for upgrade availability
91
- const { data: upgradeInfo, isLoading: upgradeLoading } = useHelmUpgradeInfo(
92
- release.namespace,
174
+ const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError } = useHelmUpgradeInfo(
175
+ helmNamespace,
93
176
  release.name
94
177
  )
178
+ const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade check failed'
179
+ const upgradeSourceIssue = upgradeInfo ? getUpgradeSourceIssue(upgradeInfo) : undefined
180
+
181
+ // Available versions for the upgrade dialog's picker — only fetched while the
182
+ // confirm dialog is open. Default the selection to latest when it opens.
183
+ const {
184
+ data: availableVersions,
185
+ isLoading: availableVersionsLoading,
186
+ error: availableVersionsError,
187
+ } = useHelmReleaseVersions(helmNamespace, release.name, showUpgradeConfirm)
188
+ const targetVersion = selectedVersion ?? upgradeInfo?.latestVersion ?? ''
189
+ const selectableVersions = availableVersions && availableVersions.length > 1 ? availableVersions : null
190
+ // Semver compare, not list-position: the installed version may be older than
191
+ // the newest-N versions the picker shows, so it isn't always in the list.
192
+ const isDowngrade = Boolean(
193
+ targetVersion && upgradeInfo?.currentVersion &&
194
+ compareVersions(targetVersion, upgradeInfo.currentVersion) === -1
195
+ )
95
196
 
96
197
  // Mutations for actions
97
198
  const uninstallMutation = useHelmUninstall()
199
+ const upgradePreviewMutation = useHelmPreviewValues()
200
+ const isPreviewingUpgrade = upgradePreviewMutation.isPending
98
201
  const queryClient = useQueryClient()
99
202
  const [upgradeProgress, setUpgradeProgress] = useState<{ phase: string; message: string }[]>([])
100
203
  const [isUpgrading, setIsUpgrading] = useState(false)
101
204
  const [rollbackProgress, setRollbackProgress] = useState<{ phase: string; message: string }[]>([])
102
205
  const [isRollingBack, setIsRollingBack] = useState(false)
103
206
 
207
+ useEffect(() => {
208
+ targetVersionRef.current = targetVersion
209
+ }, [targetVersion])
210
+
104
211
  // ESC key handler
105
212
  useEffect(() => {
106
213
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -110,13 +217,24 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
110
217
  return () => document.removeEventListener('keydown', handleKeyDown)
111
218
  }, [onClose])
112
219
 
220
+ useEffect(() => {
221
+ const handleWindowResize = () => setViewportWidth(window.innerWidth)
222
+ handleWindowResize()
223
+ window.addEventListener('resize', handleWindowResize)
224
+ return () => window.removeEventListener('resize', handleWindowResize)
225
+ }, [])
226
+
227
+ const minDrawerWidth = Math.min(MIN_WIDTH, viewportWidth)
228
+ const maxDrawerWidth = viewportWidth < MIN_WIDTH ? viewportWidth : Math.max(MIN_WIDTH, viewportWidth * MAX_WIDTH_PERCENT)
229
+ const renderedDrawerWidth = Math.max(minDrawerWidth, Math.min(drawerWidth, maxDrawerWidth))
230
+
113
231
  // Resize handlers
114
232
  const handleResizeStart = useCallback((e: React.MouseEvent) => {
115
233
  e.preventDefault()
116
234
  setIsResizing(true)
117
235
  resizeStartX.current = e.clientX
118
- resizeStartWidth.current = drawerWidth
119
- }, [drawerWidth])
236
+ resizeStartWidth.current = renderedDrawerWidth
237
+ }, [renderedDrawerWidth])
120
238
 
121
239
  useEffect(() => {
122
240
  if (!isResizing) return
@@ -124,11 +242,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
124
242
  document.body.style.cursor = 'ew-resize'
125
243
  document.body.style.userSelect = 'none'
126
244
 
127
- const maxWidth = window.innerWidth * MAX_WIDTH_PERCENT
128
245
  const handleMouseMove = (e: MouseEvent) => {
129
246
  const deltaX = resizeStartX.current - e.clientX
130
247
  const newWidth = resizeStartWidth.current + deltaX
131
- setDrawerWidth(Math.max(MIN_WIDTH, Math.min(newWidth, maxWidth)))
248
+ const viewport = window.innerWidth
249
+ const minWidth = Math.min(MIN_WIDTH, viewport)
250
+ const maxWidth = viewport < MIN_WIDTH ? viewport : Math.max(MIN_WIDTH, viewport * MAX_WIDTH_PERCENT)
251
+ setDrawerWidth(Math.max(minWidth, Math.min(newWidth, maxWidth)))
132
252
  }
133
253
  const handleMouseUp = () => setIsResizing(false)
134
254
  document.addEventListener('mousemove', handleMouseMove)
@@ -148,18 +268,22 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
148
268
  }, [])
149
269
 
150
270
  const switchTab = useCallback((tab: TabId) => {
151
- const update = () => flushSync(() => setActiveTab(tab))
152
- if (document.startViewTransition) {
153
- document.startViewTransition(update)
154
- } else {
155
- setActiveTab(tab)
156
- }
271
+ // Swallow the InvalidStateError the API rejects with on rapid
272
+ // tab clicks (SKY-833 bug 49); fall back synchronously when the
273
+ // API isn't available.
274
+ startViewTransitionSafe(() => flushSync(() => setActiveTab(tab)))
157
275
  }, [])
158
276
 
159
- const handleCompareRevisions = (rev1: number, rev2: number) => {
160
- setDiffRevisions({ rev1, rev2 })
161
- switchTab('diff')
162
- }
277
+ const handleCompareRevisions = useCallback((rev1: number, rev2: number) => {
278
+ const params = new URLSearchParams()
279
+ const globalNamespaces = new URLSearchParams(window.location.search).get('namespaces')
280
+ if (globalNamespaces) params.set('namespaces', globalNamespaces)
281
+ params.set('release', `${release.namespace}/${release.name}`)
282
+ if (release.storageNamespace) params.set('releaseStorage', release.storageNamespace)
283
+ params.set('revision1', String(rev1))
284
+ params.set('revision2', String(rev2))
285
+ navigate({ pathname: '/helm/compare', search: params.toString() })
286
+ }, [navigate, release.name, release.namespace, release.storageNamespace])
163
287
 
164
288
  const handleViewRevision = (revision: number) => {
165
289
  setSelectedRevision(revision)
@@ -177,7 +301,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
177
301
 
178
302
  try {
179
303
  await rollbackWithProgress(
180
- release.namespace,
304
+ helmNamespace,
181
305
  release.name,
182
306
  rollbackRevision,
183
307
  (event) => {
@@ -196,7 +320,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
196
320
  }])
197
321
 
198
322
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
199
- queryClient.invalidateQueries({ queryKey: ['helm-release', release.namespace, release.name] })
323
+ queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
200
324
 
201
325
  setTimeout(() => {
202
326
  setRollbackRevision(null)
@@ -216,7 +340,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
216
340
 
217
341
  const handleUninstallConfirm = () => {
218
342
  uninstallMutation.mutate(
219
- { namespace: release.namespace, name: release.name },
343
+ { namespace: helmNamespace, name: release.name },
220
344
  {
221
345
  onSuccess: () => {
222
346
  setShowUninstallConfirm(false)
@@ -229,16 +353,99 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
229
353
  )
230
354
  }
231
355
 
232
- const handleUpgradeConfirm = async () => {
233
- if (!upgradeInfo?.latestVersion) return
356
+ const resetUpgradeDialog = () => {
357
+ setUpgradeProgress([])
358
+ setSelectedVersion(null)
359
+ setAdjustValues(false)
360
+ setRenderAdjustValues(false)
361
+ setEditedUpgradeYaml('')
362
+ editedUpgradeYamlRef.current = ''
363
+ setUpgradeYamlError(null)
364
+ setUpgradeValuesSeeded(false)
365
+ setUpgradePreview(null)
366
+ setUpgradePreviewRequest(null)
367
+ setShowUpgradePreview(false)
368
+ upgradePreviewMutation.reset()
369
+ }
370
+
371
+ useEffect(() => {
372
+ if (!adjustValues || !upgradeValues || upgradeValuesSeeded) return
373
+ const nextYaml = userSuppliedToYaml(upgradeValues.userSupplied)
374
+ editedUpgradeYamlRef.current = nextYaml
375
+ setEditedUpgradeYaml(nextYaml)
376
+ setUpgradeYamlError(null)
377
+ setUpgradeValuesSeeded(true)
378
+ }, [adjustValues, upgradeValues, upgradeValuesSeeded])
379
+
380
+ const handleToggleAdjustValues = () => {
381
+ if (adjustValues) {
382
+ setAdjustValues(false)
383
+ return
384
+ }
385
+
386
+ setRenderAdjustValues(true)
387
+ setEditedUpgradeYaml('')
388
+ editedUpgradeYamlRef.current = ''
389
+ setUpgradeYamlError(null)
390
+ setUpgradeValuesSeeded(false)
391
+ upgradePreviewMutation.reset()
392
+ setAdjustValues(true)
393
+ }
394
+
395
+ const getEditedUpgradeValues = () => {
396
+ if (!adjustValues) return {}
397
+ const parsed = parseUpgradeValuesYaml(editedUpgradeYamlRef.current)
398
+ setUpgradeYamlError(parsed.error)
399
+ return parsed.values
400
+ }
401
+
402
+ const handleUpgradePreview = async () => {
403
+ if (!targetVersion || upgradeValuesLoading || upgradeValuesError || !upgradeValuesSeeded) return
404
+ const editedValues = getEditedUpgradeValues()
405
+ if (!editedValues) return
406
+ const previewYaml = editedUpgradeYamlRef.current
407
+ const previewRequest: UpgradePreviewRequest = {
408
+ targetVersion,
409
+ values: editedValues,
410
+ repositoryName: upgradeInfo?.repositoryName,
411
+ }
412
+ try {
413
+ const result = await upgradePreviewMutation.mutateAsync({
414
+ namespace: helmNamespace,
415
+ name: release.name,
416
+ values: previewRequest.values,
417
+ version: previewRequest.targetVersion,
418
+ repository: previewRequest.repositoryName,
419
+ })
420
+ if (targetVersionRef.current !== previewRequest.targetVersion || editedUpgradeYamlRef.current !== previewYaml) return
421
+ setUpgradePreview(result)
422
+ setUpgradePreviewRequest(previewRequest)
423
+ setShowUpgradePreview(true)
424
+ } catch {
425
+ return
426
+ }
427
+ }
428
+
429
+ const runUpgrade = async (versionToApply: string, editedValuesOverride: Record<string, unknown> | undefined, repositoryName: string | undefined) => {
430
+ if (!versionToApply) return
431
+
432
+ let editedValues = editedValuesOverride
433
+ if (editedValues === undefined && adjustValues) {
434
+ if (upgradeValuesLoading || upgradeValuesError || !upgradeValuesSeeded) return
435
+ editedValues = getEditedUpgradeValues() ?? undefined
436
+ if (!editedValues) return
437
+ }
438
+
439
+ setShowUpgradePreview(false)
234
440
  setIsUpgrading(true)
235
441
  setUpgradeProgress([])
236
442
 
237
443
  try {
238
444
  await upgradeWithProgress(
239
- release.namespace,
445
+ helmNamespace,
240
446
  release.name,
241
- upgradeInfo.latestVersion,
447
+ versionToApply,
448
+ repositoryName,
242
449
  (event) => {
243
450
  if (event.type === 'progress' && event.message) {
244
451
  setUpgradeProgress(prev => [...prev, {
@@ -246,23 +453,25 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
246
453
  message: event.message || '',
247
454
  }])
248
455
  }
249
- }
456
+ },
457
+ editedValues
250
458
  )
251
459
 
252
460
  setUpgradeProgress(prev => [...prev, {
253
461
  phase: 'complete',
254
- message: `Successfully upgraded to ${upgradeInfo.latestVersion}`,
462
+ message: `Successfully upgraded to ${versionToApply}`,
255
463
  }])
256
464
 
257
465
  // Invalidate queries
258
466
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
259
- queryClient.invalidateQueries({ queryKey: ['helm-release', release.namespace, release.name] })
260
- queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', release.namespace, release.name] })
467
+ queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
468
+ queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', helmNamespace, release.name] })
469
+ queryClient.invalidateQueries({ queryKey: ['helm-values', helmNamespace, release.name] })
261
470
  queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
262
471
 
263
472
  setTimeout(() => {
264
473
  setShowUpgradeConfirm(false)
265
- setUpgradeProgress([])
474
+ resetUpgradeDialog()
266
475
  refetch()
267
476
  switchTab('resources')
268
477
  }, 1500)
@@ -276,7 +485,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
276
485
  }
277
486
  }
278
487
 
488
+ const handleUpgradeConfirm = () => {
489
+ void runUpgrade(targetVersion, undefined, upgradeInfo?.repositoryName)
490
+ }
491
+
492
+ const handleApplyUpgradePreview = () => {
493
+ if (!upgradePreviewRequest) return
494
+ void runUpgrade(upgradePreviewRequest.targetVersion, upgradePreviewRequest.values, upgradePreviewRequest.repositoryName)
495
+ }
496
+
279
497
  const headerHeight = 49
498
+ const dockInset = useDockReservedHeight()
280
499
 
281
500
  const tabs: { id: TabId; label: string; icon: typeof Package }[] = [
282
501
  { id: 'overview', label: 'Overview', icon: Package },
@@ -287,11 +506,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
287
506
  { id: 'hooks', label: 'Hooks', icon: Anchor },
288
507
  ]
289
508
 
290
- // Add diff tab only when comparing
291
- if (diffRevisions) {
292
- tabs.push({ id: 'diff', label: 'Diff', icon: FileText })
293
- }
294
-
295
509
  return (
296
510
  <div
297
511
  className={clsx(
@@ -299,7 +513,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
299
513
  TRANSITION_DRAWER,
300
514
  isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
301
515
  )}
302
- style={{ width: drawerWidth, top: headerHeight, height: `calc(100vh - ${headerHeight}px)` }}
516
+ style={{ width: renderedDrawerWidth, top: headerHeight, right: rightInset, height: `calc(100vh - ${headerHeight}px - ${dockInset}px)` }}
303
517
  >
304
518
  {/* Resize handle */}
305
519
  <div
@@ -328,50 +542,112 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
328
542
  <span className="badge bg-theme-hover/50 text-theme-text-secondary animate-pulse">
329
543
  checking...
330
544
  </span>
545
+ ) : upgradeError ? (
546
+ <Tooltip content={upgradeErrorMessage ?? ''}>
547
+ <span
548
+ className="badge bg-theme-hover/50 text-theme-text-secondary"
549
+ >
550
+ upgrade check failed
551
+ </span>
552
+ </Tooltip>
553
+ ) : upgradeInfo?.updateAvailable && releaseDetail?.managedByFluxHelmRelease ? (
554
+ // Route-only for GitOps-managed releases: a direct `helm upgrade`
555
+ // would be reverted at the next reconcile, so surface the available
556
+ // version as info and point at GitOps rather than offer the upgrade.
557
+ <Tooltip content={`${upgradeInfo.latestVersion} available — managed by Flux, upgrade via the GitOps view (a direct upgrade would be reverted at the next reconcile).`}>
558
+ <span className={clsx('badge', SEVERITY_BADGE.warning, 'opacity-90')}>
559
+ <ArrowUpCircle className="w-3 h-3" />
560
+ {upgradeInfo.latestVersion}
561
+ </span>
562
+ </Tooltip>
331
563
  ) : upgradeInfo?.updateAvailable ? (
564
+ <Tooltip content={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : upgradeInfo.sourceType === 'oci' ? ' (OCI)' : ''}` : helmActReason}>
332
565
  <button
333
566
  onClick={() => setShowUpgradeConfirm(true)}
334
567
  disabled={!canHelmWrite}
335
568
  className={clsx(
336
- 'badge transition-colors', SEVERITY_BADGE.warning,
569
+ 'badge transition-colors disabled:pointer-events-none', SEVERITY_BADGE.warning,
337
570
  canHelmWrite ? 'hover:bg-amber-500/30 cursor-pointer' : 'opacity-50 cursor-not-allowed'
338
571
  )}
339
- title={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : helmActReason}
340
572
  >
341
573
  <ArrowUpCircle className="w-3 h-3" />
342
574
  {upgradeInfo.latestVersion}
343
575
  </button>
576
+ </Tooltip>
344
577
  ) : upgradeInfo && !upgradeInfo.error ? (
345
- <span className={clsx('badge', SEVERITY_BADGE.success)} title="Chart is up to date">
578
+ <Tooltip content="Chart is up to date">
579
+ <span className={clsx('badge', SEVERITY_BADGE.success)}>
346
580
  latest
347
581
  </span>
582
+ </Tooltip>
583
+ ) : upgradeInfo?.error ? (
584
+ releaseDetail?.managedByFluxHelmRelease ? (
585
+ // Managed by Flux — the "Managed by Flux" badge routes to GitOps,
586
+ // where the chart source lives. Don't push a Helm source here.
587
+ <Tooltip content="Chart source is managed by Flux — track upgrades from the GitOps view.">
588
+ <span className="badge bg-theme-hover/50 text-theme-text-secondary">
589
+ source via GitOps
590
+ </span>
591
+ </Tooltip>
592
+ ) : upgradeSourceIssue && !isUpgradeSourceIssueActionable(upgradeSourceIssue) ? (
593
+ <Tooltip content={getUpgradeSourceIssueTooltip(upgradeSourceIssue, upgradeInfo.error, canHelmWrite, helmActReason)}>
594
+ <span className="badge bg-theme-hover/50 text-theme-text-secondary">
595
+ <Link2 className="w-3 h-3" />
596
+ {getUpgradeSourceIssueLabel(upgradeSourceIssue)}
597
+ </span>
598
+ </Tooltip>
599
+ ) : isUpgradeSourceIssueActionable(upgradeSourceIssue) ? (
600
+ <Tooltip content={getUpgradeSourceIssueTooltip(upgradeSourceIssue, upgradeInfo.error, canHelmWrite, helmActReason)}>
601
+ <button
602
+ onClick={() => canHelmWrite && setShowTrackSource(true)}
603
+ disabled={!canHelmWrite}
604
+ className={clsx(
605
+ 'badge transition-colors disabled:pointer-events-none bg-theme-hover/50 text-theme-text-secondary',
606
+ canHelmWrite ? 'hover:bg-theme-hover cursor-pointer' : 'opacity-50 cursor-not-allowed'
607
+ )}
608
+ >
609
+ <Link2 className="w-3 h-3" />
610
+ {getUpgradeSourceIssueLabel(upgradeSourceIssue)}
611
+ </button>
612
+ </Tooltip>
613
+ ) : (
614
+ <Tooltip content={upgradeInfo.error}>
615
+ <span className="badge bg-theme-hover/50 text-theme-text-secondary">
616
+ upgrade source unresolved
617
+ </span>
618
+ </Tooltip>
619
+ )
348
620
  ) : null}
349
621
  </div>
350
622
  <div className="flex items-center gap-1">
623
+ <Tooltip content="Refresh">
351
624
  <button
352
625
  onClick={refetch}
353
626
  disabled={isRefreshAnimating}
354
- className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
355
- title="Refresh"
627
+ className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50 disabled:pointer-events-none"
356
628
  >
357
629
  <RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
358
630
  </button>
631
+ </Tooltip>
632
+ <Tooltip content={canHelmWrite ? 'Uninstall release' : helmActReason}>
359
633
  <button
360
634
  onClick={() => setShowUninstallConfirm(true)}
361
635
  disabled={!canHelmWrite}
362
636
  className={clsx(
363
- 'p-1.5 rounded',
637
+ 'p-1.5 rounded disabled:pointer-events-none',
364
638
  canHelmWrite
365
639
  ? 'text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10'
366
640
  : 'text-theme-text-disabled cursor-not-allowed'
367
641
  )}
368
- title={canHelmWrite ? 'Uninstall release' : helmActReason}
369
642
  >
370
643
  <Trash2 className="w-4 h-4" />
371
644
  </button>
372
- <button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded" title="Close (Esc)">
645
+ </Tooltip>
646
+ <Tooltip content="Close (Esc)">
647
+ <button onClick={onClose} className="p-1.5 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded">
373
648
  <X className="w-4 h-4" />
374
649
  </button>
650
+ </Tooltip>
375
651
  </div>
376
652
  </div>
377
653
 
@@ -380,15 +656,31 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
380
656
  <div className="flex items-center gap-2">
381
657
  <Package className="w-5 h-5 text-purple-400" />
382
658
  <h2 className="text-lg font-semibold text-theme-text-primary truncate">{release.name}</h2>
659
+ <Tooltip content="Copy name" wrapperClassName="shrink-0">
383
660
  <button
384
661
  onClick={() => copyToClipboard(release.name, 'name')}
385
662
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
386
- title="Copy name"
387
663
  >
388
664
  {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
389
665
  </button>
666
+ </Tooltip>
390
667
  </div>
391
668
  <p className="text-sm text-theme-text-tertiary">{release.namespace}</p>
669
+ {releaseDetail?.managedByFluxHelmRelease && (
670
+ <Tooltip content={`Installed by Flux helm-controller via HelmRelease ${releaseDetail.managedByFluxHelmRelease}. Changes here would be reverted at the next reconcile.`} wrapperClassName="mt-1">
671
+ <button
672
+ type="button"
673
+ onClick={() => {
674
+ const [ns, name] = releaseDetail.managedByFluxHelmRelease!.split('/')
675
+ navigate(`/gitops/detail/helmreleases/${encodeURIComponent(ns || '_')}/${encodeURIComponent(name)}`)
676
+ }}
677
+ className="inline-flex items-center gap-1 rounded border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[11px] text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 transition-colors"
678
+ >
679
+ <GitBranch className="w-3 h-3" />
680
+ Managed by Flux · {releaseDetail.managedByFluxHelmRelease}
681
+ </button>
682
+ </Tooltip>
683
+ )}
392
684
  </div>
393
685
 
394
686
  {/* Tabs */}
@@ -413,12 +705,20 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
413
705
 
414
706
  {/* Content */}
415
707
  <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'helm-drawer-content' }}>
416
- {isLoading ? (
417
- <PaneLoader className="h-32" />
418
- ) : !releaseDetail ? (
419
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Release not found</div>
708
+ {!releaseDetail ? (
709
+ <FetchResult loading={isLoading} error={releaseError} notFoundMessage="Release not found" className="h-32" />
420
710
  ) : (
421
711
  <>
712
+ <HelmOperationBanner
713
+ operation={releaseDetail.lastOperation}
714
+ operationInsight={releaseDetail.operationInsight}
715
+ releaseNamespace={releaseDetail.namespace}
716
+ managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
717
+ hookDiagnostics={releaseDetail.hookDiagnostics}
718
+ canCompare={canViewSensitive}
719
+ onCompare={handleCompareRevisions}
720
+ onNavigateToResource={onNavigateToResource}
721
+ />
422
722
  {activeTab === 'overview' && (
423
723
  <OverviewTab release={releaseDetail} onCopy={copyToClipboard} copied={copied} />
424
724
  )}
@@ -426,6 +726,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
426
726
  <RevisionHistory
427
727
  history={releaseDetail.history}
428
728
  currentRevision={releaseDetail.revision}
729
+ operations={mergeHelmOperations(releaseDetail.operations, releaseDetail.lastOperation)}
429
730
  onViewRevision={handleViewRevision}
430
731
  onCompare={handleCompareRevisions}
431
732
  onRollback={canHelmWrite ? handleRollbackRequest : undefined}
@@ -451,8 +752,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
451
752
  onToggleAllValues={setShowAllValues}
452
753
  onCopy={(text) => copyToClipboard(text, 'values')}
453
754
  copied={copied === 'values'}
454
- namespace={release.namespace}
755
+ namespace={helmNamespace}
455
756
  name={release.name}
757
+ revision={selectedRevision}
758
+ currentRevision={releaseDetail.revision}
456
759
  onApplySuccess={() => refetch()}
457
760
  />
458
761
  </RoleGatedPanel>
@@ -464,21 +767,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
464
767
  />
465
768
  )}
466
769
  {activeTab === 'hooks' && (
467
- <HooksTab hooks={releaseDetail.hooks || []} />
468
- )}
469
- {activeTab === 'diff' && diffRevisions && (
470
- <RoleGatedPanel min="member" feature="release manifest diffs">
471
- <ManifestDiffViewer
472
- diff={diffData?.diff || ''}
473
- isLoading={diffLoading}
474
- revision1={diffRevisions.rev1}
475
- revision2={diffRevisions.rev2}
476
- onClose={() => {
477
- setDiffRevisions(null)
478
- setActiveTab('history')
479
- }}
480
- />
481
- </RoleGatedPanel>
770
+ <HooksTab hooks={releaseDetail.hooks || []} hookDiagnostics={releaseDetail.hookDiagnostics || []} />
482
771
  )}
483
772
  </>
484
773
  )}
@@ -528,7 +817,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
528
817
  open={showUpgradeConfirm}
529
818
  onClose={() => {
530
819
  setShowUpgradeConfirm(false)
531
- setUpgradeProgress([])
820
+ resetUpgradeDialog()
532
821
  if (isUpgrading) {
533
822
  // Upgrade continues server-side — switch to resources tab to monitor
534
823
  setIsUpgrading(false)
@@ -537,22 +826,203 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
537
826
  }}
538
827
  onConfirm={handleUpgradeConfirm}
539
828
  title="Upgrade Release"
540
- message={`Upgrade "${release.name}" to version ${upgradeInfo?.latestVersion}?`}
829
+ message={`Upgrade "${release.name}" to version ${targetVersion}?`}
541
830
  details={upgradeProgress.length === 0
542
- ? `This will upgrade the chart from version ${upgradeInfo?.currentVersion} to ${upgradeInfo?.latestVersion}. Your existing values will be preserved. The upgrade will be applied to your cluster immediately.`
831
+ ? `The chart will move from version ${upgradeInfo?.currentVersion} to ${targetVersion}. ${adjustValues ? 'Your edited values will be applied.' : 'Your existing values will be preserved.'} The change is applied to your cluster immediately.`
543
832
  : undefined
544
833
  }
545
- confirmLabel="Upgrade"
834
+ confirmLabel={isDowngrade ? 'Downgrade' : 'Upgrade'}
546
835
  variant="warning"
547
836
  isLoading={isUpgrading}
837
+ confirmDisabled={isPreviewingUpgrade || (adjustValues && (upgradeValuesLoading || !!upgradeValuesError || !upgradeValuesSeeded || !!upgradeYamlError))}
548
838
  isClosable
839
+ className="max-w-2xl"
549
840
  >
841
+ {upgradeProgress.length === 0 && (
842
+ <div className="mb-1">
843
+ <label
844
+ id="upgrade-version-label"
845
+ htmlFor={selectableVersions ? 'upgrade-version' : undefined}
846
+ className="block text-sm font-medium text-theme-text-secondary mb-1.5"
847
+ >
848
+ Target version
849
+ </label>
850
+ {availableVersionsLoading ? (
851
+ <div
852
+ aria-labelledby="upgrade-version-label"
853
+ aria-live="polite"
854
+ className="flex h-10 items-center gap-2 rounded-lg border border-theme-border-light bg-theme-elevated px-3 text-sm text-theme-text-secondary"
855
+ >
856
+ <Loader2 className="h-4 w-4 animate-spin" />
857
+ Loading available versions...
858
+ </div>
859
+ ) : selectableVersions ? (
860
+ <select
861
+ id="upgrade-version"
862
+ value={targetVersion}
863
+ onChange={(e) => {
864
+ targetVersionRef.current = e.target.value
865
+ setSelectedVersion(e.target.value)
866
+ }}
867
+ disabled={isUpgrading || isPreviewingUpgrade}
868
+ className="w-full px-3 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
869
+ >
870
+ {selectableVersions.map((v) => (
871
+ <option key={v} value={v}>
872
+ {v}
873
+ {v === upgradeInfo?.latestVersion ? ' (latest)' : ''}
874
+ {v === upgradeInfo?.currentVersion ? ' (current)' : ''}
875
+ </option>
876
+ ))}
877
+ </select>
878
+ ) : (
879
+ <div
880
+ aria-labelledby="upgrade-version-label"
881
+ className="flex h-10 items-center rounded-lg border border-theme-border-light bg-theme-elevated px-3 text-sm text-theme-text-primary"
882
+ >
883
+ {targetVersion}
884
+ </div>
885
+ )}
886
+ {isDowngrade && (
887
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
888
+ This is a downgrade from {upgradeInfo?.currentVersion}.
889
+ </p>
890
+ )}
891
+ {availableVersionsError && (
892
+ <p className="mt-1 text-xs text-theme-text-tertiary">
893
+ Version list unavailable; using the detected target version.
894
+ </p>
895
+ )}
896
+ {availableVersions && availableVersions.length >= 50 && (
897
+ <p className="mt-1 text-xs text-theme-text-tertiary">
898
+ Showing the 50 newest versions.
899
+ </p>
900
+ )}
901
+ </div>
902
+ )}
903
+ {upgradeProgress.length === 0 && canHelmWrite && canViewSensitive && (
904
+ <div className="mt-3 border-t border-theme-border pt-3">
905
+ <Tooltip content="Edit the user-supplied Helm values that Radar will pass to this upgrade.">
906
+ <button
907
+ type="button"
908
+ onClick={handleToggleAdjustValues}
909
+ disabled={isUpgrading || isPreviewingUpgrade}
910
+ aria-expanded={adjustValues}
911
+ aria-controls="helm-upgrade-values-panel"
912
+ className="flex items-center gap-1.5 text-sm font-medium text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50"
913
+ >
914
+ <ChevronRight className={clsx('w-4 h-4 transition-transform duration-200', adjustValues && 'rotate-90')} />
915
+ <SlidersHorizontal className="w-3.5 h-3.5" />
916
+ Adjust your values (optional)
917
+ </button>
918
+ </Tooltip>
919
+ <div
920
+ id="helm-upgrade-values-panel"
921
+ className={`issue-details-motion ${adjustValues ? 'issue-details-motion-open' : ''}`}
922
+ onTransitionEnd={(event) => {
923
+ if (event.target !== event.currentTarget) return
924
+ if (event.propertyName !== 'grid-template-rows') return
925
+ if (!adjustValues) {
926
+ setRenderAdjustValues(false)
927
+ }
928
+ }}
929
+ >
930
+ <div className="overflow-hidden">
931
+ {renderAdjustValues && (
932
+ <div className="mt-2">
933
+ <p className="mb-2 text-xs text-theme-text-tertiary">
934
+ These are your current settings — edit them to carry into {targetVersion}. What you see here is exactly what gets applied.
935
+ </p>
936
+ {upgradeValuesError && (
937
+ <div className="mt-2 px-3 py-2 text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded">
938
+ Couldn’t load current values: {upgradeValuesError instanceof Error ? upgradeValuesError.message : 'Unknown error'}
939
+ </div>
940
+ )}
941
+ {(upgradeValuesLoading || !upgradeValuesSeeded) && (
942
+ <div className="flex min-h-[180px] items-center gap-2 rounded border border-theme-border bg-theme-elevated px-3 text-sm text-theme-text-secondary">
943
+ <Loader2 className="w-4 h-4 animate-spin" />
944
+ Loading current values...
945
+ </div>
946
+ )}
947
+ {!upgradeValuesLoading && !upgradeValuesError && upgradeValuesSeeded && (
948
+ <textarea
949
+ defaultValue={editedUpgradeYaml}
950
+ onChange={(event) => {
951
+ editedUpgradeYamlRef.current = event.target.value
952
+ if (upgradeYamlError) setUpgradeYamlError(null)
953
+ }}
954
+ onBlur={() => setUpgradeYamlError(parseUpgradeValuesYaml(editedUpgradeYamlRef.current).error)}
955
+ aria-label="Edited Helm values YAML"
956
+ readOnly={isPreviewingUpgrade || isUpgrading}
957
+ spellCheck={false}
958
+ autoCorrect="off"
959
+ autoCapitalize="off"
960
+ rows={9}
961
+ className="max-h-[360px] min-h-[180px] w-full resize-y rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 font-mono text-xs leading-5 text-theme-text-primary placeholder:text-theme-text-tertiary focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 read-only:opacity-70"
962
+ />
963
+ )}
964
+ {upgradeYamlError && (
965
+ <div className="mt-2 px-3 py-2 text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded">
966
+ {upgradeYamlError}
967
+ </div>
968
+ )}
969
+ {upgradePreviewMutation.error && (
970
+ <div className="mt-2 px-3 py-2 text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded">
971
+ Couldn’t preview against {targetVersion}: {upgradePreviewMutation.error.message}. You can still upgrade.
972
+ </div>
973
+ )}
974
+ <div className="mt-2 flex justify-end">
975
+ <Tooltip
976
+ content="Render this release with the selected chart version and edited values, then show the manifest diff. Nothing is applied until you confirm from the preview."
977
+ wrapperClassName="shrink-0"
978
+ >
979
+ <button
980
+ type="button"
981
+ onClick={handleUpgradePreview}
982
+ disabled={upgradeValuesLoading || !!upgradeValuesError || !upgradeValuesSeeded || !!upgradeYamlError || isPreviewingUpgrade || isUpgrading}
983
+ className="flex items-center gap-1 px-2.5 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded border border-theme-border disabled:opacity-50 disabled:cursor-not-allowed"
984
+ >
985
+ {upgradePreviewMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
986
+ Preview what changes
987
+ </button>
988
+ </Tooltip>
989
+ </div>
990
+ </div>
991
+ )}
992
+ </div>
993
+ </div>
994
+ </div>
995
+ )}
550
996
  {upgradeProgress.length > 0 && <ProgressLog entries={upgradeProgress} />}
551
997
  </ConfirmDialog>
998
+
999
+ {showUpgradePreview && upgradePreview && upgradePreviewRequest && (
1000
+ <ValuesDiffPreview
1001
+ previewData={upgradePreview}
1002
+ onClose={() => setShowUpgradePreview(false)}
1003
+ onApply={handleApplyUpgradePreview}
1004
+ isApplying={isUpgrading}
1005
+ title={`Preview upgrade to ${upgradePreviewRequest.targetVersion}`}
1006
+ applyLabel={upgradeInfo?.currentVersion && compareVersions(upgradePreviewRequest.targetVersion, upgradeInfo.currentVersion) === -1 ? 'Downgrade' : 'Upgrade'}
1007
+ />
1008
+ )}
1009
+
1010
+ <TrackChartSourceDialog
1011
+ open={showTrackSource}
1012
+ onClose={() => setShowTrackSource(false)}
1013
+ chartName={releaseDetail?.chart}
1014
+ sourceIssue={upgradeSourceIssue}
1015
+ sourceError={upgradeInfo?.error}
1016
+ />
552
1017
  </div>
553
1018
  )
554
1019
  }
555
1020
 
1021
+ function userSuppliedToYaml(userSupplied: Record<string, unknown> | undefined): string {
1022
+ if (!userSupplied || Object.keys(userSupplied).length === 0) return ''
1023
+ return yaml.stringify(userSupplied, { lineWidth: 0 })
1024
+ }
1025
+
556
1026
  // Shared progress log for streaming Helm operations
557
1027
  function ProgressLog({ entries }: { entries: { phase: string; message: string }[] }) {
558
1028
  return (
@@ -580,6 +1050,321 @@ function ProgressLog({ entries }: { entries: { phase: string; message: string }[
580
1050
  )
581
1051
  }
582
1052
 
1053
+ function mergeHelmOperations(operations: HelmOperation[] | undefined, lastOperation: HelmOperation | undefined): HelmOperation[] {
1054
+ const merged: HelmOperation[] = []
1055
+ const seen = new Set<string>()
1056
+ for (const op of [...(operations || []), ...(lastOperation ? [lastOperation] : [])]) {
1057
+ const key = helmOperationKey(op)
1058
+ if (seen.has(key)) continue
1059
+ seen.add(key)
1060
+ merged.push(op)
1061
+ }
1062
+ return merged
1063
+ }
1064
+
1065
+ function helmOperationKey(operation: HelmOperation): string {
1066
+ return [
1067
+ operation.kind,
1068
+ operation.status,
1069
+ operation.revision || 0,
1070
+ operation.failedRevision || 0,
1071
+ operation.rollbackRevision || 0,
1072
+ operation.targetRevision || 0,
1073
+ ].join(':')
1074
+ }
1075
+
1076
+ function HelmOperationBanner({
1077
+ operation,
1078
+ operationInsight,
1079
+ releaseNamespace,
1080
+ managedByFluxHelmRelease,
1081
+ hookDiagnostics,
1082
+ canCompare,
1083
+ onCompare,
1084
+ onNavigateToResource,
1085
+ }: {
1086
+ operation?: HelmOperation
1087
+ operationInsight?: HelmOperationInsight
1088
+ releaseNamespace: string
1089
+ managedByFluxHelmRelease?: string
1090
+ hookDiagnostics?: HookDiagnostic[]
1091
+ canCompare?: boolean
1092
+ onCompare?: (rev1: number, rev2: number) => void
1093
+ onNavigateToResource?: NavigateToResource
1094
+ }) {
1095
+ const primaryHookDiagnostic = hookDiagnostics?.[0]
1096
+ if ((!operation || !shouldShowOperationBanner(operation)) && !primaryHookDiagnostic) {
1097
+ return null
1098
+ }
1099
+ if (!operation || !shouldShowOperationBanner(operation)) {
1100
+ const tone = hookDiagnosticTone(primaryHookDiagnostic!)
1101
+ return (
1102
+ <div className="m-4 mb-0 card-inner-lg">
1103
+ <div className="flex items-start gap-3">
1104
+ <AlertTriangle className={clsx('mt-0.5 h-5 w-5 shrink-0', SEVERITY_TEXT[tone])} />
1105
+ <div className="min-w-0 flex-1">
1106
+ <div className="flex flex-wrap items-center gap-2">
1107
+ <span className="text-sm font-medium text-theme-text-primary">Helm hook needs attention</span>
1108
+ <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{primaryHookDiagnostic!.phase}</span>
1109
+ </div>
1110
+ <HookSignal diagnostic={primaryHookDiagnostic!} />
1111
+ </div>
1112
+ </div>
1113
+ </div>
1114
+ )
1115
+ }
1116
+
1117
+ const isFailure = operation.status === 'failed'
1118
+ const isPending = operation.status === 'stuck_pending'
1119
+ const tone: 'error' | 'warning' | 'info' = isFailure ? 'error' : operation.kind === 'rollback' ? 'info' : 'warning'
1120
+ const Icon = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' ? RotateCcw : isPending ? Clock : AlertTriangle
1121
+ const title = operationTitle(operation)
1122
+ const statusLabel = operation.status.replace(/_/g, ' ')
1123
+ const showStatusBadge = !(operation.status === 'failed' && title.toLowerCase().includes('failed'))
1124
+ const rawMessage = operation.rawMessage?.trim()
1125
+ const hasRawMessage = !!rawMessage && rawMessage !== operation.message.trim()
1126
+
1127
+ return (
1128
+ <div className="m-4 mb-0 card-inner-lg">
1129
+ <div className="flex items-start gap-3">
1130
+ <Icon className={clsx('mt-0.5 h-5 w-5 shrink-0', SEVERITY_TEXT[tone])} />
1131
+ <div className="min-w-0 flex-1">
1132
+ <div className="flex flex-wrap items-center gap-2">
1133
+ <span className="text-sm font-medium text-theme-text-primary">{title}</span>
1134
+ {showStatusBadge && (
1135
+ <span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{statusLabel}</span>
1136
+ )}
1137
+ <OperationRevisionChips operation={operation} />
1138
+ </div>
1139
+ <p className="mt-1 text-sm text-theme-text-secondary">{operation.message}</p>
1140
+ {hasRawMessage && (
1141
+ <details className="mt-2">
1142
+ <summary className="cursor-pointer select-none text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary">
1143
+ Show raw Helm error
1144
+ </summary>
1145
+ <pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-theme-base/60 p-2 font-mono text-[11px] leading-relaxed text-theme-text-tertiary">
1146
+ {rawMessage}
1147
+ </pre>
1148
+ </details>
1149
+ )}
1150
+ {operation.failureDescription && !hasRawMessage && (
1151
+ <Tooltip content={operation.failureDescription} wrapperClassName="mt-1 flex">
1152
+ <span className="line-clamp-2 text-xs text-theme-text-tertiary">
1153
+ {operation.failureDescription}
1154
+ </span>
1155
+ </Tooltip>
1156
+ )}
1157
+ {operation.kind === 'upgrade_rolled_back' && (
1158
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1159
+ Helm history does not record whether <code className="inline-code text-[11px]">--atomic</code> was set; the rollback is inferred from adjacent release revisions.
1160
+ </p>
1161
+ )}
1162
+ <OperationInsightSignals
1163
+ insight={operationInsight}
1164
+ releaseNamespace={releaseNamespace}
1165
+ canCompare={Boolean(canCompare)}
1166
+ onCompare={onCompare}
1167
+ onNavigateToResource={onNavigateToResource}
1168
+ />
1169
+ {primaryHookDiagnostic && <HookSignal diagnostic={primaryHookDiagnostic} />}
1170
+ {managedByFluxHelmRelease && (
1171
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1172
+ This release is managed by Flux HelmRelease {managedByFluxHelmRelease}; direct Helm changes may be reconciled back.
1173
+ </p>
1174
+ )}
1175
+ </div>
1176
+ </div>
1177
+ </div>
1178
+ )
1179
+ }
1180
+
1181
+ function OperationInsightSignals({
1182
+ insight,
1183
+ releaseNamespace,
1184
+ canCompare,
1185
+ onCompare,
1186
+ onNavigateToResource,
1187
+ }: {
1188
+ insight?: HelmOperationInsight
1189
+ releaseNamespace: string
1190
+ canCompare: boolean
1191
+ onCompare?: (rev1: number, rev2: number) => void
1192
+ onNavigateToResource?: NavigateToResource
1193
+ }) {
1194
+ const compare = insight?.suggestedCompare
1195
+ const primaryResource = insight?.primaryResource
1196
+ const relatedCount = primaryResource
1197
+ ? Math.max(0, (insight?.signalCount ?? 0) - 1)
1198
+ : insight?.relatedResources?.length ?? 0
1199
+ const showCompare = Boolean(compare && canCompare && onCompare)
1200
+ if (!primaryResource && !showCompare) {
1201
+ return null
1202
+ }
1203
+
1204
+ return (
1205
+ <div className="mt-2 space-y-2">
1206
+ {primaryResource && (
1207
+ <PrimaryOperationResourceSignal
1208
+ resource={primaryResource}
1209
+ releaseNamespace={releaseNamespace}
1210
+ relatedCount={relatedCount}
1211
+ onNavigateToResource={onNavigateToResource}
1212
+ />
1213
+ )}
1214
+ {showCompare && compare && (
1215
+ <Tooltip content={compare.reason || 'Compare the two Helm revisions'}>
1216
+ <button
1217
+ type="button"
1218
+ onClick={() => onCompare?.(compare.revision1, compare.revision2)}
1219
+ className="inline-flex items-center gap-1.5 rounded-md border border-theme-border-light bg-theme-elevated px-2.5 py-1.5 text-xs font-medium text-theme-text-primary shadow-theme-sm transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
1220
+ >
1221
+ <GitCompare className="h-3.5 w-3.5" />
1222
+ Compare rev {compare.revision1} to {compare.revision2}
1223
+ </button>
1224
+ </Tooltip>
1225
+ )}
1226
+ </div>
1227
+ )
1228
+ }
1229
+
1230
+ function PrimaryOperationResourceSignal({
1231
+ resource,
1232
+ releaseNamespace,
1233
+ relatedCount,
1234
+ onNavigateToResource,
1235
+ }: {
1236
+ resource: HelmOwnedResource
1237
+ releaseNamespace: string
1238
+ relatedCount: number
1239
+ onNavigateToResource?: NavigateToResource
1240
+ }) {
1241
+ const canNavigate = Boolean(onNavigateToResource)
1242
+ const resourceLabel = `${resource.kind}/${resource.name}`
1243
+ const resourceTarget = resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name
1244
+ const showNamespace = Boolean(resource.namespace && resource.namespace !== releaseNamespace)
1245
+ const resourceTooltip = `Open ${resource.kind} ${resourceTarget}`
1246
+ const showInlineReady = Boolean(resource.ready && !resource.summary)
1247
+ const openResource = () => {
1248
+ onNavigateToResource?.({
1249
+ kind: kindToPlural(resource.kind),
1250
+ namespace: resource.namespace,
1251
+ name: resource.name,
1252
+ group: apiVersionToGroup(resource.apiVersion),
1253
+ })
1254
+ }
1255
+
1256
+ return (
1257
+ <div className="rounded-md bg-theme-base/50 p-2 text-xs">
1258
+ <div className="flex flex-wrap items-center gap-2">
1259
+ <span className="font-medium text-theme-text-secondary">Likely resource to inspect</span>
1260
+ {canNavigate ? (
1261
+ <Tooltip content={resourceTooltip}>
1262
+ <button
1263
+ type="button"
1264
+ onClick={openResource}
1265
+ aria-label={resourceTooltip}
1266
+ className={clsx(
1267
+ 'badge-sm max-w-full min-w-0 transition-colors hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1 focus:ring-offset-theme-base',
1268
+ getKindBadgeColor(resource.kind)
1269
+ )}
1270
+ >
1271
+ <span className="min-w-0 truncate">{resourceLabel}</span>
1272
+ <ExternalLink className="h-3 w-3 shrink-0 opacity-70" />
1273
+ </button>
1274
+ </Tooltip>
1275
+ ) : (
1276
+ <span className={clsx('badge-sm max-w-full min-w-0', getKindBadgeColor(resource.kind))}>
1277
+ <span className="min-w-0 truncate">{resourceLabel}</span>
1278
+ </span>
1279
+ )}
1280
+ {showNamespace && <span className="text-theme-text-tertiary">ns/{resource.namespace}</span>}
1281
+ {showInlineReady && <span className="font-mono text-theme-text-secondary">{resource.ready}</span>}
1282
+ {resource.status && (
1283
+ <span className={clsx('badge-sm', getResourceStatusColor(resource.status))}>{resource.status}</span>
1284
+ )}
1285
+ </div>
1286
+ {(resource.issue || resource.summary || resource.message || relatedCount > 0) && (
1287
+ <div className="mt-1 text-theme-text-tertiary">
1288
+ {[
1289
+ resource.issue || resource.summary || resource.message,
1290
+ relatedCount > 0 ? `${relatedCount} more affected resource${relatedCount === 1 ? '' : 's'} in Resources tab` : null,
1291
+ ]
1292
+ .filter(Boolean)
1293
+ .join(' - ')}
1294
+ </div>
1295
+ )}
1296
+ </div>
1297
+ )
1298
+ }
1299
+
1300
+ function hookDiagnosticTone(diagnostic: HookDiagnostic): 'error' | 'warning' {
1301
+ return diagnostic.phase.toLowerCase() === 'failed' ? 'error' : 'warning'
1302
+ }
1303
+
1304
+ function HookSignal({ diagnostic }: { diagnostic: HookDiagnostic }) {
1305
+ return (
1306
+ <div className="mt-2 rounded-md bg-theme-base/50 p-2 text-xs">
1307
+ <div className="font-medium text-theme-text-secondary">
1308
+ Hook signal: {formatHookRef(diagnostic)} is {diagnostic.phase}
1309
+ </div>
1310
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.message}</div>
1311
+ {diagnostic.evidence?.summary && (
1312
+ <div className="mt-1 text-theme-text-secondary">{diagnostic.evidence.summary}</div>
1313
+ )}
1314
+ {diagnostic.evidenceUnavailableReason && (
1315
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.evidenceUnavailableReason}</div>
1316
+ )}
1317
+ </div>
1318
+ )
1319
+ }
1320
+
1321
+ function shouldShowOperationBanner(operation: HelmOperation): boolean {
1322
+ return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'failed' || operation.status === 'stuck_pending'
1323
+ }
1324
+
1325
+ function operationTitle(operation: HelmOperation): string {
1326
+ switch (operation.kind) {
1327
+ case 'upgrade_rolled_back':
1328
+ return 'Helm rolled back after failed upgrade'
1329
+ case 'rollback':
1330
+ return 'Helm rollback applied'
1331
+ case 'pending':
1332
+ return 'Helm operation may be stuck'
1333
+ case 'upgrade_failed':
1334
+ return 'Helm upgrade failed'
1335
+ case 'release_failed':
1336
+ return 'Helm release failed'
1337
+ default:
1338
+ return 'Helm operation'
1339
+ }
1340
+ }
1341
+
1342
+ function OperationRevisionChips({ operation }: { operation: HelmOperation }) {
1343
+ const chips: Array<{ key: string; label: string; className: string }> = []
1344
+ if (operation.failedRevision) {
1345
+ chips.push({ key: 'failed', label: `failed rev ${operation.failedRevision}`, className: SEVERITY_BADGE.error })
1346
+ }
1347
+ if (operation.rollbackRevision) {
1348
+ chips.push({ key: 'rollback', label: `rollback rev ${operation.rollbackRevision}`, className: SEVERITY_BADGE.warning })
1349
+ }
1350
+ if (operation.targetRevision) {
1351
+ chips.push({ key: 'target', label: `target rev ${operation.targetRevision}`, className: SEVERITY_BADGE.info })
1352
+ }
1353
+ if (!operation.failedRevision && !operation.rollbackRevision && operation.revision) {
1354
+ chips.push({ key: 'revision', label: `rev ${operation.revision}`, className: SEVERITY_BADGE.neutral })
1355
+ }
1356
+ if (chips.length === 0) {
1357
+ return null
1358
+ }
1359
+ return (
1360
+ <>
1361
+ {chips.map((chip) => (
1362
+ <span key={chip.key} className={clsx('badge-sm', chip.className)}>{chip.label}</span>
1363
+ ))}
1364
+ </>
1365
+ )
1366
+ }
1367
+
583
1368
  // Overview tab content
584
1369
  interface OverviewTabProps {
585
1370
  release: {
@@ -592,12 +1377,15 @@ interface OverviewTabProps {
592
1377
  notes: string
593
1378
  readme?: string
594
1379
  dependencies?: ChartDependency[]
1380
+ lastOperation?: HelmOperation
595
1381
  }
596
1382
  onCopy: (text: string, key: string) => void
597
1383
  copied: string | null
598
1384
  }
599
1385
 
600
1386
  function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
1387
+ const showDescription = !!release.description && !isDuplicateOperationRawDescription(release.description, release.lastOperation)
1388
+
601
1389
  return (
602
1390
  <div className="p-4 space-y-4">
603
1391
  {/* Chart info */}
@@ -628,7 +1416,7 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
628
1416
  </div>
629
1417
 
630
1418
  {/* Description */}
631
- {release.description && (
1419
+ {showDescription && (
632
1420
  <div className="bg-theme-elevated/30 rounded-lg p-4">
633
1421
  <h3 className="text-sm font-medium text-theme-text-secondary mb-2">Description</h3>
634
1422
  <p className="text-sm text-theme-text-secondary">{release.description}</p>
@@ -712,12 +1500,23 @@ function OverviewTab({ release, onCopy, copied }: OverviewTabProps) {
712
1500
  )
713
1501
  }
714
1502
 
1503
+ function isDuplicateOperationRawDescription(description: string, operation?: HelmOperation): boolean {
1504
+ const desc = normalizeComparableText(description)
1505
+ const raw = normalizeComparableText(operation?.rawMessage || '')
1506
+ return desc !== '' && raw.includes(desc)
1507
+ }
1508
+
1509
+ function normalizeComparableText(value: string): string {
1510
+ return value.trim().replace(/\s+/g, ' ')
1511
+ }
1512
+
715
1513
  // Hooks tab content
716
1514
  interface HooksTabProps {
717
1515
  hooks: HelmHook[]
1516
+ hookDiagnostics: HookDiagnostic[]
718
1517
  }
719
1518
 
720
- function HooksTab({ hooks }: HooksTabProps) {
1519
+ function HooksTab({ hooks, hookDiagnostics }: HooksTabProps) {
721
1520
  if (hooks.length === 0) {
722
1521
  return (
723
1522
  <div className="flex flex-col items-center justify-center h-48 text-theme-text-tertiary">
@@ -749,23 +1548,31 @@ function HooksTab({ hooks }: HooksTabProps) {
749
1548
  return SEVERITY_BADGE.neutral
750
1549
  }
751
1550
 
1551
+ const diagnostics = new Map<string, HookDiagnostic>()
1552
+ for (const diagnostic of hookDiagnostics) {
1553
+ diagnostics.set(hookDiagnosticKey(diagnostic.namespace, diagnostic.kind, diagnostic.name), diagnostic)
1554
+ diagnostics.set(hookDiagnosticKey(undefined, diagnostic.kind, diagnostic.name), diagnostic)
1555
+ }
1556
+
752
1557
  return (
753
1558
  <div className="p-4 space-y-3">
754
- <p className="text-sm text-theme-text-secondary mb-4">
755
- Helm hooks are executed at specific points during the release lifecycle.
756
- </p>
757
- {hooks.map((hook) => (
758
- <div key={hook.name} className="bg-theme-elevated/30 rounded-lg p-4">
759
- <div className="flex items-start justify-between mb-2">
760
- <div>
761
- <div className="flex items-center gap-2">
762
- <span className="text-theme-text-primary font-medium">{hook.name}</span>
763
- <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">
1559
+ {hooks.map((hook) => {
1560
+ const diagnostic = diagnostics.get(hookDiagnosticKey(hook.namespace, hook.kind, hook.name))
1561
+ || diagnostics.get(hookDiagnosticKey(undefined, hook.kind, hook.name))
1562
+ return (
1563
+ <div key={`${hook.namespace || '_'}:${hook.kind}:${hook.name}`} className="bg-theme-elevated/30 rounded-lg p-4">
1564
+ <div className="flex items-start justify-between gap-3 mb-2">
1565
+ <div className="min-w-0">
1566
+ <div className="flex flex-wrap items-center gap-2">
1567
+ <span className="break-all text-theme-text-primary font-medium">{hook.name}</span>
1568
+ <span className="badge-sm shrink-0 bg-theme-hover/50 text-theme-text-secondary">
764
1569
  {hook.kind}
765
1570
  </span>
766
1571
  </div>
767
- <div className="flex items-center gap-2 mt-1 text-xs text-theme-text-tertiary">
1572
+ <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
1573
+ {hook.namespace && <span className="break-all">Namespace: {hook.namespace}</span>}
768
1574
  <span>Weight: {hook.weight}</span>
1575
+ {hook.path && <span className="break-all">Path: {hook.path}</span>}
769
1576
  </div>
770
1577
  </div>
771
1578
  {hook.status && (
@@ -784,8 +1591,172 @@ function HooksTab({ hooks }: HooksTabProps) {
784
1591
  </span>
785
1592
  ))}
786
1593
  </div>
1594
+ {(hook.startedAt || hook.completedAt || hook.deletePolicies?.length || hook.outputLogPolicies?.length) && (
1595
+ <div className="mt-3 grid grid-cols-1 gap-2 text-xs text-theme-text-tertiary sm:grid-cols-2">
1596
+ {hook.startedAt && (
1597
+ <div>
1598
+ <span className="text-theme-text-disabled">Started: </span>
1599
+ <span>{formatDate(hook.startedAt)}</span>
1600
+ </div>
1601
+ )}
1602
+ {hook.completedAt && (
1603
+ <div>
1604
+ <span className="text-theme-text-disabled">Completed: </span>
1605
+ <span>{formatDate(hook.completedAt)}</span>
1606
+ </div>
1607
+ )}
1608
+ {hook.deletePolicies && hook.deletePolicies.length > 0 && (
1609
+ <div className="sm:col-span-2">
1610
+ <span className="text-theme-text-disabled">Delete policies: </span>
1611
+ <span>{hook.deletePolicies.join(', ')}</span>
1612
+ </div>
1613
+ )}
1614
+ {hook.outputLogPolicies && hook.outputLogPolicies.length > 0 && (
1615
+ <div className="sm:col-span-2">
1616
+ <span className="text-theme-text-disabled">Output log policies: </span>
1617
+ <span>{hook.outputLogPolicies.join(', ')}</span>
1618
+ </div>
1619
+ )}
1620
+ </div>
1621
+ )}
1622
+ {diagnostic && <HookDiagnosticEvidence diagnostic={diagnostic} />}
787
1623
  </div>
788
- ))}
1624
+ )
1625
+ })}
1626
+ </div>
1627
+ )
1628
+ }
1629
+
1630
+ function hookDiagnosticKey(namespace: string | undefined, kind: string | undefined, name: string | undefined): string {
1631
+ return `${namespace || ''}/${(kind || '').toLowerCase()}/${name || ''}`
1632
+ }
1633
+
1634
+ function formatHookRef(hook: Pick<HookDiagnostic, 'namespace' | 'kind' | 'name'>): string {
1635
+ return `${hook.namespace ? `${hook.namespace}/` : ''}${hook.name} (${hook.kind})`
1636
+ }
1637
+
1638
+ function HookDiagnosticEvidence({ diagnostic }: { diagnostic: HookDiagnostic }) {
1639
+ const evidence = diagnostic.evidence
1640
+
1641
+ return (
1642
+ <div className="mt-3 border-t border-theme-border/60 pt-3 text-xs">
1643
+ <div className="flex flex-wrap items-center gap-2">
1644
+ <span className="font-medium text-theme-text-secondary">Diagnostic evidence</span>
1645
+ <span className={clsx('badge-sm', diagnostic.phase.toLowerCase() === 'failed' ? SEVERITY_BADGE.error : SEVERITY_BADGE.warning)}>
1646
+ {diagnostic.phase}
1647
+ </span>
1648
+ {evidence?.summary && <span className="text-theme-text-tertiary">{evidence.summary}</span>}
1649
+ </div>
1650
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.message}</div>
1651
+ {diagnostic.evidenceUnavailableReason && (
1652
+ <div className="mt-1 text-theme-text-tertiary">{diagnostic.evidenceUnavailableReason}</div>
1653
+ )}
1654
+ {evidence && (
1655
+ <div className="mt-3 space-y-3">
1656
+ {evidence.jobs && evidence.jobs.length > 0 && (
1657
+ <div className="space-y-1">
1658
+ {evidence.jobs.map((job) => (
1659
+ <div key={`${job.namespace || ''}/${job.name}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
1660
+ <span className="text-theme-text-tertiary">Job</span>
1661
+ <span className="min-w-0 text-theme-text-primary">
1662
+ <span className="break-all font-medium">{job.namespace ? `${job.namespace}/` : ''}{job.name}</span>
1663
+ {job.status && <span className="ml-2 text-theme-text-secondary">{job.status}</span>}
1664
+ <span className="ml-2 text-theme-text-tertiary">
1665
+ active {job.active || 0} · succeeded {job.succeeded || 0} · failed {job.failed || 0}
1666
+ </span>
1667
+ </span>
1668
+ {job.conditions?.map((condition) => (
1669
+ <span key={condition} className="col-start-2 text-theme-text-tertiary">{condition}</span>
1670
+ ))}
1671
+ </div>
1672
+ ))}
1673
+ </div>
1674
+ )}
1675
+
1676
+ {evidence.pods && evidence.pods.length > 0 && (
1677
+ <div className="space-y-1">
1678
+ {evidence.pods.map((pod) => (
1679
+ <div key={`${pod.namespace || ''}/${pod.name}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
1680
+ <span className="text-theme-text-tertiary">Pod</span>
1681
+ <span className="min-w-0 text-theme-text-primary">
1682
+ <span className="break-all font-medium">{pod.namespace ? `${pod.namespace}/` : ''}{pod.name}</span>
1683
+ {pod.phase && <span className="ml-2 text-theme-text-secondary">{pod.phase}</span>}
1684
+ {pod.ready && <span className="ml-2 text-theme-text-tertiary">{pod.ready} ready</span>}
1685
+ {Boolean(pod.restartCount) && <span className="ml-2 text-theme-text-tertiary">{pod.restartCount} restarts</span>}
1686
+ </span>
1687
+ {(pod.reason || pod.message) && (
1688
+ <span className="col-start-2 text-theme-text-tertiary">
1689
+ {pod.reason && <span className="font-medium">{pod.reason}: </span>}
1690
+ {pod.message}
1691
+ </span>
1692
+ )}
1693
+ </div>
1694
+ ))}
1695
+ </div>
1696
+ )}
1697
+
1698
+ {evidence.events && evidence.events.length > 0 && (
1699
+ <div className="space-y-1">
1700
+ {evidence.events.map((event, index) => (
1701
+ <div key={`${event.involvedKind}/${event.involvedName}/${event.reason || index}`} className="grid grid-cols-[80px_1fr] gap-2 rounded bg-theme-base/40 px-2 py-1">
1702
+ <span className="text-theme-text-tertiary">Event</span>
1703
+ <span className="min-w-0 text-theme-text-primary">
1704
+ <span className={clsx('badge-sm mr-2', event.type === 'Warning' ? SEVERITY_BADGE.warning : SEVERITY_BADGE.neutral)}>
1705
+ {event.type || 'Event'}
1706
+ </span>
1707
+ <span className="font-medium">{event.reason || 'Unknown'}</span>
1708
+ <span className="ml-2 break-all text-theme-text-tertiary">{event.involvedKind}/{event.involvedName}</span>
1709
+ </span>
1710
+ {event.message && <span className="col-start-2 text-theme-text-tertiary">{event.message}</span>}
1711
+ </div>
1712
+ ))}
1713
+ </div>
1714
+ )}
1715
+
1716
+ {evidence.logs && evidence.logs.length > 0 && (
1717
+ <div className="space-y-2">
1718
+ {evidence.logs.map((log, index) => (
1719
+ <HookLogEvidenceBlock key={`${log.pod}/${log.container}/${log.previous ? 'previous' : 'current'}/${index}`} log={log} />
1720
+ ))}
1721
+ </div>
1722
+ )}
1723
+
1724
+ {evidence.errors && evidence.errors.length > 0 && (
1725
+ <div className="space-y-1 text-theme-text-tertiary">
1726
+ {evidence.errors.map((error) => (
1727
+ <div key={error}>Evidence read error: {error}</div>
1728
+ ))}
1729
+ </div>
1730
+ )}
1731
+ </div>
1732
+ )}
1733
+ </div>
1734
+ )
1735
+ }
1736
+
1737
+ function HookLogEvidenceBlock({ log }: { log: HookLogEvidence }) {
1738
+ const visibleLines = (log.lines || []).slice(0, 8)
1739
+
1740
+ return (
1741
+ <div className="rounded bg-theme-base/40 px-2 py-2">
1742
+ <div className="mb-1 flex flex-wrap items-center gap-2 text-theme-text-secondary">
1743
+ <span className="font-medium">Logs</span>
1744
+ <span className="break-all">{log.pod}/{log.container}</span>
1745
+ {log.previous && <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">previous</span>}
1746
+ {log.fallback && <span className="text-theme-text-tertiary">fallback tail</span>}
1747
+ </div>
1748
+ {log.error ? (
1749
+ <div className="text-theme-text-tertiary">{log.error}</div>
1750
+ ) : (
1751
+ <>
1752
+ <pre className="max-h-44 overflow-auto whitespace-pre-wrap rounded bg-theme-base px-2 py-1 font-mono text-[11px] leading-5 text-theme-text-secondary">
1753
+ {visibleLines.join('\n')}
1754
+ </pre>
1755
+ {log.lines && log.lines.length > visibleLines.length && (
1756
+ <div className="mt-1 text-theme-text-tertiary">+{log.lines.length - visibleLines.length} more lines in API response</div>
1757
+ )}
1758
+ </>
1759
+ )}
789
1760
  </div>
790
1761
  )
791
1762
  }