@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.
@@ -1,12 +1,16 @@
1
- import { useState, useRef, useEffect, useMemo } from 'react'
2
- import { ChevronDown, Check, Loader2, Server, AlertTriangle, Search, X } from 'lucide-react'
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, group, and sort contexts
44
- const { groups, hasMultipleAccounts } = useMemo(() => {
45
- if (!contexts) return { groups: [], hasMultipleProviders: false, hasMultipleAccounts: false }
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 hasMultipleAccounts = accounts.size > 1
56
-
57
- // Group by provider + account
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
- // Filter groups by search query
86
- const { filteredGroups, flatItems, itemIndexMap } = useMemo(() => {
87
- const filteredGroups = search.trim()
88
- ? groups
89
- .map(group => ({
90
- ...group,
91
- items: group.items.filter(item => {
92
- const searchLower = search.toLowerCase()
93
- return (
94
- item.clusterName.toLowerCase().includes(searchLower) ||
95
- item.raw.toLowerCase().includes(searchLower) ||
96
- (item.region && item.region.toLowerCase().includes(searchLower)) ||
97
- (item.account && item.account.toLowerCase().includes(searchLower))
98
- )
99
- }),
100
- }))
101
- .filter(group => group.items.length > 0)
102
- : groups
103
-
104
- const flatItems = filteredGroups.flatMap(g => g.items)
105
- const itemIndexMap = new Map<string, number>()
106
- flatItems.forEach((item, i) => itemIndexMap.set(item.context.name, i))
107
-
108
- return { filteredGroups, flatItems, itemIndexMap }
109
- }, [groups, search])
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
- } catch (error) {
206
- console.error('Failed to check sessions:', error)
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
- // Show toast as fallback if the backend set StateDisconnected,
230
- // ConnectionErrorView will render with provider-specific hints.
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
- // Handle confirmation dialog actions
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
- // Get current context info - parse it to extract cluster name
255
- const currentContextRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
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
- return (
274
- <div className={`relative ${className}`} ref={dropdownRef}>
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'
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
- return (
342
- <div key={`${group.provider}:${group.account}`}>
343
- {groupIndex > 0 && (
344
- <div className="border-t border-theme-border-light my-1" />
345
- )}
346
- {showHeader && (
347
- <div className="px-3 py-1 bg-theme-elevated/60 border-b border-theme-border/60">
348
- <span className="text-[11px] text-theme-text-secondary font-semibold">
349
- {headerLabel}
350
- </span>
351
- </div>
352
- )}
353
- {group.items.map((item) => {
354
- const itemIndex = itemIndexMap.get(item.context.name) ?? -1
355
- return (
356
- <button
357
- key={item.context.name}
358
- data-highlighted={itemIndex === highlightedIndex}
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
- )}
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, Loader2, Settings } from 'lucide-react'
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" : "Helm write permissions required (rbac.helm=true)"}>
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
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">
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 (rbac.helm=true)"}
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>