@skyhook-io/radar-app 1.0.0 → 1.0.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.
@@ -0,0 +1,298 @@
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { ChevronDown, Globe, Search, AlertTriangle, X } from 'lucide-react'
4
+ import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
5
+ import { Tooltip } from './ui/Tooltip'
6
+
7
+ export interface NamespaceSwitcherHandle {
8
+ open: () => void
9
+ }
10
+
11
+ interface NamespaceSwitcherProps {
12
+ className?: string
13
+ disabled?: boolean
14
+ disabledTooltip?: string
15
+ }
16
+
17
+ /**
18
+ * NamespaceSwitcher is a per-user multi-select view filter for the cluster
19
+ * view. It does NOT reshape the shared informer cache — picks are saved
20
+ * server-side per user and intersected with the user's RBAC-allowed
21
+ * namespaces on each read.
22
+ *
23
+ * Three states reflect what the backend reports:
24
+ * - cluster-wide: empty trigger label "All namespaces", picker lets the
25
+ * user narrow the view; otherwise informational.
26
+ * - namespace: label shows the namespace count (or single name); picker
27
+ * offers other accessible namespaces and a clear-all reset.
28
+ * - restricted: user can't list namespaces and isn't pinned; picker
29
+ * surfaces only the kubeconfig context's namespace + any saved picks.
30
+ *
31
+ * Selection model: the dropdown keeps a draft Set<string>; toggling rows
32
+ * mutates the draft locally; closing the dropdown applies the draft in a
33
+ * single mutation. "Clear all" applies immediately and closes; "Select all
34
+ * visible" / "Clear visible" mutate the draft only and wait for close.
35
+ */
36
+ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSwitcherProps>(function NamespaceSwitcher(
37
+ { className = '', disabled = false, disabledTooltip },
38
+ ref,
39
+ ) {
40
+ const { data: scope, isLoading } = useNamespaceScope()
41
+ const setActive = useSetActiveNamespace()
42
+
43
+ const [isOpen, setIsOpen] = useState(false)
44
+ const [search, setSearch] = useState('')
45
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
46
+ const [draft, setDraft] = useState<Set<string>>(() => new Set())
47
+
48
+ const triggerRef = useRef<HTMLButtonElement>(null)
49
+ const dropdownRef = useRef<HTMLDivElement>(null)
50
+
51
+ const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
52
+ const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
53
+
54
+ // Sync the draft with the server's view whenever it changes (initial load,
55
+ // post-mutation refetch, eviction after RBAC drift).
56
+ useEffect(() => {
57
+ setDraft(new Set(scopeActives))
58
+ }, [activesKey, scopeActives])
59
+
60
+ const items = useMemo(() => {
61
+ if (!scope) return [] as string[]
62
+ return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
63
+ }, [scope])
64
+
65
+ const filteredItems = useMemo(() => {
66
+ const q = search.trim().toLowerCase()
67
+ if (!q) return items
68
+ return items.filter(n => n.toLowerCase().includes(q))
69
+ }, [items, search])
70
+
71
+ const applySelection = useCallback((next: Set<string>) => {
72
+ if (!scope) return
73
+ const nextArr = Array.from(next).sort()
74
+ if (nextArr.join(',') === activesKey) return
75
+ setActive.mutate({ namespaces: nextArr })
76
+ }, [activesKey, scope, setActive])
77
+
78
+ const closeAndApply = useCallback(() => {
79
+ setIsOpen(false)
80
+ setSearch('')
81
+ applySelection(draft)
82
+ }, [applySelection, draft])
83
+
84
+ useImperativeHandle(ref, () => ({
85
+ open: () => {
86
+ if (disabled || isLoading || setActive.isPending) return
87
+ setIsOpen(true)
88
+ },
89
+ }), [disabled, isLoading, setActive.isPending])
90
+
91
+ useEffect(() => {
92
+ if (!isOpen) return
93
+ const trigger = triggerRef.current
94
+ if (!trigger) return
95
+ const r = trigger.getBoundingClientRect()
96
+ setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
97
+ }, [isOpen])
98
+
99
+ useEffect(() => {
100
+ if (!isOpen) return
101
+ function onClick(e: MouseEvent) {
102
+ if (
103
+ !dropdownRef.current?.contains(e.target as Node) &&
104
+ !triggerRef.current?.contains(e.target as Node)
105
+ ) {
106
+ closeAndApply()
107
+ }
108
+ }
109
+ function onKey(e: KeyboardEvent) {
110
+ if (e.key === 'Escape') closeAndApply()
111
+ }
112
+ document.addEventListener('mousedown', onClick)
113
+ document.addEventListener('keydown', onKey)
114
+ return () => {
115
+ document.removeEventListener('mousedown', onClick)
116
+ document.removeEventListener('keydown', onKey)
117
+ }
118
+ }, [isOpen, closeAndApply])
119
+
120
+ if (!scope) return null
121
+
122
+ const toggle = (ns: string) => {
123
+ const next = new Set(draft)
124
+ if (next.has(ns)) next.delete(ns)
125
+ else next.add(ns)
126
+ setDraft(next)
127
+ }
128
+
129
+ const clearAll = () => {
130
+ setDraft(new Set())
131
+ setIsOpen(false)
132
+ setSearch('')
133
+ applySelection(new Set())
134
+ }
135
+
136
+ const selectAllVisible = () => {
137
+ const next = new Set(draft)
138
+ for (const ns of filteredItems) next.add(ns)
139
+ setDraft(next)
140
+ }
141
+
142
+ const clearVisible = () => {
143
+ const next = new Set(draft)
144
+ for (const ns of filteredItems) next.delete(ns)
145
+ setDraft(next)
146
+ }
147
+
148
+ const activeCount = scopeActives.length
149
+ const triggerLabel =
150
+ activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
151
+ const isClusterWide = activeCount === 0
152
+ const restrictedHint = scope.mode === 'restricted'
153
+ const isDisabled = disabled || isLoading || setActive.isPending
154
+ const canClearAll = scope.canClearNamespace || activeCount === 0
155
+ const tooltipContent = disabled && disabledTooltip
156
+ ? disabledTooltip
157
+ : restrictedHint
158
+ ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
159
+ : isClusterWide
160
+ ? 'Currently viewing all namespaces. Click to narrow the view.'
161
+ : activeCount === 1
162
+ ? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
163
+ : `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
164
+
165
+ // Counts used to label the bulk-action buttons; computed against the visible
166
+ // (filtered) set so the labels match what the action will affect.
167
+ const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
168
+ const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
169
+
170
+ return (
171
+ <>
172
+ <Tooltip
173
+ content={tooltipContent}
174
+ delay={300}
175
+ position="bottom"
176
+ >
177
+ <button
178
+ ref={triggerRef}
179
+ onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
180
+ disabled={isDisabled}
181
+ className={`flex items-center gap-1.5 px-2 py-1 rounded text-sm bg-theme-elevated hover:bg-theme-hover text-theme-text-primary disabled:opacity-60 transition-colors ${className}`}
182
+ aria-label="Switch active namespaces"
183
+ >
184
+ {isClusterWide ? (
185
+ <Globe className="w-3.5 h-3.5 text-theme-text-tertiary" />
186
+ ) : restrictedHint ? (
187
+ <AlertTriangle className="w-3.5 h-3.5 text-theme-text-tertiary" />
188
+ ) : null}
189
+ <span className="font-medium max-w-[180px] truncate">
190
+ {setActive.isPending ? 'Switching…' : triggerLabel}
191
+ </span>
192
+ <ChevronDown className="w-3 h-3 opacity-60" />
193
+ </button>
194
+ </Tooltip>
195
+
196
+ {isOpen &&
197
+ createPortal(
198
+ <div
199
+ ref={dropdownRef}
200
+ style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
201
+ className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
202
+ >
203
+ {items.length > 6 && (
204
+ <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
205
+ <Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
206
+ <input
207
+ autoFocus
208
+ value={search}
209
+ onChange={e => setSearch(e.target.value)}
210
+ placeholder="Filter namespaces"
211
+ className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
212
+ />
213
+ </div>
214
+ )}
215
+
216
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
217
+ <button
218
+ onClick={canClearAll ? clearAll : undefined}
219
+ disabled={!canClearAll || activeCount === 0}
220
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
221
+ aria-label="Clear namespace selection"
222
+ >
223
+ <X className="w-3 h-3" />
224
+ Clear all
225
+ </button>
226
+ <button
227
+ onClick={allVisibleSelected ? clearVisible : selectAllVisible}
228
+ disabled={filteredItems.length === 0}
229
+ className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
230
+ >
231
+ {allVisibleSelected
232
+ ? `Clear ${filteredItems.length} visible`
233
+ : search.trim()
234
+ ? `Select ${filteredItems.length} visible`
235
+ : 'Select all'}
236
+ </button>
237
+ </div>
238
+
239
+ <ul className="max-h-80 overflow-y-auto py-1">
240
+ {filteredItems.length === 0 && (
241
+ <li className="px-3 py-2 text-xs text-theme-text-tertiary">
242
+ {search ? 'No matches.' : 'No namespaces available.'}
243
+ </li>
244
+ )}
245
+
246
+ {filteredItems.map(ns => {
247
+ const isChecked = draft.has(ns)
248
+ const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
249
+ return (
250
+ <li key={ns}>
251
+ <label
252
+ className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer"
253
+ >
254
+ <span className="flex items-center gap-2 min-w-0">
255
+ <input
256
+ type="checkbox"
257
+ checked={isChecked}
258
+ onChange={() => toggle(ns)}
259
+ className="shrink-0 accent-current"
260
+ />
261
+ <span className="truncate">{ns}</span>
262
+ {isContextDefault && (
263
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
264
+ kubeconfig
265
+ </span>
266
+ )}
267
+ </span>
268
+ </label>
269
+ </li>
270
+ )
271
+ })}
272
+ </ul>
273
+
274
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
275
+ <span>
276
+ {draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
277
+ </span>
278
+ <button
279
+ onClick={closeAndApply}
280
+ className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
281
+ >
282
+ Done
283
+ </button>
284
+ </div>
285
+
286
+ {!scope.authoritative && (
287
+ <div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
288
+ Limited list — your RBAC doesn&rsquo;t allow listing all
289
+ namespaces. Other namespaces may be accessible but won&rsquo;t
290
+ appear here until you switch context.
291
+ </div>
292
+ )}
293
+ </div>,
294
+ document.body,
295
+ )}
296
+ </>
297
+ )
298
+ })
@@ -56,16 +56,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
56
56
  // transient error state under the role-gated panel.
57
57
  const { canAtLeast } = useCloudRole()
58
58
  const canViewSensitive = canAtLeast('member')
59
+ const helmNamespace = release.storageNamespace || release.namespace
59
60
 
60
61
  const { data: releaseDetail, isLoading, refetch: refetchRelease } = useHelmRelease(
61
- release.namespace,
62
+ helmNamespace,
62
63
  release.name
63
64
  )
64
65
  const [refetch, isRefreshAnimating] = useRefreshAnimation(refetchRelease)
65
66
 
66
67
  // Fetch manifest for selected revision (or latest)
67
68
  const { data: manifest, isLoading: manifestLoading } = useHelmManifest(
68
- release.namespace,
69
+ helmNamespace,
69
70
  release.name,
70
71
  selectedRevision,
71
72
  canViewSensitive,
@@ -73,7 +74,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
73
74
 
74
75
  // Fetch values
75
76
  const { data: values, isLoading: valuesLoading } = useHelmValues(
76
- release.namespace,
77
+ helmNamespace,
77
78
  release.name,
78
79
  showAllValues,
79
80
  canViewSensitive,
@@ -81,7 +82,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
81
82
 
82
83
  // Fetch diff if comparing revisions
83
84
  const { data: diffData, isLoading: diffLoading } = useHelmManifestDiff(
84
- release.namespace,
85
+ helmNamespace,
85
86
  release.name,
86
87
  diffRevisions?.rev1 || 0,
87
88
  diffRevisions?.rev2 || 0,
@@ -89,10 +90,11 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
89
90
  )
90
91
 
91
92
  // Lazy check for upgrade availability
92
- const { data: upgradeInfo, isLoading: upgradeLoading } = useHelmUpgradeInfo(
93
- release.namespace,
93
+ const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError } = useHelmUpgradeInfo(
94
+ helmNamespace,
94
95
  release.name
95
96
  )
97
+ const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade check failed'
96
98
 
97
99
  // Mutations for actions
98
100
  const uninstallMutation = useHelmUninstall()
@@ -176,7 +178,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
176
178
 
177
179
  try {
178
180
  await rollbackWithProgress(
179
- release.namespace,
181
+ helmNamespace,
180
182
  release.name,
181
183
  rollbackRevision,
182
184
  (event) => {
@@ -195,7 +197,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
195
197
  }])
196
198
 
197
199
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
198
- queryClient.invalidateQueries({ queryKey: ['helm-release', release.namespace, release.name] })
200
+ queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
199
201
 
200
202
  setTimeout(() => {
201
203
  setRollbackRevision(null)
@@ -215,7 +217,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
215
217
 
216
218
  const handleUninstallConfirm = () => {
217
219
  uninstallMutation.mutate(
218
- { namespace: release.namespace, name: release.name },
220
+ { namespace: helmNamespace, name: release.name },
219
221
  {
220
222
  onSuccess: () => {
221
223
  setShowUninstallConfirm(false)
@@ -235,9 +237,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
235
237
 
236
238
  try {
237
239
  await upgradeWithProgress(
238
- release.namespace,
240
+ helmNamespace,
239
241
  release.name,
240
242
  upgradeInfo.latestVersion,
243
+ upgradeInfo.repositoryName,
241
244
  (event) => {
242
245
  if (event.type === 'progress' && event.message) {
243
246
  setUpgradeProgress(prev => [...prev, {
@@ -255,8 +258,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
255
258
 
256
259
  // Invalidate queries
257
260
  queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
258
- queryClient.invalidateQueries({ queryKey: ['helm-release', release.namespace, release.name] })
259
- queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', release.namespace, release.name] })
261
+ queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
262
+ queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', helmNamespace, release.name] })
260
263
  queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
261
264
 
262
265
  setTimeout(() => {
@@ -327,6 +330,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
327
330
  <span className="badge bg-theme-hover/50 text-theme-text-secondary animate-pulse">
328
331
  checking...
329
332
  </span>
333
+ ) : upgradeError ? (
334
+ <span
335
+ className="badge bg-theme-hover/50 text-theme-text-secondary"
336
+ title={upgradeErrorMessage}
337
+ >
338
+ upgrade check failed
339
+ </span>
330
340
  ) : upgradeInfo?.updateAvailable ? (
331
341
  <button
332
342
  onClick={() => setShowUpgradeConfirm(true)}
@@ -344,6 +354,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
344
354
  <span className={clsx('badge', SEVERITY_BADGE.success)} title="Chart is up to date">
345
355
  latest
346
356
  </span>
357
+ ) : upgradeInfo?.error ? (
358
+ <span
359
+ className="badge bg-theme-hover/50 text-theme-text-secondary"
360
+ title={upgradeInfo.error}
361
+ >
362
+ upstream unknown
363
+ </span>
347
364
  ) : null}
348
365
  </div>
349
366
  <div className="flex items-center gap-1">
@@ -450,7 +467,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
450
467
  onToggleAllValues={setShowAllValues}
451
468
  onCopy={(text) => copyToClipboard(text, 'values')}
452
469
  copied={copied === 'values'}
453
- namespace={release.namespace}
470
+ namespace={helmNamespace}
454
471
  name={release.name}
455
472
  onApplySuccess={() => refetch()}
456
473
  />
@@ -17,7 +17,7 @@ type ViewTab = 'releases' | 'charts'
17
17
  interface HelmViewProps {
18
18
  namespace: 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
23
  export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmViewProps) {
@@ -27,12 +27,14 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
27
27
 
28
28
  const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespace || undefined)
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
+ const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
33
34
  namespace || undefined,
34
35
  Boolean(releases && releases.length > 0)
35
36
  )
37
+ const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
36
38
 
37
39
  const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(async () => {
38
40
  await Promise.all([refetchReleases(), refetchUpgradeInfo()])
@@ -132,7 +134,7 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
132
134
  scope: 'helm',
133
135
  handler: () => {
134
136
  const release = getHighlightedRelease()
135
- if (release) onReleaseClick?.(release.namespace, release.name)
137
+ if (release) onReleaseClick?.(release.namespace, release.name, release.storageNamespace)
136
138
  },
137
139
  enabled: highlightedIndex >= 0,
138
140
  },
@@ -232,6 +234,11 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
232
234
 
233
235
  {/* Releases Table */}
234
236
  <div className="flex-1 overflow-auto">
237
+ {upgradeError && (
238
+ <div className="m-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-300">
239
+ Upgrade checks failed: {upgradeErrorMessage}
240
+ </div>
241
+ )}
235
242
  {isLoading ? (
236
243
  <PaneLoader className="h-full" />
237
244
  ) : isForbidden ? (
@@ -240,6 +247,20 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
240
247
  <p className="text-theme-text-secondary font-medium">Access Restricted</p>
241
248
  <p className="text-sm mt-1">Insufficient permissions to list Helm releases</p>
242
249
  </div>
250
+ ) : releasesError ? (
251
+ <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-3 px-6 text-center">
252
+ <Package className="w-10 h-10 text-amber-400" />
253
+ <div>
254
+ <p className="text-theme-text-secondary font-medium">Failed to load Helm releases</p>
255
+ <p className="text-sm mt-1 break-all">{releasesErrorMessage}</p>
256
+ </div>
257
+ <button
258
+ onClick={() => refetchReleases()}
259
+ className="px-3 py-1.5 text-sm text-theme-text-primary border border-theme-border rounded-lg hover:bg-theme-elevated transition-colors"
260
+ >
261
+ Retry
262
+ </button>
263
+ </div>
243
264
  ) : filteredReleases.length === 0 ? (
244
265
  <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-2">
245
266
  <Package className="w-12 h-12 text-theme-text-disabled" />
@@ -291,16 +312,17 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
291
312
  <tbody className="table-divide-subtle">
292
313
  {filteredReleases.map((release, index) => (
293
314
  <ReleaseRow
294
- key={`${release.namespace}-${release.name}`}
315
+ key={releaseIdentityKey(release)}
295
316
  ref={index === highlightedIndex ? highlightedRowRef : null}
296
317
  release={release}
297
- upgradeInfo={upgradeInfo?.releases[`${release.namespace}/${release.name}`]}
318
+ upgradeInfo={upgradeInfo?.releases[releaseIdentityKey(release)]}
298
319
  isSelected={
299
320
  selectedRelease?.namespace === release.namespace &&
300
- selectedRelease?.name === release.name
321
+ selectedRelease?.name === release.name &&
322
+ (selectedRelease?.storageNamespace || selectedRelease?.namespace) === (release.storageNamespace || release.namespace)
301
323
  }
302
324
  isHighlighted={index === highlightedIndex}
303
- onClick={() => onReleaseClick?.(release.namespace, release.name)}
325
+ onClick={() => onReleaseClick?.(release.namespace, release.name, release.storageNamespace)}
304
326
  onMouseEnter={() => setHighlightedIndex(-1)}
305
327
  />
306
328
  ))}
@@ -329,6 +351,10 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
329
351
  )
330
352
  }
331
353
 
354
+ function releaseIdentityKey(release: Pick<HelmRelease, 'namespace' | 'name' | 'storageNamespace'>): string {
355
+ return `${release.storageNamespace || release.namespace}/${release.name}`
356
+ }
357
+
332
358
  interface ReleaseRowProps {
333
359
  release: HelmRelease
334
360
  upgradeInfo?: UpgradeInfo