@skyhook-io/radar-app 0.1.6 → 0.2.0
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 +93 -27
- package/src/api/client.ts +168 -24
- package/src/components/ContextSwitcher.tsx +100 -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/resources/ResourcesView.tsx +1 -0
- 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/components/workload/WorkloadView.tsx +16 -0
- package/src/index.ts +6 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +0 -31
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { 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,50 @@ 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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
// `name` is the raw context — ClusterSwitcher renders it through
|
|
65
|
+
// ClusterName, which collapses GKE/EKS/AKS shapes to the meaningful
|
|
66
|
+
// tail. `secondary` shows the original raw when we collapsed it,
|
|
67
|
+
// so users always see the full context at a glance (rather than
|
|
68
|
+
// having to hover to reveal it).
|
|
69
|
+
return {
|
|
70
|
+
id: p.context.name,
|
|
71
|
+
name: p.context.name,
|
|
72
|
+
secondary: p.provider ? p.raw : undefined,
|
|
73
|
+
badge: p.region || undefined,
|
|
74
|
+
group: { key: groupKey, label: groupLabel },
|
|
204
75
|
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Continue with switch even if check fails
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// No active sessions, proceed with switch
|
|
211
|
-
performSwitch(parsed)
|
|
212
|
-
}
|
|
76
|
+
})
|
|
77
|
+
}, [parsedById, hasMultipleAccounts])
|
|
213
78
|
|
|
214
|
-
// Actually perform the context switch
|
|
215
79
|
const performSwitch = async (parsed: ParsedContext) => {
|
|
216
80
|
startSwitch({
|
|
217
81
|
raw: parsed.raw,
|
|
@@ -222,21 +86,45 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
222
86
|
})
|
|
223
87
|
try {
|
|
224
88
|
await switchContext.mutateAsync({ name: parsed.context.name })
|
|
225
|
-
// Success - endSwitch is called by the overlay when it detects success
|
|
226
89
|
} catch (error) {
|
|
227
90
|
console.error('Failed to switch context:', error)
|
|
228
91
|
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.
|
|
92
|
+
// Backend may not transition to StateDisconnected on client-side errors
|
|
93
|
+
// (network, timeout) — without this toast the user gets no feedback.
|
|
234
94
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
235
95
|
showError('Failed to switch context', message)
|
|
236
96
|
}
|
|
237
97
|
}
|
|
238
98
|
|
|
239
|
-
|
|
99
|
+
const handleSelect = async (item: ClusterSwitcherItem) => {
|
|
100
|
+
const parsed = parsedById.get(item.id)
|
|
101
|
+
if (!parsed || parsed.context.isCurrent || switchContext.isPending) return
|
|
102
|
+
|
|
103
|
+
// Active sessions (port forwards from API + terminal tabs from dock) get
|
|
104
|
+
// a confirmation prompt — switching contexts kills both.
|
|
105
|
+
try {
|
|
106
|
+
const counts = await fetchSessionCounts()
|
|
107
|
+
const terminalTabs = tabs.filter(t => t.type === 'terminal').length
|
|
108
|
+
const total = counts.portForwards + terminalTabs
|
|
109
|
+
if (total > 0) {
|
|
110
|
+
setSessionCounts({ ...counts, execSessions: terminalTabs, total })
|
|
111
|
+
setPendingSwitch(parsed)
|
|
112
|
+
setShowConfirm(true)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Session-counts is best-effort; failing it shouldn't block the user.
|
|
117
|
+
// But warn — if there ARE active sessions we couldn't see, the switch
|
|
118
|
+
// will silently kill them.
|
|
119
|
+
console.error('Failed to check sessions:', error)
|
|
120
|
+
showError(
|
|
121
|
+
'Could not check active sessions',
|
|
122
|
+
'Switching anyway. Any open port-forwards or terminals will be terminated.',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
performSwitch(parsed)
|
|
126
|
+
}
|
|
127
|
+
|
|
240
128
|
const handleConfirmSwitch = () => {
|
|
241
129
|
setShowConfirm(false)
|
|
242
130
|
if (pendingSwitch) {
|
|
@@ -251,15 +139,9 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
251
139
|
setSessionCounts(null)
|
|
252
140
|
}
|
|
253
141
|
|
|
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")
|
|
142
|
+
// In-cluster mode renders a static badge instead of a switcher (only one
|
|
143
|
+
// synthetic context, no kubeconfig to choose from).
|
|
260
144
|
const isInClusterMode = contexts?.length === 1 && contexts[0].name === 'in-cluster'
|
|
261
|
-
|
|
262
|
-
// If in-cluster mode, just show a static badge
|
|
263
145
|
if (isInClusterMode) {
|
|
264
146
|
return (
|
|
265
147
|
<div className={`flex items-center gap-2 ${className}`}>
|
|
@@ -270,166 +152,28 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
270
152
|
)
|
|
271
153
|
}
|
|
272
154
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
{/* Trigger button */}
|
|
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'
|
|
155
|
+
const currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
|
|
156
|
+
const currentId = contexts?.find(c => c.isCurrent)?.name
|
|
340
157
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
onClick={() => handleContextSwitch(item)}
|
|
360
|
-
onMouseEnter={() => setHighlightedIndex(itemIndex)}
|
|
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
|
-
)}
|
|
158
|
+
return (
|
|
159
|
+
<>
|
|
160
|
+
<ClusterSwitcher
|
|
161
|
+
className={className}
|
|
162
|
+
currentId={currentId}
|
|
163
|
+
currentName={currentRaw}
|
|
164
|
+
items={items}
|
|
165
|
+
onSelect={handleSelect}
|
|
166
|
+
loading={switchContext.isPending}
|
|
167
|
+
disabled={contextsLoading}
|
|
168
|
+
searchable={items.length > 1}
|
|
169
|
+
showGroupHeaders={hasMultipleAccounts}
|
|
170
|
+
errorSlot={
|
|
171
|
+
switchContext.isError ? (
|
|
172
|
+
<span className="text-xs text-red-400">{switchContext.error?.message}</span>
|
|
173
|
+
) : undefined
|
|
174
|
+
}
|
|
175
|
+
/>
|
|
431
176
|
|
|
432
|
-
{/* Confirmation modal */}
|
|
433
177
|
{showConfirm && sessionCounts && pendingSwitch && (
|
|
434
178
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
|
|
435
179
|
<div className="bg-theme-surface border border-theme-border rounded-lg shadow-xl max-w-md mx-4 overflow-hidden">
|
|
@@ -476,7 +220,6 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
476
220
|
</div>
|
|
477
221
|
</div>
|
|
478
222
|
)}
|
|
479
|
-
|
|
480
|
-
</div>
|
|
223
|
+
</>
|
|
481
224
|
)
|
|
482
225
|
}
|
|
@@ -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>
|