@skyhook-io/k8s-ui 1.5.6 → 1.5.8
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 +347 -0
- package/src/components/cluster-switcher/index.ts +2 -0
- package/src/components/resources/ResourcesView.tsx +20 -10
- package/src/components/resources/resource-utils-cnpg.test.ts +75 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +16 -4
- package/src/components/timeline/TimelineList.tsx +2 -4
- package/src/components/topology/TopologyGraph.tsx +2 -8
- package/src/components/ui/ClusterName.tsx +41 -11
- package/src/components/ui/MiddleEllipsis.tsx +157 -0
- package/src/components/ui/PaneLoader.tsx +21 -0
- package/src/components/ui/drawer-components.tsx +53 -5
- package/src/components/ui/index.ts +3 -0
- package/src/components/workload/WorkloadView.tsx +39 -8
- package/src/index.ts +3 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +11 -0
- package/src/utils/navigation.ts +4 -1
- package/src/utils/parse-go-time.test.ts +54 -0
- package/src/utils/parse-go-time.ts +23 -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.8",
|
|
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,347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react'
|
|
8
|
+
import { ChevronDown, Check, Loader2, Search, Server, X } from 'lucide-react'
|
|
9
|
+
import { ClusterName } from '../ui/ClusterName'
|
|
10
|
+
import { StatusDot, type StatusTone } from '../ui/status-tone'
|
|
11
|
+
|
|
12
|
+
export interface ClusterSwitcherItem {
|
|
13
|
+
id: string
|
|
14
|
+
/** Raw context / display string. ClusterName collapses GKE/EKS/AKS
|
|
15
|
+
* shapes; user-named clusters pass through unchanged. */
|
|
16
|
+
name: string
|
|
17
|
+
secondary?: string
|
|
18
|
+
badge?: string
|
|
19
|
+
group?: { key: string; label?: string }
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
status?: StatusTone
|
|
22
|
+
/** Hard navigation target. Takes precedence over the parent's `onSelect`. */
|
|
23
|
+
href?: string
|
|
24
|
+
/** Native tooltip — useful on disabled rows to explain why the row is inert.
|
|
25
|
+
* ClusterName supplies its own tooltip for the cluster name itself; this
|
|
26
|
+
* is for row-level affordances (e.g. "Cluster offline — reconnect…"). */
|
|
27
|
+
title?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClusterSwitcherProps {
|
|
31
|
+
currentId?: string
|
|
32
|
+
/** Raw context / display string. Pass it as-is — the trigger renders
|
|
33
|
+
* through ClusterName, which handles parse + provider badge + tooltip. */
|
|
34
|
+
currentName: string
|
|
35
|
+
items: ClusterSwitcherItem[]
|
|
36
|
+
onSelect?: (item: ClusterSwitcherItem) => void
|
|
37
|
+
searchable?: boolean
|
|
38
|
+
showGroupHeaders?: boolean
|
|
39
|
+
showCurrentBullet?: boolean
|
|
40
|
+
loading?: boolean
|
|
41
|
+
disabled?: boolean
|
|
42
|
+
emptyText?: string
|
|
43
|
+
footerSlot?: ReactNode
|
|
44
|
+
errorSlot?: ReactNode
|
|
45
|
+
className?: string
|
|
46
|
+
align?: 'left' | 'right'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Trigger width cap. With middle-truncation kicking in, this is a
|
|
50
|
+
// horizontal-real-estate guard rather than a readability one. The cap
|
|
51
|
+
// grows with viewport so wide screens (where there's no real estate
|
|
52
|
+
// pressure) show full cluster names rather than middle-truncating
|
|
53
|
+
// pointlessly. The xl tier (~400px) fits names up to ~30 chars in
|
|
54
|
+
// full — comfortably covering parsed cluster names from any provider.
|
|
55
|
+
const TRIGGER_NAME_MAX_WIDTH = 'max-w-[160px] sm:max-w-[260px] xl:max-w-[400px]'
|
|
56
|
+
|
|
57
|
+
export function ClusterSwitcher({
|
|
58
|
+
currentId,
|
|
59
|
+
currentName,
|
|
60
|
+
items,
|
|
61
|
+
onSelect,
|
|
62
|
+
searchable = true,
|
|
63
|
+
showGroupHeaders = true,
|
|
64
|
+
showCurrentBullet = true,
|
|
65
|
+
loading = false,
|
|
66
|
+
disabled = false,
|
|
67
|
+
emptyText = 'No clusters',
|
|
68
|
+
footerSlot,
|
|
69
|
+
errorSlot,
|
|
70
|
+
className = '',
|
|
71
|
+
align = 'left',
|
|
72
|
+
}: ClusterSwitcherProps) {
|
|
73
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
74
|
+
const [search, setSearch] = useState('')
|
|
75
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
76
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
77
|
+
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
78
|
+
|
|
79
|
+
const groups = useMemo(() => {
|
|
80
|
+
const q = search.trim().toLowerCase()
|
|
81
|
+
const matches = (item: ClusterSwitcherItem) => {
|
|
82
|
+
if (!q) return true
|
|
83
|
+
return (
|
|
84
|
+
item.name.toLowerCase().includes(q) ||
|
|
85
|
+
item.secondary?.toLowerCase().includes(q) ||
|
|
86
|
+
item.badge?.toLowerCase().includes(q) ||
|
|
87
|
+
item.group?.label?.toLowerCase().includes(q)
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
const map = new Map<string, { key: string; label?: string; items: ClusterSwitcherItem[] }>()
|
|
91
|
+
for (const item of items) {
|
|
92
|
+
if (!matches(item)) continue
|
|
93
|
+
const key = item.group?.key ?? ''
|
|
94
|
+
if (!map.has(key)) map.set(key, { key, label: item.group?.label, items: [] })
|
|
95
|
+
map.get(key)!.items.push(item)
|
|
96
|
+
}
|
|
97
|
+
return Array.from(map.values())
|
|
98
|
+
}, [items, search])
|
|
99
|
+
|
|
100
|
+
const flat = useMemo(() => groups.flatMap(g => g.items), [groups])
|
|
101
|
+
const indexById = useMemo(() => {
|
|
102
|
+
const m = new Map<string, number>()
|
|
103
|
+
flat.forEach((it, i) => m.set(it.id, i))
|
|
104
|
+
return m
|
|
105
|
+
}, [flat])
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!isOpen) return
|
|
109
|
+
setSearch('')
|
|
110
|
+
setHighlightedIndex(-1)
|
|
111
|
+
requestAnimationFrame(() => searchInputRef.current?.focus())
|
|
112
|
+
}, [isOpen])
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setHighlightedIndex(-1)
|
|
116
|
+
}, [search])
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!isOpen || highlightedIndex < 0 || !rootRef.current) return
|
|
120
|
+
const el = rootRef.current.querySelector('[data-highlighted="true"]')
|
|
121
|
+
if (el) (el as HTMLElement).scrollIntoView({ block: 'nearest' })
|
|
122
|
+
}, [highlightedIndex, isOpen])
|
|
123
|
+
|
|
124
|
+
// Listeners attach only while open so a closed switcher doesn't intercept
|
|
125
|
+
// ESC or click-outside meant for parent modals/overlays.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!isOpen) return
|
|
128
|
+
function onClick(e: MouseEvent) {
|
|
129
|
+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setIsOpen(false)
|
|
130
|
+
}
|
|
131
|
+
function onEsc(e: KeyboardEvent) {
|
|
132
|
+
if (e.key === 'Escape') setIsOpen(false)
|
|
133
|
+
}
|
|
134
|
+
document.addEventListener('mousedown', onClick)
|
|
135
|
+
document.addEventListener('keydown', onEsc)
|
|
136
|
+
return () => {
|
|
137
|
+
document.removeEventListener('mousedown', onClick)
|
|
138
|
+
document.removeEventListener('keydown', onEsc)
|
|
139
|
+
}
|
|
140
|
+
}, [isOpen])
|
|
141
|
+
|
|
142
|
+
const select = (item: ClusterSwitcherItem) => {
|
|
143
|
+
if (item.disabled || item.id === currentId) return
|
|
144
|
+
setIsOpen(false)
|
|
145
|
+
if (item.href) {
|
|
146
|
+
window.location.href = item.href
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
onSelect?.(item)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const onSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
153
|
+
if (e.key === 'ArrowDown') {
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
setHighlightedIndex(p => (p < flat.length - 1 ? p + 1 : p))
|
|
156
|
+
} else if (e.key === 'ArrowUp') {
|
|
157
|
+
e.preventDefault()
|
|
158
|
+
setHighlightedIndex(p => (p > 0 ? p - 1 : 0))
|
|
159
|
+
} else if (e.key === 'Enter') {
|
|
160
|
+
e.preventDefault()
|
|
161
|
+
if (highlightedIndex >= 0 && flat[highlightedIndex]) select(flat[highlightedIndex])
|
|
162
|
+
else if (flat.length > 0) setHighlightedIndex(0)
|
|
163
|
+
} else if (e.key === 'Escape') {
|
|
164
|
+
e.preventDefault()
|
|
165
|
+
setIsOpen(false)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const positionClass = align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left'
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className={`relative ${className}`} ref={rootRef}>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => setIsOpen(v => !v)}
|
|
176
|
+
disabled={disabled || loading}
|
|
177
|
+
className={`
|
|
178
|
+
flex items-center gap-1.5 px-2.5 py-1.5
|
|
179
|
+
bg-theme-elevated border border-theme-border rounded text-sm font-medium
|
|
180
|
+
text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
|
|
181
|
+
transition-colors cursor-pointer
|
|
182
|
+
disabled:opacity-50 disabled:cursor-not-allowed
|
|
183
|
+
`}
|
|
184
|
+
>
|
|
185
|
+
{loading ? (
|
|
186
|
+
<>
|
|
187
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
188
|
+
<span className={`${TRIGGER_NAME_MAX_WIDTH} block truncate`}>Switching…</span>
|
|
189
|
+
</>
|
|
190
|
+
) : (
|
|
191
|
+
// ClusterName parses the context (provider badge for GKE/EKS/AKS,
|
|
192
|
+
// raw name for custom kubeconfig) and middle-truncates to the cap.
|
|
193
|
+
// Server icon is the fallback badge for the no-provider case so
|
|
194
|
+
// the trigger always has a leading visual. Tooltip is suppressed
|
|
195
|
+
// while the dropdown is open — the popover already shows the raw
|
|
196
|
+
// context inline (per-row secondary line), and an extra hover
|
|
197
|
+
// tooltip would just overlap the search input.
|
|
198
|
+
<ClusterName
|
|
199
|
+
name={currentName}
|
|
200
|
+
fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
|
|
201
|
+
className={TRIGGER_NAME_MAX_WIDTH}
|
|
202
|
+
noTooltip={isOpen}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
206
|
+
</button>
|
|
207
|
+
|
|
208
|
+
{isOpen && (
|
|
209
|
+
<div
|
|
210
|
+
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`}
|
|
211
|
+
>
|
|
212
|
+
{searchable && (
|
|
213
|
+
<div className="p-2 border-b border-theme-border">
|
|
214
|
+
<div className="relative">
|
|
215
|
+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
216
|
+
<input
|
|
217
|
+
ref={searchInputRef}
|
|
218
|
+
type="text"
|
|
219
|
+
value={search}
|
|
220
|
+
onChange={e => setSearch(e.target.value)}
|
|
221
|
+
onKeyDown={onSearchKeyDown}
|
|
222
|
+
placeholder="Search clusters..."
|
|
223
|
+
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"
|
|
224
|
+
/>
|
|
225
|
+
{search && (
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={() => setSearch('')}
|
|
229
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-theme-text-tertiary hover:text-theme-text-secondary"
|
|
230
|
+
>
|
|
231
|
+
<X className="w-3.5 h-3.5" />
|
|
232
|
+
</button>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
<div className="max-h-[400px] overflow-y-auto">
|
|
239
|
+
{flat.length === 0 ? (
|
|
240
|
+
<div className="px-3 py-6 text-center text-xs text-theme-text-tertiary">
|
|
241
|
+
{search ? `No clusters match "${search}"` : emptyText}
|
|
242
|
+
</div>
|
|
243
|
+
) : (
|
|
244
|
+
groups.map((group, gi) => (
|
|
245
|
+
<div key={group.key || `g${gi}`}>
|
|
246
|
+
{gi > 0 && <div className="border-t border-theme-border-light my-1" />}
|
|
247
|
+
{showGroupHeaders && group.label && (
|
|
248
|
+
<div className="px-3 py-1 bg-theme-elevated/60 border-b border-theme-border/60">
|
|
249
|
+
<span className="text-[11px] text-theme-text-secondary font-semibold">
|
|
250
|
+
{group.label}
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
{group.items.map(item => {
|
|
255
|
+
const idx = indexById.get(item.id) ?? -1
|
|
256
|
+
const isCurrent = item.id === currentId
|
|
257
|
+
return (
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
key={item.id}
|
|
261
|
+
data-highlighted={idx === highlightedIndex}
|
|
262
|
+
onClick={() => select(item)}
|
|
263
|
+
onMouseEnter={() => setHighlightedIndex(idx)}
|
|
264
|
+
disabled={isCurrent || item.disabled}
|
|
265
|
+
title={item.title}
|
|
266
|
+
className={`
|
|
267
|
+
w-full flex items-center gap-2 px-3 py-2 text-left transition-colors
|
|
268
|
+
${isCurrent
|
|
269
|
+
? 'selection'
|
|
270
|
+
: idx === highlightedIndex
|
|
271
|
+
? 'bg-theme-hover cursor-pointer'
|
|
272
|
+
: 'hover:bg-theme-hover cursor-pointer'}
|
|
273
|
+
disabled:opacity-50
|
|
274
|
+
`}
|
|
275
|
+
>
|
|
276
|
+
<div className="shrink-0 w-4 h-4 flex items-center justify-center">
|
|
277
|
+
{isCurrent ? (
|
|
278
|
+
<Check className="w-3.5 h-3.5 selection-text" />
|
|
279
|
+
) : showCurrentBullet ? (
|
|
280
|
+
<div className="w-1.5 h-1.5 rounded-full bg-theme-text-tertiary/30" />
|
|
281
|
+
) : null}
|
|
282
|
+
</div>
|
|
283
|
+
{item.status && (
|
|
284
|
+
<span className="shrink-0">
|
|
285
|
+
<StatusDot tone={item.status} />
|
|
286
|
+
</span>
|
|
287
|
+
)}
|
|
288
|
+
<div className="flex-1 min-w-0">
|
|
289
|
+
<div className="flex items-center gap-1.5">
|
|
290
|
+
{/* No tooltip on row names — each row already
|
|
291
|
+
renders the raw context inline below the
|
|
292
|
+
name (item.secondary), so the hover tooltip
|
|
293
|
+
would just repeat what's already visible. */}
|
|
294
|
+
<ClusterName
|
|
295
|
+
name={item.name}
|
|
296
|
+
noTooltip
|
|
297
|
+
className={`text-sm font-medium flex-1 ${
|
|
298
|
+
isCurrent
|
|
299
|
+
? 'selection-text'
|
|
300
|
+
: item.disabled
|
|
301
|
+
? 'text-theme-text-tertiary'
|
|
302
|
+
: 'text-theme-text-primary'
|
|
303
|
+
}`}
|
|
304
|
+
/>
|
|
305
|
+
{item.badge && (
|
|
306
|
+
<span className="shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
|
|
307
|
+
{item.badge}
|
|
308
|
+
</span>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
{item.secondary && (
|
|
312
|
+
<div
|
|
313
|
+
className="text-[10px] text-theme-text-tertiary opacity-70 truncate mt-0.5"
|
|
314
|
+
title={item.secondary}
|
|
315
|
+
>
|
|
316
|
+
{item.secondary}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</button>
|
|
321
|
+
)
|
|
322
|
+
})}
|
|
323
|
+
</div>
|
|
324
|
+
))
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{search && flat.length > 0 && flat.length < items.length && (
|
|
329
|
+
<div className="px-3 py-1.5 text-[10px] text-theme-text-tertiary border-t border-theme-border bg-theme-base">
|
|
330
|
+
{flat.length} of {items.length} clusters
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
{footerSlot && (
|
|
335
|
+
<div className="border-t border-theme-border bg-theme-base">{footerSlot}</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{errorSlot && (
|
|
339
|
+
<div className="px-3 py-2 bg-red-500/10 border-t border-red-500/20">
|
|
340
|
+
{errorSlot}
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
@@ -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,
|
|
@@ -1694,8 +1695,20 @@ function getInitialKindFromURL(
|
|
|
1694
1695
|
locationPathname?: string,
|
|
1695
1696
|
locationSearch?: string,
|
|
1696
1697
|
): SelectedKindInfo {
|
|
1697
|
-
|
|
1698
|
-
|
|
1698
|
+
// Prefer injected pathname/search from the host router. Using `||` would incorrectly fall back to
|
|
1699
|
+
// window when the host passes '' before hydration. SSR has no window.
|
|
1700
|
+
const pathname =
|
|
1701
|
+
locationPathname !== undefined
|
|
1702
|
+
? locationPathname
|
|
1703
|
+
: typeof window !== 'undefined'
|
|
1704
|
+
? window.location.pathname
|
|
1705
|
+
: ''
|
|
1706
|
+
const search =
|
|
1707
|
+
locationSearch !== undefined
|
|
1708
|
+
? locationSearch
|
|
1709
|
+
: typeof window !== 'undefined'
|
|
1710
|
+
? window.location.search
|
|
1711
|
+
: ''
|
|
1699
1712
|
const base = basePath.replace(/\/$/, '') // strip trailing slash
|
|
1700
1713
|
let kind: string | null = null
|
|
1701
1714
|
if (pathname.startsWith(base + '/')) {
|
|
@@ -1750,8 +1763,8 @@ export function ResourcesView({
|
|
|
1750
1763
|
pinned = [],
|
|
1751
1764
|
togglePin = () => {},
|
|
1752
1765
|
isPinned = () => false,
|
|
1753
|
-
locationSearch
|
|
1754
|
-
locationPathname
|
|
1766
|
+
locationSearch,
|
|
1767
|
+
locationPathname,
|
|
1755
1768
|
onNavigate,
|
|
1756
1769
|
basePath = '/resources',
|
|
1757
1770
|
onOpenLogs,
|
|
@@ -1762,7 +1775,6 @@ export function ResourcesView({
|
|
|
1762
1775
|
extraLeadingColumns,
|
|
1763
1776
|
onRowSelect,
|
|
1764
1777
|
}: ResourcesViewProps) {
|
|
1765
|
-
const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
|
|
1766
1778
|
const initialFilters = getInitialFiltersFromURL()
|
|
1767
1779
|
const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, locationPathname, locationSearch))
|
|
1768
1780
|
// Sync selectedKind from URL when locationPathname changes (e.g., browser back, external sidebar navigation)
|
|
@@ -1771,7 +1783,7 @@ export function ResourcesView({
|
|
|
1771
1783
|
if (kindFromURL.name !== selectedKind.name || kindFromURL.group !== selectedKind.group) {
|
|
1772
1784
|
setSelectedKind(kindFromURL)
|
|
1773
1785
|
}
|
|
1774
|
-
}, [locationPathname]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1786
|
+
}, [locationPathname, locationSearch]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1775
1787
|
// Notify parent of selected kind changes (including initial mount)
|
|
1776
1788
|
useEffect(() => {
|
|
1777
1789
|
onSelectedKindChange?.(selectedKind)
|
|
@@ -2354,7 +2366,7 @@ export function ResourcesView({
|
|
|
2354
2366
|
requestAnimationFrame(() => {
|
|
2355
2367
|
isSyncingFromURL.current = false
|
|
2356
2368
|
})
|
|
2357
|
-
}, [
|
|
2369
|
+
}, [locationPathname, locationSearch]) // Re-run when injected URL path or search params change
|
|
2358
2370
|
|
|
2359
2371
|
const navigate = useMemo(() => {
|
|
2360
2372
|
if (!onNavigate) return (_pathOrObj: any, _opts?: any) => {}
|
|
@@ -3547,9 +3559,7 @@ export function ResourcesView({
|
|
|
3547
3559
|
}}
|
|
3548
3560
|
>
|
|
3549
3561
|
{isLoading ? (
|
|
3550
|
-
<
|
|
3551
|
-
Loading...
|
|
3552
|
-
</div>
|
|
3562
|
+
<PaneLoader className="absolute inset-0" />
|
|
3553
3563
|
) : isSelectedForbidden ? (
|
|
3554
3564
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
|
|
3555
3565
|
<Shield className="w-8 h-8 text-amber-400 mb-2" />
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { getCNPGClusterCertificateExpirations } from './resource-utils-cnpg'
|
|
3
|
+
|
|
4
|
+
describe('getCNPGClusterCertificateExpirations', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers()
|
|
7
|
+
vi.setSystemTime(new Date('2026-04-28T12:00:00Z'))
|
|
8
|
+
})
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('regression: future Go-format dates are not flagged as expired (issue #554)', () => {
|
|
14
|
+
const resource = {
|
|
15
|
+
status: {
|
|
16
|
+
certificates: {
|
|
17
|
+
expirations: {
|
|
18
|
+
'mycluster-ca': '2026-07-27 08:27:41 +0000 UTC',
|
|
19
|
+
'mycluster-server': '2026-07-27 08:27:41 +0000 UTC',
|
|
20
|
+
'mycluster-replication': '2026-07-27 08:27:41 +0000 UTC',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
const certs = getCNPGClusterCertificateExpirations(resource)
|
|
26
|
+
expect(certs).toHaveLength(3)
|
|
27
|
+
for (const cert of certs) {
|
|
28
|
+
expect(cert.daysUntilExpiry).toBeGreaterThan(0)
|
|
29
|
+
expect(cert.daysUntilExpiry).toBeLessThanOrEqual(91)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('floors fractional days down so threshold banners do not misfire', () => {
|
|
34
|
+
// Pinned now: 2026-04-28T12:00:00Z; expiry 2026-07-27T00:00:00Z is 89.5
|
|
35
|
+
// days away. Math.floor pins this to 89, not 90 — locks day-boundary
|
|
36
|
+
// semantics so a future Math.ceil/round refactor doesn't shift the
|
|
37
|
+
// <30d / <7d alert thresholds.
|
|
38
|
+
const resource = {
|
|
39
|
+
status: {
|
|
40
|
+
certificates: {
|
|
41
|
+
expirations: { 'mycluster-ca': '2026-07-27 00:00:00 +0000 UTC' },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
const [cert] = getCNPGClusterCertificateExpirations(resource)
|
|
46
|
+
expect(cert.daysUntilExpiry).toBe(89)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('flags genuinely expired certificates as negative', () => {
|
|
50
|
+
const resource = {
|
|
51
|
+
status: {
|
|
52
|
+
certificates: {
|
|
53
|
+
expirations: {
|
|
54
|
+
'mycluster-ca': '2026-04-27 08:27:41 +0000 UTC',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
const [cert] = getCNPGClusterCertificateExpirations(resource)
|
|
60
|
+
expect(cert.daysUntilExpiry).toBeLessThan(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('maps unparseable values to the -1 sentinel (renders as "expired")', () => {
|
|
64
|
+
const resource = {
|
|
65
|
+
status: { certificates: { expirations: { 'mycluster-ca': 'garbage' } } },
|
|
66
|
+
}
|
|
67
|
+
const [cert] = getCNPGClusterCertificateExpirations(resource)
|
|
68
|
+
expect(cert.daysUntilExpiry).toBe(-1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns empty list when no expirations are present', () => {
|
|
72
|
+
expect(getCNPGClusterCertificateExpirations({})).toEqual([])
|
|
73
|
+
expect(getCNPGClusterCertificateExpirations({ status: {} })).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
})
|