@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.
- package/package.json +5 -5
- package/src/App.tsx +135 -34
- package/src/api/client.ts +94 -3
- package/src/components/ContextSwitcher.tsx +49 -16
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +30 -13
- package/src/components/helm/HelmView.tsx +33 -7
- package/src/components/portforward/PortForwardManager.tsx +152 -111
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -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’t allow listing all
|
|
289
|
+
namespaces. Other namespaces may be accessible but won’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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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:
|
|
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
|
-
|
|
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',
|
|
259
|
-
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info',
|
|
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={
|
|
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={
|
|
315
|
+
key={releaseIdentityKey(release)}
|
|
295
316
|
ref={index === highlightedIndex ? highlightedRowRef : null}
|
|
296
317
|
release={release}
|
|
297
|
-
upgradeInfo={upgradeInfo?.releases[
|
|
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
|