@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.5.6",
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
+ }
@@ -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,
@@ -1694,8 +1695,20 @@ function getInitialKindFromURL(
1694
1695
  locationPathname?: string,
1695
1696
  locationSearch?: string,
1696
1697
  ): SelectedKindInfo {
1697
- const pathname = locationPathname || window.location.pathname
1698
- const search = locationSearch || window.location.search
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
- }, [location.search, location.pathname]) // Re-run when URL path or search params change
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
- <div className="absolute inset-0 flex items-center justify-center text-theme-text-tertiary">
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
+ })