@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,13 +1,13 @@
1
1
  import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'react'
2
- import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
2
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
4
- import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield } from 'lucide-react'
5
- import { PaneLoader } from '@skyhook-io/k8s-ui'
3
+ import { Package, Search, ArrowUpCircle, LayoutGrid, List, Shield, GitBranch, ChevronRight, RotateCcw, Clock } from 'lucide-react'
4
+ import { PaneLoader, PageHeader, SortableTh, FreshnessControl, type SortDir, Input } from '@skyhook-io/k8s-ui'
5
+ import { useConnection } from '../../context/ConnectionContext'
6
6
  import { clsx } from 'clsx'
7
7
  import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
8
- import type { HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
9
- import { getStatusColor, formatAge, truncate } from './helm-utils'
10
- import { SEVERITY_BADGE } from '../../utils/badge-colors'
8
+ import type { HelmOperation, HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
9
+ import { getStatusColor, formatAge, isHelmReleaseActionable } from './helm-utils'
10
+ import { SEVERITY_BADGE, SEVERITY_DOT, SEVERITY_TEXT } from '../../utils/badge-colors'
11
11
  import { Tooltip } from '../ui/Tooltip'
12
12
  import { ChartBrowser } from './ChartBrowser'
13
13
  import { InstallWizard } from './InstallWizard'
@@ -15,43 +15,59 @@ import { InstallWizard } from './InstallWizard'
15
15
  type ViewTab = 'releases' | 'charts'
16
16
 
17
17
  interface HelmViewProps {
18
- namespace: string
18
+ namespaces: string[]
19
19
  selectedRelease?: SelectedHelmRelease | null
20
- onReleaseClick?: (namespace: string, name: string) => void
20
+ onReleaseClick?: (namespace: string, name: string, storageNamespace?: string) => void
21
21
  }
22
22
 
23
- export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmViewProps) {
23
+ export function HelmView({ namespaces, selectedRelease, onReleaseClick }: HelmViewProps) {
24
24
  const [activeTab, setActiveTab] = useState<ViewTab>('releases')
25
25
  const [searchTerm, setSearchTerm] = useState('')
26
26
  const [selectedChart, setSelectedChart] = useState<{ repo: string; chart: string; version: string; source: ChartSource } | null>(null)
27
27
 
28
- const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespace || undefined)
28
+ const { data: releases, isLoading, error: releasesError, dataUpdatedAt: releasesUpdatedAt, isFetching: releasesFetching, refetch: refetchReleases } = useHelmReleases(namespaces)
29
29
  const isForbidden = isForbiddenError(releasesError)
30
+ const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
30
31
 
31
32
  // Lazy load upgrade info after releases are loaded
32
- const { data: upgradeInfo, isLoading: upgradeLoading, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
33
- namespace || undefined,
33
+ const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
34
+ namespaces,
34
35
  Boolean(releases && releases.length > 0)
35
36
  )
37
+ const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
36
38
 
37
- const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(async () => {
38
- await Promise.all([refetchReleases(), refetchUpgradeInfo()])
39
- })
40
-
41
- const isFullyLoaded = !isLoading && !upgradeLoading
39
+ const { connection } = useConnection()
40
+ const refetchAll = () => Promise.all([refetchReleases(), refetchUpgradeInfo()])
42
41
 
43
42
  // Filter releases by search term
43
+ // Resources-table cycle: asc → desc → off (null restores the server's order).
44
+ const [sort, setSort] = useState<{ key: HelmSortKey; dir: SortDir } | null>(null)
45
+ const onSort = useCallback(
46
+ (key: HelmSortKey) =>
47
+ setSort((prev) => {
48
+ if (!prev || prev.key !== key) return { key, dir: 'asc' }
49
+ if (prev.dir === 'asc') return { key, dir: 'desc' }
50
+ return null
51
+ }),
52
+ [],
53
+ )
54
+
44
55
  const filteredReleases = useMemo(() => {
45
56
  if (!releases) return []
46
- if (!searchTerm) return releases
47
- const term = searchTerm.toLowerCase()
48
- return releases.filter(
49
- (r) =>
50
- r.name.toLowerCase().includes(term) ||
51
- r.namespace.toLowerCase().includes(term) ||
52
- r.chart.toLowerCase().includes(term)
53
- )
54
- }, [releases, searchTerm])
57
+ const term = searchTerm.trim().toLowerCase()
58
+ const filtered = term
59
+ ? releases.filter(
60
+ (r) =>
61
+ r.name.toLowerCase().includes(term) ||
62
+ r.namespace.toLowerCase().includes(term) ||
63
+ r.chart.toLowerCase().includes(term)
64
+ )
65
+ : releases
66
+
67
+ if (!sort) return filtered
68
+ const factor = sort.dir === 'asc' ? 1 : -1
69
+ return [...filtered].sort((a, b) => compareReleases(a, b, sort.key) * factor)
70
+ }, [releases, searchTerm, sort])
55
71
 
56
72
  // Keyboard navigation state
57
73
  const searchInputRef = useRef<HTMLInputElement>(null)
@@ -60,8 +76,9 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
60
76
  const filteredReleasesCountRef = useRef(0)
61
77
  filteredReleasesCountRef.current = filteredReleases.length
62
78
 
63
- // Reset highlight when search changes
64
- useEffect(() => { setHighlightedIndex(-1) }, [searchTerm])
79
+ // Reset highlight when the visible order changes (search or sort) — otherwise
80
+ // the index would point at whatever release shifted into that row.
81
+ useEffect(() => { setHighlightedIndex(-1) }, [searchTerm, sort])
65
82
 
66
83
  // Scroll highlighted row into view
67
84
  useEffect(() => {
@@ -132,7 +149,7 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
132
149
  scope: 'helm',
133
150
  handler: () => {
134
151
  const release = getHighlightedRelease()
135
- if (release) onReleaseClick?.(release.namespace, release.name)
152
+ if (release) onReleaseClick?.(release.namespace, release.name, release.storageNamespace)
136
153
  },
137
154
  enabled: highlightedIndex >= 0,
138
155
  },
@@ -165,6 +182,25 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
165
182
  <div className="flex h-full w-full">
166
183
  {/* Main Content */}
167
184
  <div className="flex-1 flex flex-col overflow-hidden min-w-0 w-full">
185
+ {/* Page header — consistent across views; the tabs + search sit below. */}
186
+ <div className="px-4 pt-4 pb-1">
187
+ <PageHeader
188
+ icon={Package}
189
+ title="Helm"
190
+ description="Installed Helm releases and the chart catalog for this cluster."
191
+ actions={
192
+ activeTab === 'releases' ? (
193
+ <FreshnessControl
194
+ mode="snapshot"
195
+ dataUpdatedAt={releasesUpdatedAt}
196
+ isFetching={releasesFetching || upgradeLoading}
197
+ onRefresh={refetchAll}
198
+ connectionState={connection.state}
199
+ />
200
+ ) : undefined
201
+ }
202
+ />
203
+ </div>
168
204
  {/* Tab bar */}
169
205
  <div className="flex items-center gap-1 px-4 pt-3 border-b border-theme-border bg-theme-surface/50">
170
206
  <button
@@ -202,36 +238,25 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
202
238
  <>
203
239
  {/* Releases Toolbar */}
204
240
  <div className="flex items-center gap-4 px-4 py-3 border-b border-theme-border bg-theme-surface/50 shrink-0">
205
- <div className="flex items-center gap-2 text-theme-text-secondary">
206
- <Package className="w-5 h-5" />
207
- <span className="font-medium">Helm Releases</span>
208
- {!isFullyLoaded && (
209
- <RefreshCw className="w-3.5 h-3.5 animate-spin text-theme-text-tertiary" />
210
- )}
211
- </div>
212
241
  <div className="flex-1 relative">
213
242
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
214
- <input
243
+ <Input
215
244
  ref={searchInputRef}
216
- type="text"
217
245
  placeholder="Search releases..."
218
246
  value={searchTerm}
219
247
  onChange={(e) => setSearchTerm(e.target.value)}
220
248
  className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
221
249
  />
222
250
  </div>
223
- <button
224
- onClick={handleRefresh}
225
- disabled={isRefreshAnimating}
226
- className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg disabled:opacity-50"
227
- title="Refresh"
228
- >
229
- <RefreshCw className={clsx('w-4 h-4', isRefreshAnimating && 'animate-spin')} />
230
- </button>
231
251
  </div>
232
252
 
233
253
  {/* Releases Table */}
234
254
  <div className="flex-1 overflow-auto">
255
+ {upgradeError && (
256
+ <div className="m-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-300">
257
+ Upgrade checks failed: {upgradeErrorMessage}
258
+ </div>
259
+ )}
235
260
  {isLoading ? (
236
261
  <PaneLoader className="h-full" />
237
262
  ) : isForbidden ? (
@@ -240,6 +265,20 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
240
265
  <p className="text-theme-text-secondary font-medium">Access Restricted</p>
241
266
  <p className="text-sm mt-1">Insufficient permissions to list Helm releases</p>
242
267
  </div>
268
+ ) : releasesError ? (
269
+ <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-3 px-6 text-center">
270
+ <Package className="w-10 h-10 text-amber-400" />
271
+ <div>
272
+ <p className="text-theme-text-secondary font-medium">Failed to load Helm releases</p>
273
+ <p className="text-sm mt-1 break-all">{releasesErrorMessage}</p>
274
+ </div>
275
+ <button
276
+ onClick={() => refetchReleases()}
277
+ className="px-3 py-1.5 text-sm text-theme-text-primary border border-theme-border rounded-lg hover:bg-theme-elevated transition-colors"
278
+ >
279
+ Retry
280
+ </button>
281
+ </div>
243
282
  ) : filteredReleases.length === 0 ? (
244
283
  <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-2">
245
284
  <Package className="w-12 h-12 text-theme-text-disabled" />
@@ -263,44 +302,31 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
263
302
  </div>
264
303
  ) : (
265
304
  <table className="w-full table-fixed">
266
- <thead className="bg-theme-surface sticky top-0 z-10">
305
+ <thead className="bg-theme-base sticky top-0 z-10">
267
306
  <tr>
268
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
269
- Name
270
- </th>
271
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-32">
272
- Namespace
273
- </th>
274
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-48">
275
- Chart
276
- </th>
277
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24 hidden xl:table-cell">
278
- App Version
279
- </th>
280
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-28">
281
- Status
282
- </th>
283
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-20">
284
- Rev
285
- </th>
286
- <th className="text-left px-4 py-3 text-xs font-medium text-theme-text-secondary uppercase tracking-wide w-24">
287
- Updated
288
- </th>
307
+ <SortableTh label="Name" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[28%]" />
308
+ <SortableTh label="Namespace" sortKey="namespace" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[18%]" />
309
+ <SortableTh label="Chart" sortKey="chart" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[22%]" />
310
+ <SortableTh label="App Version" sortKey="appVersion" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24 hidden xl:table-cell" />
311
+ <SortableTh label="Status" sortKey="status" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-40" />
312
+ <SortableTh label="Rev" sortKey="revision" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-16" />
313
+ <SortableTh label="Updated" sortKey="updated" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-24" />
289
314
  </tr>
290
315
  </thead>
291
316
  <tbody className="table-divide-subtle">
292
317
  {filteredReleases.map((release, index) => (
293
318
  <ReleaseRow
294
- key={`${release.namespace}-${release.name}`}
319
+ key={releaseIdentityKey(release)}
295
320
  ref={index === highlightedIndex ? highlightedRowRef : null}
296
321
  release={release}
297
- upgradeInfo={upgradeInfo?.releases[`${release.namespace}/${release.name}`]}
322
+ upgradeInfo={upgradeInfo?.releases[releaseIdentityKey(release)]}
298
323
  isSelected={
299
324
  selectedRelease?.namespace === release.namespace &&
300
- selectedRelease?.name === release.name
325
+ selectedRelease?.name === release.name &&
326
+ (selectedRelease?.storageNamespace || selectedRelease?.namespace) === (release.storageNamespace || release.namespace)
301
327
  }
302
328
  isHighlighted={index === highlightedIndex}
303
- onClick={() => onReleaseClick?.(release.namespace, release.name)}
329
+ onClick={() => onReleaseClick?.(release.namespace, release.name, release.storageNamespace)}
304
330
  onMouseEnter={() => setHighlightedIndex(-1)}
305
331
  />
306
332
  ))}
@@ -329,6 +355,27 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
329
355
  )
330
356
  }
331
357
 
358
+ function releaseIdentityKey(release: Pick<HelmRelease, 'namespace' | 'name' | 'storageNamespace'>): string {
359
+ return `${release.storageNamespace || release.namespace}/${release.name}`
360
+ }
361
+
362
+ type HelmSortKey = 'name' | 'namespace' | 'chart' | 'appVersion' | 'status' | 'revision' | 'updated'
363
+
364
+ function compareReleases(a: HelmRelease, b: HelmRelease, key: HelmSortKey): number {
365
+ let cmp: number
366
+ switch (key) {
367
+ case 'revision':
368
+ cmp = a.revision - b.revision
369
+ break
370
+ case 'updated':
371
+ cmp = (Date.parse(a.updated) || 0) - (Date.parse(b.updated) || 0)
372
+ break
373
+ default:
374
+ cmp = String(a[key] ?? '').localeCompare(String(b[key] ?? ''))
375
+ }
376
+ return cmp || a.name.localeCompare(b.name)
377
+ }
378
+
332
379
  interface ReleaseRowProps {
333
380
  release: HelmRelease
334
381
  upgradeInfo?: UpgradeInfo
@@ -361,7 +408,7 @@ function getActionableTooltip(issue: string | undefined, summary: string | undef
361
408
  <div className="max-w-xs">
362
409
  <div className={clsx(
363
410
  'font-medium',
364
- health === 'unhealthy' ? 'text-red-400' : 'text-yellow-400'
411
+ health === 'unhealthy' ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning
365
412
  )}>
366
413
  {summary || issue || health}
367
414
  </div>
@@ -380,16 +427,82 @@ function getActionableTooltip(issue: string | undefined, summary: string | undef
380
427
  )
381
428
  }
382
429
 
430
+ function getListOperation(release: HelmRelease): HelmOperation | undefined {
431
+ if (release.lastOperation) {
432
+ const releaseStatus = release.status.toLowerCase()
433
+ if (release.lastOperation.status === 'failed' && releaseStatus === 'failed') {
434
+ return undefined
435
+ }
436
+ if (isListOperation(release.lastOperation)) {
437
+ return release.lastOperation
438
+ }
439
+ }
440
+ return release.operations?.find(isListOperation)
441
+ }
442
+
443
+ function isListOperation(operation: HelmOperation): boolean {
444
+ return operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback' || operation.status === 'stuck_pending'
445
+ }
446
+
447
+ function HelmOperationChip({ operation }: { operation: HelmOperation }) {
448
+ const isPending = operation.status === 'stuck_pending'
449
+ const isRollback = operation.kind === 'upgrade_rolled_back' || operation.kind === 'rollback'
450
+ const Icon = isRollback ? RotateCcw : Clock
451
+ const tone: keyof typeof SEVERITY_BADGE = operation.kind === 'rollback' ? 'info' : isPending ? 'warning' : 'alert'
452
+ const label = operation.kind === 'upgrade_rolled_back'
453
+ ? 'rolled back'
454
+ : operation.kind === 'rollback'
455
+ ? 'rollback'
456
+ : 'stuck'
457
+
458
+ return (
459
+ <Tooltip content={
460
+ <div className="max-w-xs">
461
+ <div className="font-medium text-theme-text-primary">{operationSummary(operation)}</div>
462
+ <div className="mt-1 text-[10px] text-theme-text-secondary">{operation.message}</div>
463
+ {operation.failureDescription && (
464
+ <div className="mt-1.5 border-t border-theme-border pt-1.5 text-[10px] text-theme-text-tertiary">
465
+ {operation.failureDescription}
466
+ </div>
467
+ )}
468
+ </div>
469
+ }>
470
+ <span className={clsx('badge-sm shrink-0', SEVERITY_BADGE[tone])} aria-label={operationSummary(operation)}>
471
+ <Icon className="h-3 w-3" />
472
+ <span className="sr-only">{label}</span>
473
+ <span className="hidden 2xl:inline" aria-hidden="true">{label}</span>
474
+ </span>
475
+ </Tooltip>
476
+ )
477
+ }
478
+
479
+ function operationSummary(operation: HelmOperation): string {
480
+ switch (operation.kind) {
481
+ case 'upgrade_rolled_back':
482
+ return 'Helm rolled back after failed upgrade'
483
+ case 'rollback':
484
+ return 'Helm rollback applied'
485
+ case 'pending':
486
+ return 'Helm operation may be stuck'
487
+ case 'upgrade_failed':
488
+ return 'Helm upgrade failed'
489
+ default:
490
+ return 'Helm release failed'
491
+ }
492
+ }
493
+
383
494
  const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
384
495
  function ReleaseRow({ release, upgradeInfo, isSelected, isHighlighted, onClick, onMouseEnter }, ref) {
496
+ const listOperation = getListOperation(release)
497
+
385
498
  // Health badge styling
386
499
  const getHealthBadge = () => {
387
500
  if (!release.resourceHealth || release.resourceHealth === 'unknown') return null
388
501
 
389
- const healthStyles: Record<string, { bg: string; text: string; dot: string }> = {
390
- healthy: { bg: 'bg-green-500/10', text: 'text-green-400', dot: 'bg-green-500' },
391
- degraded: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', dot: 'bg-yellow-500' },
392
- unhealthy: { bg: 'bg-red-500/10', text: 'text-red-400', dot: 'bg-red-500' },
502
+ const healthStyles: Record<string, { badge: string; dot: string }> = {
503
+ healthy: { badge: SEVERITY_BADGE.success, dot: SEVERITY_DOT.success },
504
+ degraded: { badge: SEVERITY_BADGE.warning, dot: SEVERITY_DOT.warning },
505
+ unhealthy: { badge: SEVERITY_BADGE.error, dot: SEVERITY_DOT.error },
393
506
  }
394
507
 
395
508
  const style = healthStyles[release.resourceHealth] || healthStyles.healthy
@@ -398,8 +511,8 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
398
511
  return (
399
512
  <Tooltip content={tooltipContent}>
400
513
  <span className={clsx(
401
- 'flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded shrink-0',
402
- style.bg, style.text
514
+ 'badge-sm shrink-0',
515
+ style.badge
403
516
  )}>
404
517
  <span className={clsx('w-1.5 h-1.5 rounded-full', style.dot)} />
405
518
  {release.healthIssue || (release.resourceHealth !== 'healthy' ? release.healthSummary : null)}
@@ -423,10 +536,19 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
423
536
  )}
424
537
  >
425
538
  <td className="px-4 py-3">
426
- <div className="flex items-center gap-2">
539
+ <div className="flex min-w-0 items-center gap-2">
427
540
  <Package className="w-4 h-4 text-theme-text-tertiary shrink-0" />
428
- <span className="text-sm text-theme-text-primary font-medium truncate">{release.name}</span>
541
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{release.name}</span>
429
542
  {getHealthBadge()}
543
+ {listOperation && <HelmOperationChip operation={listOperation} />}
544
+ {release.managedByFluxHelmRelease && (
545
+ <Tooltip content={`Installed by Flux helm-controller via HelmRelease ${release.managedByFluxHelmRelease}. Changes here will be reverted at the next reconcile — manage via the GitOps tab.`}>
546
+ <span className="badge-sm shrink-0 border border-theme-border bg-theme-elevated text-theme-text-secondary">
547
+ <GitBranch className="w-3 h-3" />
548
+ Flux
549
+ </span>
550
+ </Tooltip>
551
+ )}
430
552
  {upgradeInfo?.updateAvailable && (
431
553
  <Tooltip content={`Upgrade available: ${release.chartVersion} → ${upgradeInfo.latestVersion}`}>
432
554
  <span className={clsx('badge-sm shrink-0', SEVERITY_BADGE.warning)}>
@@ -436,30 +558,49 @@ const ReleaseRow = forwardRef<HTMLTableRowElement, ReleaseRowProps>(
436
558
  )}
437
559
  </div>
438
560
  </td>
439
- <td className="px-4 py-3 w-32">
440
- <span className="text-sm text-theme-text-secondary">{release.namespace}</span>
561
+ <td className="px-4 py-3 w-[18%]">
562
+ <Tooltip content={release.namespace}>
563
+ <span className="block truncate text-sm text-theme-text-secondary">{release.namespace}</span>
564
+ </Tooltip>
441
565
  </td>
442
- <td className="px-4 py-3 w-48">
566
+ <td className="px-4 py-3 w-[22%]">
443
567
  <Tooltip content={`${release.chart}-${release.chartVersion}`}>
444
568
  <span className="text-sm text-theme-text-secondary truncate block">
445
- {truncate(`${release.chart}-${release.chartVersion}`, 35)}
569
+ {release.chart}-{release.chartVersion}
446
570
  </span>
447
571
  </Tooltip>
448
572
  </td>
449
573
  <td className="px-4 py-3 w-24 hidden xl:table-cell">
450
- <span className="text-sm text-theme-text-secondary">{release.appVersion || '-'}</span>
574
+ {release.appVersion ? (
575
+ <span className="text-sm text-theme-text-secondary">{release.appVersion}</span>
576
+ ) : (
577
+ <Tooltip content="This chart did not declare an appVersion in Chart.yaml.">
578
+ <span className="text-sm text-theme-text-disabled cursor-help">—</span>
579
+ </Tooltip>
580
+ )}
451
581
  </td>
452
- <td className="px-4 py-3 w-28">
453
- <span
454
- className={clsx(
455
- 'badge',
456
- getStatusColor(release.status)
457
- )}
458
- >
459
- {release.status}
460
- </span>
582
+ <td className="px-4 py-3 w-40">
583
+ {isHelmReleaseActionable(release.status) ? (
584
+ <Tooltip content="Click row to view rollback / history / logs and recover">
585
+ <span
586
+ className={clsx('badge inline-flex items-center gap-1', getStatusColor(release.status))}
587
+ >
588
+ {release.status}
589
+ <ChevronRight className="w-3 h-3 opacity-70" />
590
+ </span>
591
+ </Tooltip>
592
+ ) : (
593
+ <span
594
+ className={clsx(
595
+ 'badge',
596
+ getStatusColor(release.status)
597
+ )}
598
+ >
599
+ {release.status}
600
+ </span>
601
+ )}
461
602
  </td>
462
- <td className="px-4 py-3 w-20">
603
+ <td className="px-4 py-3 w-16">
463
604
  <span className="text-sm text-theme-text-secondary">{release.revision}</span>
464
605
  </td>
465
606
  <td className="px-4 py-3 w-24">