@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.
@@ -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 { 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, 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
+ // `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
- } 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
- }
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
- // 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.
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
- // Handle confirmation dialog actions
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
- // 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")
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
- 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'
155
+ const currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
156
+ const currentId = contexts?.find(c => c.isCurrent)?.name
340
157
 
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
- )}
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, 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>