@skyhook-io/radar-app 0.1.6 → 0.1.9
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 +1 -1
- package/src/App.tsx +6 -6
- package/src/api/client.ts +102 -10
- package/src/components/ContextSwitcher.tsx +98 -357
- package/src/components/audit/AuditView.tsx +3 -10
- package/src/components/cost/CostView.tsx +2 -8
- package/src/components/helm/ChartBrowser.tsx +16 -6
- package/src/components/helm/HelmReleaseDrawer.tsx +53 -36
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +5 -7
- package/src/components/helm/ManifestDiffViewer.tsx +2 -5
- package/src/components/helm/ManifestViewer.tsx +2 -5
- package/src/components/helm/RoleGatedPanel.tsx +47 -0
- package/src/components/helm/ValuesViewer.tsx +5 -8
- package/src/components/home/HelmSummary.tsx +12 -0
- package/src/components/home/HomeView.tsx +3 -10
- package/src/components/resources/ImageFilesystemModal.tsx +6 -5
- package/src/components/resources/PodFilesystemModal.tsx +2 -6
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -7
- package/src/components/traffic/TrafficView.tsx +5 -7
- package/src/components/traffic/TrafficWizard.tsx +7 -12
- package/src/index.ts +6 -0
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { Server, AlertTriangle } from 'lucide-react'
|
|
3
|
+
import {
|
|
4
|
+
ClusterSwitcher,
|
|
5
|
+
type ClusterSwitcherItem,
|
|
6
|
+
pluralize,
|
|
7
|
+
} from '@skyhook-io/k8s-ui'
|
|
3
8
|
import { useContexts, useSwitchContext, useClusterInfo, fetchSessionCounts, type SessionCounts } from '../api/client'
|
|
4
9
|
import { useContextSwitch } from '../context/ContextSwitchContext'
|
|
5
10
|
import { useToast } from '../components/ui/Toast'
|
|
6
11
|
import { useDock } from '../components/dock'
|
|
7
12
|
import type { ContextInfo } from '../types'
|
|
8
13
|
import { parseContextName, type ParsedContextName } from '../utils/context-name'
|
|
9
|
-
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
10
14
|
|
|
11
15
|
interface ContextSwitcherProps {
|
|
12
16
|
className?: string
|
|
@@ -16,22 +20,10 @@ interface ParsedContext extends ParsedContextName {
|
|
|
16
20
|
context: ContextInfo
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
// Group contexts by provider, then by account
|
|
20
|
-
interface ContextGroup {
|
|
21
|
-
provider: string | null
|
|
22
|
-
account: string | null
|
|
23
|
-
items: ParsedContext[]
|
|
24
|
-
}
|
|
25
|
-
|
|
26
23
|
export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
27
|
-
const [isOpen, setIsOpen] = useState(false)
|
|
28
|
-
const [search, setSearch] = useState('')
|
|
29
|
-
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
30
24
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
31
25
|
const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
|
|
32
26
|
const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
|
|
33
|
-
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
34
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
35
27
|
|
|
36
28
|
const { data: contexts, isLoading: contextsLoading } = useContexts()
|
|
37
29
|
const { data: clusterInfo } = useClusterInfo()
|
|
@@ -40,178 +32,45 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
40
32
|
const { showError } = useToast()
|
|
41
33
|
const { tabs } = useDock()
|
|
42
34
|
|
|
43
|
-
// Parse
|
|
44
|
-
const {
|
|
45
|
-
if (!contexts) return {
|
|
46
|
-
|
|
47
|
-
// Parse all contexts
|
|
48
|
-
const parsed: ParsedContext[] = contexts.map(ctx => ({
|
|
49
|
-
context: ctx,
|
|
50
|
-
...parseContextName(ctx.name),
|
|
51
|
-
}))
|
|
52
|
-
|
|
53
|
-
// Check if we have multiple accounts (to decide whether to show group headers)
|
|
35
|
+
// Parse contexts and decide whether to render group headers (multi-account only).
|
|
36
|
+
const { parsedById, hasMultipleAccounts } = useMemo(() => {
|
|
37
|
+
if (!contexts) return { parsedById: new Map<string, ParsedContext>(), hasMultipleAccounts: false }
|
|
38
|
+
const parsed: ParsedContext[] = contexts.map(ctx => ({ context: ctx, ...parseContextName(ctx.name) }))
|
|
54
39
|
const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const groupMap = new Map<string, ContextGroup>()
|
|
59
|
-
for (const p of parsed) {
|
|
60
|
-
const key = `${p.provider || 'other'}:${p.account || 'default'}`
|
|
61
|
-
if (!groupMap.has(key)) {
|
|
62
|
-
groupMap.set(key, { provider: p.provider, account: p.account, items: [] })
|
|
63
|
-
}
|
|
64
|
-
groupMap.get(key)!.items.push(p)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Sort groups: GKE first, then EKS, then AKS, then Other
|
|
68
|
-
// Within provider, sort by account name
|
|
69
|
-
const providerOrder: Record<string, number> = { 'GKE': 0, 'EKS': 1, 'AKS': 2 }
|
|
70
|
-
const groups = Array.from(groupMap.values()).sort((a, b) => {
|
|
71
|
-
const orderA = providerOrder[a.provider || ''] ?? 3
|
|
72
|
-
const orderB = providerOrder[b.provider || ''] ?? 3
|
|
73
|
-
if (orderA !== orderB) return orderA - orderB
|
|
74
|
-
return (a.account || '').localeCompare(b.account || '')
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
// Sort items within each group by cluster name
|
|
78
|
-
for (const group of groups) {
|
|
79
|
-
group.items.sort((a, b) => a.clusterName.localeCompare(b.clusterName))
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return { groups, hasMultipleAccounts }
|
|
40
|
+
const byId = new Map<string, ParsedContext>()
|
|
41
|
+
for (const p of parsed) byId.set(p.context.name, p)
|
|
42
|
+
return { parsedById: byId, hasMultipleAccounts: accounts.size > 1 }
|
|
83
43
|
}, [contexts])
|
|
84
44
|
|
|
85
|
-
//
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Reset search and highlight when dropdown opens/closes
|
|
112
|
-
useEffect(() => {
|
|
113
|
-
if (isOpen) {
|
|
114
|
-
setSearch('')
|
|
115
|
-
setHighlightedIndex(-1)
|
|
116
|
-
requestAnimationFrame(() => {
|
|
117
|
-
searchInputRef.current?.focus()
|
|
118
|
-
})
|
|
119
|
-
}
|
|
120
|
-
}, [isOpen])
|
|
121
|
-
|
|
122
|
-
// Reset highlighted index when filtered results change
|
|
123
|
-
useEffect(() => {
|
|
124
|
-
setHighlightedIndex(-1)
|
|
125
|
-
}, [search])
|
|
126
|
-
|
|
127
|
-
// Keyboard navigation for search
|
|
128
|
-
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
129
|
-
switch (e.key) {
|
|
130
|
-
case 'ArrowDown':
|
|
131
|
-
e.preventDefault()
|
|
132
|
-
setHighlightedIndex(prev => (prev < flatItems.length - 1 ? prev + 1 : prev))
|
|
133
|
-
break
|
|
134
|
-
case 'ArrowUp':
|
|
135
|
-
e.preventDefault()
|
|
136
|
-
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : 0))
|
|
137
|
-
break
|
|
138
|
-
case 'Enter':
|
|
139
|
-
e.preventDefault()
|
|
140
|
-
if (highlightedIndex >= 0 && flatItems[highlightedIndex]) {
|
|
141
|
-
handleContextSwitch(flatItems[highlightedIndex])
|
|
142
|
-
} else if (flatItems.length > 0) {
|
|
143
|
-
setHighlightedIndex(0)
|
|
144
|
-
}
|
|
145
|
-
break
|
|
146
|
-
case 'Escape':
|
|
147
|
-
e.preventDefault()
|
|
148
|
-
setIsOpen(false)
|
|
149
|
-
break
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Scroll highlighted item into view
|
|
154
|
-
useEffect(() => {
|
|
155
|
-
if (!isOpen || highlightedIndex < 0 || !dropdownRef.current) return
|
|
156
|
-
const highlighted = dropdownRef.current.querySelector('[data-highlighted="true"]')
|
|
157
|
-
if (highlighted) {
|
|
158
|
-
highlighted.scrollIntoView({ block: 'nearest' })
|
|
159
|
-
}
|
|
160
|
-
}, [highlightedIndex, isOpen])
|
|
161
|
-
|
|
162
|
-
// Close dropdown when clicking outside
|
|
163
|
-
useEffect(() => {
|
|
164
|
-
function handleClickOutside(event: MouseEvent) {
|
|
165
|
-
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
166
|
-
setIsOpen(false)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
document.addEventListener('mousedown', handleClickOutside)
|
|
171
|
-
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
172
|
-
}, [])
|
|
173
|
-
|
|
174
|
-
// Close dropdown on escape
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
function handleEscape(event: KeyboardEvent) {
|
|
177
|
-
if (event.key === 'Escape') {
|
|
178
|
-
setIsOpen(false)
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
document.addEventListener('keydown', handleEscape)
|
|
183
|
-
return () => document.removeEventListener('keydown', handleEscape)
|
|
184
|
-
}, [])
|
|
185
|
-
|
|
186
|
-
// Check for active sessions and show confirmation if needed
|
|
187
|
-
const handleContextSwitch = async (parsed: ParsedContext) => {
|
|
188
|
-
if (parsed.context.isCurrent || switchContext.isPending) return
|
|
189
|
-
|
|
190
|
-
setIsOpen(false)
|
|
191
|
-
|
|
192
|
-
// Check for active sessions (port forwards from API + terminal tabs from dock)
|
|
193
|
-
try {
|
|
194
|
-
const counts = await fetchSessionCounts()
|
|
195
|
-
const terminalTabs = tabs.filter(t => t.type === 'terminal').length
|
|
196
|
-
const totalSessions = counts.portForwards + terminalTabs
|
|
197
|
-
|
|
198
|
-
if (totalSessions > 0) {
|
|
199
|
-
// Show confirmation dialog
|
|
200
|
-
setSessionCounts({ ...counts, execSessions: terminalTabs, total: totalSessions })
|
|
201
|
-
setPendingSwitch(parsed)
|
|
202
|
-
setShowConfirm(true)
|
|
203
|
-
return
|
|
45
|
+
// Map parsed contexts → generic ClusterSwitcher items, sorted GKE/EKS/AKS/Other → account → name.
|
|
46
|
+
const items = useMemo<ClusterSwitcherItem[]>(() => {
|
|
47
|
+
const order: Record<string, number> = { GKE: 0, EKS: 1, AKS: 2 }
|
|
48
|
+
const arr = Array.from(parsedById.values())
|
|
49
|
+
arr.sort((a, b) => {
|
|
50
|
+
const oa = order[a.provider || ''] ?? 3
|
|
51
|
+
const ob = order[b.provider || ''] ?? 3
|
|
52
|
+
if (oa !== ob) return oa - ob
|
|
53
|
+
const acc = (a.account || '').localeCompare(b.account || '')
|
|
54
|
+
if (acc !== 0) return acc
|
|
55
|
+
return a.clusterName.localeCompare(b.clusterName)
|
|
56
|
+
})
|
|
57
|
+
return arr.map(p => {
|
|
58
|
+
const groupKey = `${p.provider || 'other'}:${p.account || 'default'}`
|
|
59
|
+
const groupLabel = hasMultipleAccounts && p.provider
|
|
60
|
+
? `${p.provider}${p.account ? ` · ${p.account}` : ''}`
|
|
61
|
+
: hasMultipleAccounts
|
|
62
|
+
? 'Other'
|
|
63
|
+
: undefined
|
|
64
|
+
return {
|
|
65
|
+
id: p.context.name,
|
|
66
|
+
name: p.clusterName,
|
|
67
|
+
secondary: p.provider ? p.raw : undefined,
|
|
68
|
+
badge: p.region || undefined,
|
|
69
|
+
group: { key: groupKey, label: groupLabel },
|
|
204
70
|
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Continue with switch even if check fails
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// No active sessions, proceed with switch
|
|
211
|
-
performSwitch(parsed)
|
|
212
|
-
}
|
|
71
|
+
})
|
|
72
|
+
}, [parsedById, hasMultipleAccounts])
|
|
213
73
|
|
|
214
|
-
// Actually perform the context switch
|
|
215
74
|
const performSwitch = async (parsed: ParsedContext) => {
|
|
216
75
|
startSwitch({
|
|
217
76
|
raw: parsed.raw,
|
|
@@ -222,21 +81,45 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
222
81
|
})
|
|
223
82
|
try {
|
|
224
83
|
await switchContext.mutateAsync({ name: parsed.context.name })
|
|
225
|
-
// Success - endSwitch is called by the overlay when it detects success
|
|
226
84
|
} catch (error) {
|
|
227
85
|
console.error('Failed to switch context:', error)
|
|
228
86
|
endSwitch()
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// But if the request never reached the backend (network error,
|
|
232
|
-
// client timeout), connection.state stays 'connected' and the
|
|
233
|
-
// toast is the only error feedback the user gets.
|
|
87
|
+
// Backend may not transition to StateDisconnected on client-side errors
|
|
88
|
+
// (network, timeout) — without this toast the user gets no feedback.
|
|
234
89
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
235
90
|
showError('Failed to switch context', message)
|
|
236
91
|
}
|
|
237
92
|
}
|
|
238
93
|
|
|
239
|
-
|
|
94
|
+
const handleSelect = async (item: ClusterSwitcherItem) => {
|
|
95
|
+
const parsed = parsedById.get(item.id)
|
|
96
|
+
if (!parsed || parsed.context.isCurrent || switchContext.isPending) return
|
|
97
|
+
|
|
98
|
+
// Active sessions (port forwards from API + terminal tabs from dock) get
|
|
99
|
+
// a confirmation prompt — switching contexts kills both.
|
|
100
|
+
try {
|
|
101
|
+
const counts = await fetchSessionCounts()
|
|
102
|
+
const terminalTabs = tabs.filter(t => t.type === 'terminal').length
|
|
103
|
+
const total = counts.portForwards + terminalTabs
|
|
104
|
+
if (total > 0) {
|
|
105
|
+
setSessionCounts({ ...counts, execSessions: terminalTabs, total })
|
|
106
|
+
setPendingSwitch(parsed)
|
|
107
|
+
setShowConfirm(true)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Session-counts is best-effort; failing it shouldn't block the user.
|
|
112
|
+
// But warn — if there ARE active sessions we couldn't see, the switch
|
|
113
|
+
// will silently kill them.
|
|
114
|
+
console.error('Failed to check sessions:', error)
|
|
115
|
+
showError(
|
|
116
|
+
'Could not check active sessions',
|
|
117
|
+
'Switching anyway. Any open port-forwards or terminals will be terminated.',
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
performSwitch(parsed)
|
|
121
|
+
}
|
|
122
|
+
|
|
240
123
|
const handleConfirmSwitch = () => {
|
|
241
124
|
setShowConfirm(false)
|
|
242
125
|
if (pendingSwitch) {
|
|
@@ -251,15 +134,9 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
251
134
|
setSessionCounts(null)
|
|
252
135
|
}
|
|
253
136
|
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
const currentParsed = useMemo(() => parseContextName(currentContextRaw), [currentContextRaw])
|
|
257
|
-
const currentDisplayName = currentParsed.clusterName
|
|
258
|
-
|
|
259
|
-
// Check if in-cluster mode (only one context named "in-cluster")
|
|
137
|
+
// In-cluster mode renders a static badge instead of a switcher (only one
|
|
138
|
+
// synthetic context, no kubeconfig to choose from).
|
|
260
139
|
const isInClusterMode = contexts?.length === 1 && contexts[0].name === 'in-cluster'
|
|
261
|
-
|
|
262
|
-
// If in-cluster mode, just show a static badge
|
|
263
140
|
if (isInClusterMode) {
|
|
264
141
|
return (
|
|
265
142
|
<div className={`flex items-center gap-2 ${className}`}>
|
|
@@ -270,166 +147,31 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
270
147
|
)
|
|
271
148
|
}
|
|
272
149
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
<button
|
|
277
|
-
onClick={() => setIsOpen(!isOpen)}
|
|
278
|
-
disabled={switchContext.isPending || contextsLoading}
|
|
279
|
-
className={`
|
|
280
|
-
flex items-center gap-1.5 px-2.5 py-1.5
|
|
281
|
-
bg-theme-elevated border border-theme-border rounded text-sm font-medium
|
|
282
|
-
text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
|
|
283
|
-
transition-colors cursor-pointer
|
|
284
|
-
disabled:opacity-50 disabled:cursor-not-allowed
|
|
285
|
-
`}
|
|
286
|
-
title={currentContextRaw}
|
|
287
|
-
>
|
|
288
|
-
{switchContext.isPending ? (
|
|
289
|
-
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
290
|
-
) : (
|
|
291
|
-
<Server className="w-3.5 h-3.5 text-theme-text-secondary" />
|
|
292
|
-
)}
|
|
293
|
-
<span className="max-w-[120px] sm:max-w-[220px] truncate">
|
|
294
|
-
{switchContext.isPending ? 'Switching...' : currentDisplayName}
|
|
295
|
-
</span>
|
|
296
|
-
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
297
|
-
</button>
|
|
298
|
-
|
|
299
|
-
{/* Dropdown menu */}
|
|
300
|
-
{isOpen && !contextsLoading && contexts && (
|
|
301
|
-
<div className="absolute top-full left-0 mt-1 z-50 min-w-[280px] max-w-[420px] bg-theme-surface border border-theme-border-light rounded-lg shadow-xl overflow-hidden">
|
|
302
|
-
{/* Search input */}
|
|
303
|
-
{contexts.length > 1 && (
|
|
304
|
-
<div className="p-2 border-b border-theme-border">
|
|
305
|
-
<div className="relative">
|
|
306
|
-
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
307
|
-
<input
|
|
308
|
-
ref={searchInputRef}
|
|
309
|
-
type="text"
|
|
310
|
-
value={search}
|
|
311
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
312
|
-
onKeyDown={handleSearchKeyDown}
|
|
313
|
-
placeholder="Search clusters..."
|
|
314
|
-
className="w-full bg-theme-base text-theme-text-primary text-xs rounded px-2 py-1.5 pl-7 pr-7 border border-theme-border-light focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-theme-text-tertiary"
|
|
315
|
-
/>
|
|
316
|
-
{search && (
|
|
317
|
-
<button
|
|
318
|
-
type="button"
|
|
319
|
-
onClick={() => setSearch('')}
|
|
320
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-secondary"
|
|
321
|
-
>
|
|
322
|
-
<X className="w-3.5 h-3.5" />
|
|
323
|
-
</button>
|
|
324
|
-
)}
|
|
325
|
-
</div>
|
|
326
|
-
</div>
|
|
327
|
-
)}
|
|
328
|
-
|
|
329
|
-
<div className="max-h-[400px] overflow-y-auto">
|
|
330
|
-
{flatItems.length === 0 ? (
|
|
331
|
-
<div className="px-3 py-6 text-center text-xs text-theme-text-tertiary">
|
|
332
|
-
No clusters match "{search}"
|
|
333
|
-
</div>
|
|
334
|
-
) : (
|
|
335
|
-
filteredGroups.map((group, groupIndex) => {
|
|
336
|
-
const showHeader = hasMultipleAccounts
|
|
337
|
-
const headerLabel = group.provider
|
|
338
|
-
? `${group.provider}${group.account ? ` · ${group.account}` : ''}`
|
|
339
|
-
: 'Other'
|
|
150
|
+
const currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
|
|
151
|
+
const currentParsed = parseContextName(currentRaw)
|
|
152
|
+
const currentId = contexts?.find(c => c.isCurrent)?.name
|
|
340
153
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
disabled={item.context.isCurrent || switchContext.isPending}
|
|
362
|
-
className={`
|
|
363
|
-
w-full flex items-center gap-2 px-3 py-2 text-left
|
|
364
|
-
transition-colors
|
|
365
|
-
${item.context.isCurrent
|
|
366
|
-
? 'bg-blue-500/10'
|
|
367
|
-
: itemIndex === highlightedIndex
|
|
368
|
-
? 'bg-theme-hover cursor-pointer'
|
|
369
|
-
: 'hover:bg-theme-hover cursor-pointer'
|
|
370
|
-
}
|
|
371
|
-
disabled:opacity-50
|
|
372
|
-
`}
|
|
373
|
-
>
|
|
374
|
-
<div className="shrink-0 w-4 h-4 flex items-center justify-center">
|
|
375
|
-
{item.context.isCurrent ? (
|
|
376
|
-
<Check className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
|
377
|
-
) : (
|
|
378
|
-
<div className="w-1.5 h-1.5 rounded-full bg-theme-text-tertiary/30" />
|
|
379
|
-
)}
|
|
380
|
-
</div>
|
|
381
|
-
<div className="flex-1 min-w-0">
|
|
382
|
-
<div className="flex items-center gap-1.5">
|
|
383
|
-
<span className={`text-sm font-medium truncate ${item.context.isCurrent ? 'text-blue-600 dark:text-blue-400' : 'text-theme-text-primary'}`}>
|
|
384
|
-
{item.clusterName}
|
|
385
|
-
</span>
|
|
386
|
-
{item.context.isCurrent && (
|
|
387
|
-
<span className="shrink-0 text-[9px] text-blue-600 dark:text-blue-400">
|
|
388
|
-
●
|
|
389
|
-
</span>
|
|
390
|
-
)}
|
|
391
|
-
{item.region && (
|
|
392
|
-
<span className="shrink-0 ml-auto text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
|
|
393
|
-
{item.region}
|
|
394
|
-
</span>
|
|
395
|
-
)}
|
|
396
|
-
</div>
|
|
397
|
-
{item.provider && (
|
|
398
|
-
<div className="text-[10px] text-theme-text-tertiary opacity-70 truncate mt-0.5" title={item.raw}>
|
|
399
|
-
{item.raw}
|
|
400
|
-
</div>
|
|
401
|
-
)}
|
|
402
|
-
</div>
|
|
403
|
-
</button>
|
|
404
|
-
)
|
|
405
|
-
})}
|
|
406
|
-
</div>
|
|
407
|
-
)
|
|
408
|
-
})
|
|
409
|
-
)}
|
|
410
|
-
</div>
|
|
411
|
-
|
|
412
|
-
{/* Footer with count */}
|
|
413
|
-
{contexts.length > 1 && search && flatItems.length > 0 && (
|
|
414
|
-
<div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base">
|
|
415
|
-
{flatItems.length === contexts.length
|
|
416
|
-
? `${contexts.length} clusters`
|
|
417
|
-
: `${flatItems.length} of ${contexts.length} clusters`}
|
|
418
|
-
</div>
|
|
419
|
-
)}
|
|
420
|
-
|
|
421
|
-
{/* Error message if switch failed */}
|
|
422
|
-
{switchContext.isError && (
|
|
423
|
-
<div className="px-3 py-2 bg-red-500/10 border-t border-red-500/20">
|
|
424
|
-
<span className="text-xs text-red-400">
|
|
425
|
-
{switchContext.error?.message}
|
|
426
|
-
</span>
|
|
427
|
-
</div>
|
|
428
|
-
)}
|
|
429
|
-
</div>
|
|
430
|
-
)}
|
|
154
|
+
return (
|
|
155
|
+
<>
|
|
156
|
+
<ClusterSwitcher
|
|
157
|
+
className={className}
|
|
158
|
+
currentId={currentId}
|
|
159
|
+
currentName={currentParsed.clusterName}
|
|
160
|
+
currentTooltip={currentRaw}
|
|
161
|
+
triggerIcon={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
|
|
162
|
+
items={items}
|
|
163
|
+
onSelect={handleSelect}
|
|
164
|
+
loading={switchContext.isPending}
|
|
165
|
+
disabled={contextsLoading}
|
|
166
|
+
searchable={items.length > 1}
|
|
167
|
+
showGroupHeaders={hasMultipleAccounts}
|
|
168
|
+
errorSlot={
|
|
169
|
+
switchContext.isError ? (
|
|
170
|
+
<span className="text-xs text-red-400">{switchContext.error?.message}</span>
|
|
171
|
+
) : undefined
|
|
172
|
+
}
|
|
173
|
+
/>
|
|
431
174
|
|
|
432
|
-
{/* Confirmation modal */}
|
|
433
175
|
{showConfirm && sessionCounts && pendingSwitch && (
|
|
434
176
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
|
|
435
177
|
<div className="bg-theme-surface border border-theme-border rounded-lg shadow-xl max-w-md mx-4 overflow-hidden">
|
|
@@ -476,7 +218,6 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
476
218
|
</div>
|
|
477
219
|
</div>
|
|
478
220
|
)}
|
|
479
|
-
|
|
480
|
-
</div>
|
|
221
|
+
</>
|
|
481
222
|
)
|
|
482
223
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react'
|
|
2
2
|
import { useAudit, useAuditSettings, useUpdateAuditSettings } from '../../api/client'
|
|
3
3
|
import type { SelectedResource } from '../../types'
|
|
4
|
-
import { AuditFindingsTable } from '@skyhook-io/k8s-ui'
|
|
5
|
-
import { ArrowLeft, ClipboardCheck,
|
|
4
|
+
import { AuditFindingsTable, PaneLoader } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { ArrowLeft, ClipboardCheck, Settings } from 'lucide-react'
|
|
6
6
|
import { AuditSettingsDialog } from './AuditSettingsDialog'
|
|
7
7
|
|
|
8
8
|
interface AuditViewProps {
|
|
@@ -47,14 +47,7 @@ export function AuditView({ namespaces, onBack, onNavigateToResource }: AuditVie
|
|
|
47
47
|
}, [auditSettings, updateSettings])
|
|
48
48
|
|
|
49
49
|
if (isLoading) {
|
|
50
|
-
return
|
|
51
|
-
<div className="flex-1 flex items-center justify-center">
|
|
52
|
-
<div className="flex flex-col items-center gap-3">
|
|
53
|
-
<Loader2 className="w-6 h-6 animate-spin text-theme-text-tertiary" />
|
|
54
|
-
<span className="text-sm text-theme-text-tertiary">Loading audit data...</span>
|
|
55
|
-
</div>
|
|
56
|
-
</div>
|
|
57
|
-
)
|
|
50
|
+
return <PaneLoader label="Loading audit data…" className="flex-1" />
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
if (error) {
|
|
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|
|
2
2
|
import { useOpenCostSummary, useOpenCostWorkloads, useOpenCostNodes } from '../../api/client'
|
|
3
3
|
import type { OpenCostNamespaceCost, OpenCostWorkloadCost, OpenCostNodeCost } from '../../api/client'
|
|
4
4
|
import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, HelpCircle, Loader2, Server, X } from 'lucide-react'
|
|
5
|
+
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
5
6
|
import { CostTrendChart } from './CostTrendChart'
|
|
6
7
|
|
|
7
8
|
interface CostViewProps {
|
|
@@ -14,14 +15,7 @@ export function CostView({ onBack }: CostViewProps) {
|
|
|
14
15
|
const [showHelp, setShowHelp] = useState(false)
|
|
15
16
|
|
|
16
17
|
if (isLoading) {
|
|
17
|
-
return
|
|
18
|
-
<div className="flex-1 flex items-center justify-center">
|
|
19
|
-
<div className="flex flex-col items-center gap-3">
|
|
20
|
-
<Loader2 className="w-6 h-6 animate-spin text-theme-text-tertiary" />
|
|
21
|
-
<span className="text-sm text-theme-text-tertiary">Loading cost data...</span>
|
|
22
|
-
</div>
|
|
23
|
-
</div>
|
|
24
|
-
)
|
|
18
|
+
return <PaneLoader label="Loading cost data…" className="flex-1" />
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
if (!data || !data.available) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
2
|
import { Search, RefreshCw, Package, Database, AlertCircle, ExternalLink, ChevronDown, Star, Shield, BadgeCheck, Building2, Globe, ArrowUpDown, FileJson, PenTool } from 'lucide-react'
|
|
3
|
+
import { PaneLoader } from '@skyhook-io/k8s-ui'
|
|
3
4
|
import { clsx } from 'clsx'
|
|
4
5
|
import { useHelmRepositories, useSearchCharts, useUpdateRepository, useArtifactHubSearch, type ArtifactHubSortOption } from '../../api/client'
|
|
5
6
|
import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
|
|
@@ -23,7 +24,13 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
23
24
|
const [showVerifiedOnly, setShowVerifiedOnly] = useState(false)
|
|
24
25
|
const [artifactHubSort, setArtifactHubSort] = useState<ArtifactHubSortOption>('relevance')
|
|
25
26
|
|
|
27
|
+
// Repo refresh is gated only by `requireHelmWrite` on the backend
|
|
28
|
+
// (handleUpdateRepository deliberately skips requireCloudRole — it
|
|
29
|
+
// mutates pod-local chart cache, not cluster state). So the SPA gate
|
|
30
|
+
// here must NOT include the Cloud role check, or Cloud viewers with
|
|
31
|
+
// rbac.helm=true would be blocked from a refresh the backend allows.
|
|
26
32
|
const canHelmWrite = useCanHelmWrite()
|
|
33
|
+
const helmWriteReason = canHelmWrite ? '' : 'Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.'
|
|
27
34
|
|
|
28
35
|
// Local repo hooks
|
|
29
36
|
const { data: repositories, isLoading: reposLoading } = useHelmRepositories()
|
|
@@ -162,6 +169,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
162
169
|
onUpdate={() => handleUpdateRepo(repo.name)}
|
|
163
170
|
isUpdating={updateRepoMutation.isPending}
|
|
164
171
|
canUpdate={canHelmWrite}
|
|
172
|
+
cantUpdateReason={helmWriteReason}
|
|
165
173
|
/>
|
|
166
174
|
))
|
|
167
175
|
)}
|
|
@@ -235,7 +243,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
235
243
|
</label>
|
|
236
244
|
|
|
237
245
|
{/* Refresh button */}
|
|
238
|
-
<Tooltip content={canHelmWrite ? "Update all repositories" :
|
|
246
|
+
<Tooltip content={canHelmWrite ? "Update all repositories" : helmWriteReason}>
|
|
239
247
|
<button
|
|
240
248
|
onClick={handleUpdateAllRepos}
|
|
241
249
|
disabled={updateRepoMutation.isPending || !canHelmWrite}
|
|
@@ -251,9 +259,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
251
259
|
{/* Chart grid */}
|
|
252
260
|
<div className="flex-1 overflow-auto p-4">
|
|
253
261
|
{isLoading ? (
|
|
254
|
-
<
|
|
255
|
-
Loading charts...
|
|
256
|
-
</div>
|
|
262
|
+
<PaneLoader label="Loading charts…" className="h-32" />
|
|
257
263
|
) : chartSource === 'local' ? (
|
|
258
264
|
// Local charts view
|
|
259
265
|
filteredLocalCharts.length === 0 ? (
|
|
@@ -356,9 +362,13 @@ interface RepoDropdownItemProps {
|
|
|
356
362
|
onUpdate: () => void
|
|
357
363
|
isUpdating: boolean
|
|
358
364
|
canUpdate: boolean
|
|
365
|
+
/** Reason rendered in the disabled button's tooltip when canUpdate
|
|
366
|
+
* is false — only the rbac.helm capability is relevant here, since
|
|
367
|
+
* repo refresh is not Cloud-role-gated on the backend. */
|
|
368
|
+
cantUpdateReason?: string
|
|
359
369
|
}
|
|
360
370
|
|
|
361
|
-
function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, canUpdate }: RepoDropdownItemProps) {
|
|
371
|
+
function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, canUpdate, cantUpdateReason }: RepoDropdownItemProps) {
|
|
362
372
|
return (
|
|
363
373
|
<div className="flex items-center justify-between px-3 py-2 hover:bg-theme-hover group">
|
|
364
374
|
<button
|
|
@@ -379,7 +389,7 @@ function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, ca
|
|
|
379
389
|
onClick={(e) => { e.stopPropagation(); onUpdate() }}
|
|
380
390
|
disabled={isUpdating || !canUpdate}
|
|
381
391
|
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
|
382
|
-
title={canUpdate ? "Update repository" : "Helm write permissions required
|
|
392
|
+
title={canUpdate ? "Update repository" : (cantUpdateReason ?? "Helm write permissions required")}
|
|
383
393
|
>
|
|
384
394
|
<RefreshCw className={clsx('w-3.5 h-3.5', isUpdating && 'animate-spin')} />
|
|
385
395
|
</button>
|