@skyhook-io/k8s-ui 1.5.6 → 1.5.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.5.6",
3
+ "version": "1.5.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -47,7 +47,8 @@
47
47
  "./theme/tailwind-theme.css": "./src/theme/tailwind-theme.css",
48
48
  "./theme/components.css": "./src/theme/components.css",
49
49
  "./theme": "./src/theme/index.ts",
50
- "./topology.css": "./src/components/topology/topology.css"
50
+ "./topology.css": "./src/components/topology/topology.css",
51
+ "./assets/radar/radar-icon-loading.svg": "./src/assets/radar/radar-icon-loading.svg"
51
52
  },
52
53
  "scripts": {
53
54
  "tsc": "tsc --noEmit",
@@ -0,0 +1,147 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
2
+ <defs>
3
+ <!-- Dark emerald background gradient -->
4
+ <radialGradient id="bgHub" cx="50%" cy="50%" r="50%">
5
+ <stop offset="0%" stop-color="#072920"/>
6
+ <stop offset="70%" stop-color="#03180f"/>
7
+ <stop offset="100%" stop-color="#010a06"/>
8
+ </radialGradient>
9
+
10
+ <!-- Sweep trail gradient, emerald -->
11
+ <linearGradient id="sweepGlowHub" x1="0" y1="0" x2="1" y2="0" gradientTransform="rotate(-45, 0.5, 0.5)">
12
+ <stop offset="0%" stop-color="#10b981" stop-opacity="0"/>
13
+ <stop offset="60%" stop-color="#10b981" stop-opacity="0.22"/>
14
+ <stop offset="100%" stop-color="#a7f3d0" stop-opacity="0.55"/>
15
+ </linearGradient>
16
+
17
+ <!-- Ring glow -->
18
+ <filter id="ringGlowHub" x="-20%" y="-20%" width="140%" height="140%">
19
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
20
+ <feMerge>
21
+ <feMergeNode in="blur"/>
22
+ <feMergeNode in="SourceGraphic"/>
23
+ </feMerge>
24
+ </filter>
25
+
26
+ <!-- Dot glow -->
27
+ <filter id="dotGlowHub" x="-100%" y="-100%" width="300%" height="300%">
28
+ <feGaussianBlur stdDeviation="2.5" result="blur"/>
29
+ <feMerge>
30
+ <feMergeNode in="blur"/>
31
+ <feMergeNode in="SourceGraphic"/>
32
+ </feMerge>
33
+ </filter>
34
+
35
+ <!-- Sweep line glow -->
36
+ <filter id="lineGlowHub" x="-50%" y="-50%" width="200%" height="200%">
37
+ <feGaussianBlur stdDeviation="3" result="blur"/>
38
+ <feMerge>
39
+ <feMergeNode in="blur"/>
40
+ <feMergeNode in="SourceGraphic"/>
41
+ </feMerge>
42
+ </filter>
43
+
44
+ <!-- Outer ring glow -->
45
+ <filter id="outerGlowHub" x="-10%" y="-10%" width="120%" height="120%">
46
+ <feGaussianBlur stdDeviation="4" result="blur"/>
47
+ <feMerge>
48
+ <feMergeNode in="blur"/>
49
+ <feMergeNode in="SourceGraphic"/>
50
+ </feMerge>
51
+ </filter>
52
+
53
+ <clipPath id="radarClipHub">
54
+ <circle cx="128" cy="128" r="118"/>
55
+ </clipPath>
56
+
57
+ <!--
58
+ Self-contained CSS animation. This SVG is consumed via <img>, so
59
+ neither external CSS nor JS reaches it — keyframes have to live
60
+ inside the file. Sweep arm rotates around (128,128); each blip
61
+ pings via animation-delay timed to when the arm passes its angle
62
+ (cycle = 2400ms, baseline = 45° NE). Honors prefers-reduced-motion.
63
+ -->
64
+ <style>
65
+ .sweep-arm {
66
+ transform-origin: 128px 128px;
67
+ animation: radar-sweep 2.4s linear infinite;
68
+ }
69
+ @keyframes radar-sweep {
70
+ to { transform: rotate(360deg); }
71
+ }
72
+
73
+ .blip {
74
+ transform-origin: center;
75
+ transform-box: fill-box;
76
+ animation: blip-ping 2.4s linear infinite;
77
+ }
78
+ @keyframes blip-ping {
79
+ 0% { opacity: 1; transform: scale(1.45); }
80
+ 6% { opacity: 0.95; transform: scale(1.15); }
81
+ 25% { opacity: 0.6; transform: scale(1); }
82
+ 100% { opacity: 0.55; transform: scale(1); }
83
+ }
84
+
85
+ .center-dot {
86
+ transform-origin: 128px 128px;
87
+ animation: center-pulse 2.4s ease-in-out infinite;
88
+ }
89
+ @keyframes center-pulse {
90
+ 0%, 100% { opacity: 0.75; }
91
+ 50% { opacity: 1; }
92
+ }
93
+
94
+ @media (prefers-reduced-motion: reduce) {
95
+ .sweep-arm,
96
+ .blip,
97
+ .center-dot { animation: none; }
98
+ }
99
+ </style>
100
+ </defs>
101
+
102
+ <!-- Background -->
103
+ <circle cx="128" cy="128" r="128" fill="#010a06"/>
104
+ <circle cx="128" cy="128" r="118" fill="url(#bgHub)"/>
105
+
106
+ <!-- Outer ring -->
107
+ <circle cx="128" cy="128" r="118" fill="none" stroke="#34d399" stroke-width="3.5" filter="url(#outerGlowHub)" opacity="0.9"/>
108
+
109
+ <!-- Concentric rings -->
110
+ <g filter="url(#ringGlowHub)" opacity="0.6">
111
+ <circle cx="128" cy="128" r="90" fill="none" stroke="#059669" stroke-width="1.2"/>
112
+ <circle cx="128" cy="128" r="62" fill="none" stroke="#059669" stroke-width="1.2"/>
113
+ <circle cx="128" cy="128" r="34" fill="none" stroke="#059669" stroke-width="1.2"/>
114
+ </g>
115
+
116
+ <!-- Crosshairs -->
117
+ <g opacity="0.15" stroke="#34d399" stroke-width="0.5">
118
+ <line x1="128" y1="10" x2="128" y2="246"/>
119
+ <line x1="10" y1="128" x2="246" y2="128"/>
120
+ </g>
121
+
122
+ <!-- Radar blips. animation-delay = -((360 - (angle - 45)) mod 360) / 360 * 2400ms,
123
+ so the 0% peak fires when the rotating sweep arm is at the blip's angle.
124
+ Negative delays put the animation mid-cycle at t=0 so the first paint isn't
125
+ a sea of bright pings. -->
126
+ <g filter="url(#dotGlowHub)">
127
+ <circle class="blip" cx="155" cy="68" r="4" fill="#ffffff" style="animation-delay: -140ms"/>
128
+ <circle class="blip" cx="178" cy="108" r="3.5" fill="#ffffff" style="animation-delay: -2247ms"/>
129
+ <circle class="blip" cx="98" cy="85" r="3" fill="#ecfdf5" style="animation-delay: -533ms"/>
130
+ <circle class="blip" cx="85" cy="155" r="3.5" fill="#ecfdf5" style="animation-delay: -1113ms"/>
131
+ <circle class="blip" cx="160" cy="175" r="3" fill="#ecfdf5" style="animation-delay: -1727ms"/>
132
+ <circle class="blip" cx="108" cy="195" r="3.5" fill="#ecfdf5" style="animation-delay: -1387ms"/>
133
+ </g>
134
+
135
+ <!-- Sweep arm: trail + leading line, rotated as one group -->
136
+ <g class="sweep-arm">
137
+ <g clip-path="url(#radarClipHub)">
138
+ <path d="M128,128 L128,10 A118,118 0 0,1 211.5,44.5 Z" fill="url(#sweepGlowHub)" opacity="0.75"/>
139
+ <path d="M128,128 L211.5,44.5 A118,118 0 0,1 240,95 Z" fill="#10b981" opacity="0.06"/>
140
+ </g>
141
+ <line x1="128" y1="128" x2="211" y2="45" stroke="#a7f3d0" stroke-width="2" filter="url(#lineGlowHub)" stroke-linecap="round"/>
142
+ <line x1="128" y1="128" x2="211" y2="45" stroke="#ffffff" stroke-width="1" opacity="0.9" stroke-linecap="round"/>
143
+ </g>
144
+
145
+ <!-- Center dot -->
146
+ <circle class="center-dot" cx="128" cy="128" r="3" fill="#6ee7b7" filter="url(#dotGlowHub)"/>
147
+ </svg>
@@ -0,0 +1,330 @@
1
+ import {
2
+ type ReactNode,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react'
8
+ import { ChevronDown, Check, Loader2, Search, X } from 'lucide-react'
9
+ import { StatusDot, type StatusTone } from '../ui/status-tone'
10
+
11
+ export interface ClusterSwitcherItem {
12
+ id: string
13
+ name: string
14
+ secondary?: string
15
+ badge?: string
16
+ group?: { key: string; label?: string }
17
+ disabled?: boolean
18
+ status?: StatusTone
19
+ /** Hard navigation target. Takes precedence over the parent's `onSelect`. */
20
+ href?: string
21
+ /** Tooltip — useful on disabled rows to explain why the row is inert. */
22
+ title?: string
23
+ }
24
+
25
+ export interface ClusterSwitcherProps {
26
+ currentId?: string
27
+ currentName: string
28
+ currentTooltip?: string
29
+ triggerIcon?: ReactNode
30
+ triggerPrefix?: ReactNode
31
+ triggerMaxWidthClass?: string
32
+ items: ClusterSwitcherItem[]
33
+ onSelect?: (item: ClusterSwitcherItem) => void
34
+ searchable?: boolean
35
+ showGroupHeaders?: boolean
36
+ showCurrentBullet?: boolean
37
+ loading?: boolean
38
+ disabled?: boolean
39
+ emptyText?: string
40
+ footerSlot?: ReactNode
41
+ errorSlot?: ReactNode
42
+ className?: string
43
+ align?: 'left' | 'right'
44
+ }
45
+
46
+ const DEFAULT_TRIGGER_MAX_WIDTH = 'max-w-[120px] sm:max-w-[220px]'
47
+
48
+ export function ClusterSwitcher({
49
+ currentId,
50
+ currentName,
51
+ currentTooltip,
52
+ triggerIcon,
53
+ triggerPrefix,
54
+ triggerMaxWidthClass = DEFAULT_TRIGGER_MAX_WIDTH,
55
+ items,
56
+ onSelect,
57
+ searchable = true,
58
+ showGroupHeaders = true,
59
+ showCurrentBullet = true,
60
+ loading = false,
61
+ disabled = false,
62
+ emptyText = 'No clusters',
63
+ footerSlot,
64
+ errorSlot,
65
+ className = '',
66
+ align = 'left',
67
+ }: ClusterSwitcherProps) {
68
+ const [isOpen, setIsOpen] = useState(false)
69
+ const [search, setSearch] = useState('')
70
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
71
+ const rootRef = useRef<HTMLDivElement>(null)
72
+ const searchInputRef = useRef<HTMLInputElement>(null)
73
+
74
+ const groups = useMemo(() => {
75
+ const q = search.trim().toLowerCase()
76
+ const matches = (item: ClusterSwitcherItem) => {
77
+ if (!q) return true
78
+ return (
79
+ item.name.toLowerCase().includes(q) ||
80
+ item.secondary?.toLowerCase().includes(q) ||
81
+ item.badge?.toLowerCase().includes(q) ||
82
+ item.group?.label?.toLowerCase().includes(q)
83
+ )
84
+ }
85
+ const map = new Map<string, { key: string; label?: string; items: ClusterSwitcherItem[] }>()
86
+ for (const item of items) {
87
+ if (!matches(item)) continue
88
+ const key = item.group?.key ?? ''
89
+ if (!map.has(key)) map.set(key, { key, label: item.group?.label, items: [] })
90
+ map.get(key)!.items.push(item)
91
+ }
92
+ return Array.from(map.values())
93
+ }, [items, search])
94
+
95
+ const flat = useMemo(() => groups.flatMap(g => g.items), [groups])
96
+ const indexById = useMemo(() => {
97
+ const m = new Map<string, number>()
98
+ flat.forEach((it, i) => m.set(it.id, i))
99
+ return m
100
+ }, [flat])
101
+
102
+ useEffect(() => {
103
+ if (!isOpen) return
104
+ setSearch('')
105
+ setHighlightedIndex(-1)
106
+ requestAnimationFrame(() => searchInputRef.current?.focus())
107
+ }, [isOpen])
108
+
109
+ useEffect(() => {
110
+ setHighlightedIndex(-1)
111
+ }, [search])
112
+
113
+ useEffect(() => {
114
+ if (!isOpen || highlightedIndex < 0 || !rootRef.current) return
115
+ const el = rootRef.current.querySelector('[data-highlighted="true"]')
116
+ if (el) (el as HTMLElement).scrollIntoView({ block: 'nearest' })
117
+ }, [highlightedIndex, isOpen])
118
+
119
+ // Listeners attach only while open so a closed switcher doesn't intercept
120
+ // ESC or click-outside meant for parent modals/overlays.
121
+ useEffect(() => {
122
+ if (!isOpen) return
123
+ function onClick(e: MouseEvent) {
124
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setIsOpen(false)
125
+ }
126
+ function onEsc(e: KeyboardEvent) {
127
+ if (e.key === 'Escape') setIsOpen(false)
128
+ }
129
+ document.addEventListener('mousedown', onClick)
130
+ document.addEventListener('keydown', onEsc)
131
+ return () => {
132
+ document.removeEventListener('mousedown', onClick)
133
+ document.removeEventListener('keydown', onEsc)
134
+ }
135
+ }, [isOpen])
136
+
137
+ const select = (item: ClusterSwitcherItem) => {
138
+ if (item.disabled || item.id === currentId) return
139
+ setIsOpen(false)
140
+ if (item.href) {
141
+ window.location.href = item.href
142
+ return
143
+ }
144
+ onSelect?.(item)
145
+ }
146
+
147
+ const onSearchKeyDown = (e: React.KeyboardEvent) => {
148
+ if (e.key === 'ArrowDown') {
149
+ e.preventDefault()
150
+ setHighlightedIndex(p => (p < flat.length - 1 ? p + 1 : p))
151
+ } else if (e.key === 'ArrowUp') {
152
+ e.preventDefault()
153
+ setHighlightedIndex(p => (p > 0 ? p - 1 : 0))
154
+ } else if (e.key === 'Enter') {
155
+ e.preventDefault()
156
+ if (highlightedIndex >= 0 && flat[highlightedIndex]) select(flat[highlightedIndex])
157
+ else if (flat.length > 0) setHighlightedIndex(0)
158
+ } else if (e.key === 'Escape') {
159
+ e.preventDefault()
160
+ setIsOpen(false)
161
+ }
162
+ }
163
+
164
+ const positionClass = align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left'
165
+
166
+ return (
167
+ <div className={`relative ${className}`} ref={rootRef}>
168
+ <button
169
+ type="button"
170
+ onClick={() => setIsOpen(v => !v)}
171
+ disabled={disabled || loading}
172
+ title={currentTooltip ?? currentName}
173
+ className={`
174
+ flex items-center gap-1.5 px-2.5 py-1.5
175
+ bg-theme-elevated border border-theme-border rounded text-sm font-medium
176
+ text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
177
+ transition-colors cursor-pointer
178
+ disabled:opacity-50 disabled:cursor-not-allowed
179
+ `}
180
+ >
181
+ {loading ? (
182
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
183
+ ) : (
184
+ triggerIcon
185
+ )}
186
+ {triggerPrefix && (
187
+ <span className="text-theme-text-tertiary">{triggerPrefix}</span>
188
+ )}
189
+ <span className={`${triggerMaxWidthClass} truncate`}>
190
+ {loading ? 'Switching...' : currentName}
191
+ </span>
192
+ <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
193
+ </button>
194
+
195
+ {isOpen && (
196
+ <div
197
+ className={`absolute top-full ${positionClass} mt-1 z-50 min-w-[280px] max-w-[420px] bg-theme-surface border border-theme-border-light rounded-lg shadow-xl overflow-hidden`}
198
+ >
199
+ {searchable && (
200
+ <div className="p-2 border-b border-theme-border">
201
+ <div className="relative">
202
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
203
+ <input
204
+ ref={searchInputRef}
205
+ type="text"
206
+ value={search}
207
+ onChange={e => setSearch(e.target.value)}
208
+ onKeyDown={onSearchKeyDown}
209
+ placeholder="Search clusters..."
210
+ 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-[var(--color-brand)] placeholder:text-theme-text-tertiary"
211
+ />
212
+ {search && (
213
+ <button
214
+ type="button"
215
+ onClick={() => setSearch('')}
216
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-secondary"
217
+ >
218
+ <X className="w-3.5 h-3.5" />
219
+ </button>
220
+ )}
221
+ </div>
222
+ </div>
223
+ )}
224
+
225
+ <div className="max-h-[400px] overflow-y-auto">
226
+ {flat.length === 0 ? (
227
+ <div className="px-3 py-6 text-center text-xs text-theme-text-tertiary">
228
+ {search ? `No clusters match "${search}"` : emptyText}
229
+ </div>
230
+ ) : (
231
+ groups.map((group, gi) => (
232
+ <div key={group.key || `g${gi}`}>
233
+ {gi > 0 && <div className="border-t border-theme-border-light my-1" />}
234
+ {showGroupHeaders && group.label && (
235
+ <div className="px-3 py-1 bg-theme-elevated/60 border-b border-theme-border/60">
236
+ <span className="text-[11px] text-theme-text-secondary font-semibold">
237
+ {group.label}
238
+ </span>
239
+ </div>
240
+ )}
241
+ {group.items.map(item => {
242
+ const idx = indexById.get(item.id) ?? -1
243
+ const isCurrent = item.id === currentId
244
+ return (
245
+ <button
246
+ type="button"
247
+ key={item.id}
248
+ data-highlighted={idx === highlightedIndex}
249
+ onClick={() => select(item)}
250
+ onMouseEnter={() => setHighlightedIndex(idx)}
251
+ disabled={isCurrent || item.disabled}
252
+ title={item.title}
253
+ className={`
254
+ w-full flex items-center gap-2 px-3 py-2 text-left transition-colors
255
+ ${isCurrent
256
+ ? 'selection'
257
+ : idx === highlightedIndex
258
+ ? 'bg-theme-hover cursor-pointer'
259
+ : 'hover:bg-theme-hover cursor-pointer'}
260
+ disabled:opacity-50
261
+ `}
262
+ >
263
+ <div className="shrink-0 w-4 h-4 flex items-center justify-center">
264
+ {isCurrent ? (
265
+ <Check className="w-3.5 h-3.5 selection-text" />
266
+ ) : showCurrentBullet ? (
267
+ <div className="w-1.5 h-1.5 rounded-full bg-theme-text-tertiary/30" />
268
+ ) : null}
269
+ </div>
270
+ {item.status && (
271
+ <span className="shrink-0">
272
+ <StatusDot tone={item.status} />
273
+ </span>
274
+ )}
275
+ <div className="flex-1 min-w-0">
276
+ <div className="flex items-center gap-1.5">
277
+ <span
278
+ className={`text-sm font-medium truncate ${
279
+ isCurrent
280
+ ? 'selection-text'
281
+ : item.disabled
282
+ ? 'text-theme-text-tertiary'
283
+ : 'text-theme-text-primary'
284
+ }`}
285
+ >
286
+ {item.name}
287
+ </span>
288
+ {item.badge && (
289
+ <span className="shrink-0 ml-auto text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
290
+ {item.badge}
291
+ </span>
292
+ )}
293
+ </div>
294
+ {item.secondary && (
295
+ <div
296
+ className="text-[10px] text-theme-text-tertiary opacity-70 truncate mt-0.5"
297
+ title={item.secondary}
298
+ >
299
+ {item.secondary}
300
+ </div>
301
+ )}
302
+ </div>
303
+ </button>
304
+ )
305
+ })}
306
+ </div>
307
+ ))
308
+ )}
309
+ </div>
310
+
311
+ {search && flat.length > 0 && flat.length < items.length && (
312
+ <div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base">
313
+ {flat.length} of {items.length} clusters
314
+ </div>
315
+ )}
316
+
317
+ {footerSlot && (
318
+ <div className="border-t border-theme-border bg-theme-base">{footerSlot}</div>
319
+ )}
320
+
321
+ {errorSlot && (
322
+ <div className="px-3 py-2 bg-red-500/10 border-t border-red-500/20">
323
+ {errorSlot}
324
+ </div>
325
+ )}
326
+ </div>
327
+ )}
328
+ </div>
329
+ )
330
+ }
@@ -0,0 +1,2 @@
1
+ export { ClusterSwitcher } from './ClusterSwitcher'
2
+ export type { ClusterSwitcherProps, ClusterSwitcherItem } from './ClusterSwitcher'
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useMemo, useEffect, useCallback, useRef, useContext } from 'react'
2
2
  import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
3
3
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
+ import { PaneLoader } from '../ui/PaneLoader'
4
5
  import type { TopPodMetrics, TopNodeMetrics } from '../../types'
5
6
  import {
6
7
  Search,
@@ -3547,9 +3548,7 @@ export function ResourcesView({
3547
3548
  }}
3548
3549
  >
3549
3550
  {isLoading ? (
3550
- <div className="absolute inset-0 flex items-center justify-center text-theme-text-tertiary">
3551
- Loading...
3552
- </div>
3551
+ <PaneLoader className="absolute inset-0" />
3553
3552
  ) : isSelectedForbidden ? (
3554
3553
  <div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
3555
3554
  <Shield className="w-8 h-8 text-amber-400 mb-2" />
@@ -1,5 +1,6 @@
1
1
  import { useState, useMemo, useRef, useEffect } from 'react'
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
+ import { PaneLoader } from '../ui/PaneLoader'
3
4
  import {
4
5
  AlertCircle,
5
6
  CheckCircle,
@@ -403,10 +404,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
403
404
  {/* Timeline content */}
404
405
  <div className="flex-1 overflow-auto">
405
406
  {isLoading ? (
406
- <div className="flex items-center justify-center h-full text-theme-text-tertiary">
407
- <RefreshCw className="w-5 h-5 animate-spin mr-2" />
408
- Loading timeline...
409
- </div>
407
+ <PaneLoader label="Loading timeline…" className="h-full" />
410
408
  ) : filteredActivity.length === 0 ? (
411
409
  <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
412
410
  <Clock className="w-12 h-12 mb-4 opacity-50" />
@@ -21,6 +21,7 @@ import '@xyflow/react/dist/style.css'
21
21
  import { toCanvas } from 'html-to-image'
22
22
 
23
23
  import { AlertTriangle, Download, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield, Workflow } from 'lucide-react'
24
+ import { PaneLoader } from '../ui/PaneLoader'
24
25
  import { Tooltip } from '../ui/Tooltip'
25
26
  import { useToast } from '../ui/Toast'
26
27
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
@@ -653,14 +654,7 @@ export function TopologyGraph({
653
654
  }, [selectedNodeId, setNodes])
654
655
 
655
656
  if (!topology) {
656
- return (
657
- <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
658
- <div className="text-center">
659
- <Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 opacity-50" />
660
- <p className="text-sm">Loading topology...</p>
661
- </div>
662
- </div>
663
- )
657
+ return <PaneLoader label="Loading topology…" className="flex-1" />
664
658
  }
665
659
 
666
660
  if (topology.nodes.length === 0) {
@@ -0,0 +1,21 @@
1
+ import radarLoadingIcon from '../../assets/radar/radar-icon-loading.svg'
2
+
3
+ // PaneLoader — center-of-pane loading state. Animated radar icon stacked
4
+ // above a label so swapping the label across the loading chain doesn't
5
+ // shift the icon horizontally. Pin to the parent's fill via `className`
6
+ // (`flex-1`, `h-full`, `h-32`, `absolute inset-0`, etc.). The SVG self-
7
+ // animates (sweep arm + blips, `prefers-reduced-motion` honored).
8
+ export function PaneLoader({
9
+ label = 'Loading…',
10
+ className = '',
11
+ }: {
12
+ label?: string
13
+ className?: string
14
+ }) {
15
+ return (
16
+ <div className={`flex flex-col items-center justify-center gap-3 ${className}`}>
17
+ <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
18
+ <span className="text-sm text-theme-text-tertiary">{label}</span>
19
+ </div>
20
+ )
21
+ }
@@ -1,4 +1,5 @@
1
1
  export { Tooltip } from './Tooltip'
2
+ export { PaneLoader } from './PaneLoader'
2
3
  export { ClusterName } from './ClusterName'
3
4
  export { EmptyState } from './EmptyState'
4
5
  export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
4
+ import { PaneLoader } from '../ui/PaneLoader'
4
5
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
5
6
  import { clsx } from 'clsx'
6
7
  import {
@@ -426,7 +427,7 @@ export function WorkloadView({
426
427
  {/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
427
428
  <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
428
429
  {resourceLoading ? (
429
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
430
+ <PaneLoader className="h-32" />
430
431
  ) : !resource ? (
431
432
  <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
432
433
  ) : showYaml ? (
@@ -640,7 +641,7 @@ export function WorkloadView({
640
641
  {activeTab === 'yaml' && (
641
642
  <div className="h-full overflow-auto">
642
643
  {resourceLoading ? (
643
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
644
+ <PaneLoader className="h-32" />
644
645
  ) : !resource ? (
645
646
  <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Resource not found</div>
646
647
  ) : (
@@ -1105,12 +1106,7 @@ function InfoTab({
1105
1106
  extraContent?: ReactNode
1106
1107
  }) {
1107
1108
  if (isLoading) {
1108
- return (
1109
- <div className="flex items-center justify-center h-full text-theme-text-tertiary">
1110
- <RefreshCw className="w-5 h-5 animate-spin mr-2" />
1111
- Loading...
1112
- </div>
1113
- )
1109
+ return <PaneLoader className="h-full" />
1114
1110
  }
1115
1111
 
1116
1112
  if (!resource) {
package/src/index.ts CHANGED
@@ -36,3 +36,6 @@ export * from './components/topology'
36
36
 
37
37
  // Cluster audit (AuditCard, AuditAlerts, AuditFindingsTable)
38
38
  export * from './components/audit'
39
+
40
+ // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
41
+ export * from './components/cluster-switcher'