@skyhook-io/k8s-ui 1.5.5 → 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 +3 -2
- package/src/assets/radar/radar-icon-loading.svg +147 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +330 -0
- package/src/components/cluster-switcher/index.ts +2 -0
- package/src/components/resources/ResourcesView.tsx +2 -3
- package/src/components/timeline/TimelineList.tsx +2 -4
- package/src/components/topology/TopologyGraph.tsx +2 -8
- package/src/components/ui/PaneLoader.tsx +21 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +4 -8
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.5.
|
|
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
|
+
}
|
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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,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
|
-
<
|
|
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
|
-
<
|
|
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'
|