@skyhook-io/k8s-ui 1.2.1 → 1.2.2

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.2.1",
3
+ "version": "1.2.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -0,0 +1,542 @@
1
+ import { useState, useMemo, useEffect, useRef, forwardRef } from 'react'
2
+ import {
3
+ Search,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ Eye,
7
+ EyeOff,
8
+ Pin,
9
+ Shield,
10
+ X,
11
+ } from 'lucide-react'
12
+ import { clsx } from 'clsx'
13
+ import type { APIResource } from '../../types'
14
+ import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
15
+ import { getResourceIcon } from '../../utils/resource-icons'
16
+ import { Tooltip } from '../ui/Tooltip'
17
+
18
+ // Selected resource type info (need both name for API and kind for display)
19
+ export interface SelectedKindInfo {
20
+ name: string // Plural name for API calls (e.g., 'pods')
21
+ kind: string // Kind for display (e.g., 'Pod')
22
+ group: string // API group for disambiguation (e.g., '', 'metrics.k8s.io')
23
+ }
24
+
25
+ // Pinned item shape
26
+ export interface PinnedItem {
27
+ name: string
28
+ kind: string
29
+ group: string
30
+ }
31
+
32
+ export interface ResourcesSidebarProps {
33
+ selectedKind: SelectedKindInfo | null
34
+ onSelectedKindChange: (kind: SelectedKindInfo) => void
35
+ onKindChange?: () => void
36
+ apiResources?: APIResource[]
37
+ resourceCounts?: Record<string, number>
38
+ resourceForbidden?: string[]
39
+ pinned?: PinnedItem[]
40
+ togglePin?: (item: PinnedItem) => void
41
+ isPinned?: (kind: string, group?: string) => boolean
42
+ className?: string
43
+ /** When provided, kind clicks navigate via this callback instead of only updating state */
44
+ onNavigate?: (path: string) => void
45
+ /** Base path for generating navigation URLs (e.g., '/org/clusters/id/k8s-resources') */
46
+ basePath?: string
47
+ }
48
+
49
+ // Persisted across remounts so collapsed categories survive tab switches
50
+ let persistedExpandedCategories: Set<string> | null = null
51
+
52
+ // Core kinds that are always shown even with 0 instances
53
+ // These are the most commonly used Kubernetes resources (using Kind names, not plural names)
54
+ const ALWAYS_SHOWN_KINDS = new Set([
55
+ 'Pod',
56
+ 'Deployment',
57
+ 'DaemonSet',
58
+ 'StatefulSet',
59
+ 'ReplicaSet',
60
+ 'Service',
61
+ 'Ingress',
62
+ 'ConfigMap',
63
+ 'Secret',
64
+ 'Job',
65
+ 'CronJob',
66
+ 'HorizontalPodAutoscaler',
67
+ 'PersistentVolumeClaim',
68
+ 'Node',
69
+ 'Namespace',
70
+ 'ServiceAccount',
71
+ 'NetworkPolicy',
72
+ 'Event',
73
+ ])
74
+
75
+ // Fallback resource types when API resources aren't loaded yet
76
+ const CORE_RESOURCE_TYPES = [
77
+ { kind: 'pods', label: 'Pods' },
78
+ { kind: 'deployments', label: 'Deployments' },
79
+ { kind: 'daemonsets', label: 'DaemonSets' },
80
+ { kind: 'statefulsets', label: 'StatefulSets' },
81
+ { kind: 'replicasets', label: 'ReplicaSets' },
82
+ { kind: 'services', label: 'Services' },
83
+ { kind: 'ingresses', label: 'Ingresses' },
84
+ { kind: 'configmaps', label: 'ConfigMaps' },
85
+ { kind: 'secrets', label: 'Secrets' },
86
+ { kind: 'jobs', label: 'Jobs' },
87
+ { kind: 'cronjobs', label: 'CronJobs' },
88
+ { kind: 'hpas', label: 'HPAs' },
89
+ ] as const
90
+
91
+ // Resource type button in sidebar
92
+ interface ResourceTypeButtonProps {
93
+ resource: APIResource
94
+ count: number
95
+ isSelected: boolean
96
+ isForbidden?: boolean
97
+ isPinned?: boolean
98
+ onTogglePin?: () => void
99
+ onClick: () => void
100
+ }
101
+
102
+ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
103
+ function ResourceTypeButton({ resource, count, isSelected, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
104
+ const Icon = getResourceIcon(resource.kind)
105
+ return (
106
+ <button
107
+ ref={ref}
108
+ onClick={onClick}
109
+ className={clsx(
110
+ 'w-full flex items-center gap-2 px-2 xl:px-3 py-1.5 rounded-lg text-sm transition-colors group/kind min-w-0',
111
+ isSelected
112
+ ? 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
113
+ : forbidden
114
+ ? 'text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary'
115
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
116
+ )}
117
+ >
118
+ <Icon className="w-4 h-4 shrink-0" />
119
+ <Tooltip content={forbidden ? `${resource.kind} (no access)` : resource.kind} position="right" wrapperClassName="min-w-0 flex-1 overflow-hidden">
120
+ <span className="text-left truncate block">
121
+ {resource.kind}
122
+ </span>
123
+ </Tooltip>
124
+ <div className="ml-auto flex items-center gap-1 shrink-0">
125
+ {onTogglePin && (
126
+ <span
127
+ role="button"
128
+ onClick={(e) => {
129
+ e.stopPropagation()
130
+ onTogglePin()
131
+ }}
132
+ className={clsx(
133
+ 'p-0.5 rounded transition-all hover:bg-theme-hover',
134
+ isPinned
135
+ ? 'text-theme-text-secondary'
136
+ : 'opacity-0 group-hover/kind:opacity-100 text-theme-text-disabled'
137
+ )}
138
+ title={isPinned ? 'Unpin from favorites' : 'Pin to favorites'}
139
+ >
140
+ <Pin className={clsx('w-3 h-3', isPinned && 'fill-current')} />
141
+ </span>
142
+ )}
143
+ {forbidden ? (
144
+ <Tooltip content="Insufficient permissions" position="left">
145
+ <Shield className="w-3.5 h-3.5 text-amber-400/60" />
146
+ </Tooltip>
147
+ ) : (
148
+ <span className={clsx(
149
+ 'text-xs py-0.5 rounded text-center',
150
+ isSelected ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated',
151
+ count < 1000 ? 'w-8' : 'w-9'
152
+ )}>
153
+ {count}
154
+ </span>
155
+ )}
156
+ </div>
157
+ </button>
158
+ )
159
+ }
160
+ )
161
+
162
+ export function ResourcesSidebar({
163
+ selectedKind,
164
+ onSelectedKindChange,
165
+ onKindChange,
166
+ apiResources,
167
+ resourceCounts,
168
+ resourceForbidden,
169
+ pinned = [],
170
+ togglePin = () => {},
171
+ isPinned = () => false,
172
+ className,
173
+ onNavigate,
174
+ basePath,
175
+ }: ResourcesSidebarProps) {
176
+ // Wraps kind selection to also navigate when basePath/onNavigate are provided
177
+ const selectKind = (kind: SelectedKindInfo) => {
178
+ onSelectedKindChange(kind)
179
+ onKindChange?.()
180
+ if (onNavigate && basePath) {
181
+ const path = `${basePath}/${kind.name}${kind.group ? `?apiGroup=${kind.group}` : ''}`
182
+ onNavigate(path)
183
+ }
184
+ }
185
+
186
+ // --- Sidebar-local state ---
187
+ const [kindFilter, setKindFilter] = useState('')
188
+ const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
189
+ () => persistedExpandedCategories ?? new Set(['Workloads', 'Networking', 'Configuration'])
190
+ )
191
+ useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
192
+ const [showEmptyKinds, setShowEmptyKinds] = useState(false)
193
+ const [favoritesExpanded, setFavoritesExpanded] = useState(() => pinned.length > 0)
194
+
195
+ // Ref to selected sidebar item for scrolling into view on deeplink
196
+ const selectedSidebarRef = useRef<HTMLButtonElement>(null)
197
+
198
+ // Effective selected kind — fall back to a safe default
199
+ const effectiveSelectedKind = selectedKind ?? { name: 'pods', kind: 'Pod', group: '' }
200
+
201
+ // Categorize resources for sidebar
202
+ const categories = useMemo(() => {
203
+ if (!apiResources) return null
204
+ return categorizeResources(apiResources)
205
+ }, [apiResources])
206
+
207
+ // Auto-expand the sidebar category containing the selected kind
208
+ const lastAutoExpandedKind = useRef<string | null>(null)
209
+ useEffect(() => {
210
+ if (!categories) return
211
+ const kindKey = `${effectiveSelectedKind.group}/${effectiveSelectedKind.kind}`
212
+ if (lastAutoExpandedKind.current === kindKey) return
213
+ lastAutoExpandedKind.current = kindKey
214
+ for (const cat of categories) {
215
+ const match = cat.resources.some(r => r.kind === effectiveSelectedKind.kind || r.name === effectiveSelectedKind.name)
216
+ if (match && !expandedCategories.has(cat.name)) {
217
+ setExpandedCategories(prev => new Set([...prev, cat.name]))
218
+ break
219
+ }
220
+ }
221
+ }, [categories, effectiveSelectedKind.kind, effectiveSelectedKind.name]) // eslint-disable-line react-hooks/exhaustive-deps
222
+
223
+ // Derive counts from resourceCounts prop
224
+ const resourcesToCount = useMemo(() => {
225
+ if (categories) {
226
+ return categories.flatMap(c => c.resources).map(r => ({
227
+ kind: r.kind,
228
+ name: r.name,
229
+ group: r.group,
230
+ }))
231
+ }
232
+ return CORE_RESOURCES.map(r => ({
233
+ kind: r.kind,
234
+ name: r.name,
235
+ group: r.group,
236
+ }))
237
+ }, [categories])
238
+
239
+ const counts = useMemo(() => {
240
+ if (!resourceCounts) return {} as Record<string, number>
241
+ const results: Record<string, number> = {}
242
+ for (const resource of resourcesToCount) {
243
+ const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
244
+ results[key] = resourceCounts[key] ?? 0
245
+ }
246
+ return results
247
+ }, [resourcesToCount, resourceCounts])
248
+
249
+ // Track which resource kinds returned 403 Forbidden
250
+ const forbiddenKinds = useMemo(() => {
251
+ return new Set(resourceForbidden ?? [])
252
+ }, [resourceForbidden])
253
+
254
+ // Calculate category totals, filter empty kinds/groups, and sort (empty categories at bottom)
255
+ const { sortedCategories, hiddenKindsCount, hiddenGroupsCount } = useMemo(() => {
256
+ if (!categories) return { sortedCategories: null, hiddenKindsCount: 0, hiddenGroupsCount: 0 }
257
+
258
+ let totalHiddenKinds = 0
259
+ let totalHiddenGroups = 0
260
+
261
+ const withTotals = categories.map(category => {
262
+ const total = category.resources.reduce(
263
+ (sum, resource) => sum + (counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0),
264
+ 0
265
+ )
266
+
267
+ // Filter resources: show if has instances, is core kind, or showEmptyKinds is true
268
+ const visibleResources = category.resources.filter(resource => {
269
+ const count = counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0
270
+ const isCore = ALWAYS_SHOWN_KINDS.has(resource.kind)
271
+ const shouldShow = count > 0 || isCore || showEmptyKinds
272
+ if (!shouldShow) totalHiddenKinds++
273
+ return shouldShow
274
+ })
275
+
276
+ return { ...category, total, visibleResources }
277
+ })
278
+
279
+ // Sort: categories with resources first, empty ones at bottom
280
+ const sorted = withTotals.sort((a, b) => {
281
+ if (a.total === 0 && b.total > 0) return 1
282
+ if (a.total > 0 && b.total === 0) return -1
283
+ return 0
284
+ })
285
+
286
+ // Filter out empty groups unless they have visible resources (core kinds) or showEmptyKinds is true
287
+ const visibleCategories = sorted.filter(category => {
288
+ // Show if: has resources with instances, OR has visible resources (core kinds), OR showEmptyKinds
289
+ const shouldShow = category.total > 0 || category.visibleResources.length > 0 || showEmptyKinds
290
+ if (!shouldShow) totalHiddenGroups++
291
+ return shouldShow
292
+ })
293
+
294
+ return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
295
+ }, [categories, counts, showEmptyKinds])
296
+
297
+ // Filter sidebar categories/kinds by the kind search term
298
+ const filteredCategories = useMemo(() => {
299
+ if (!sortedCategories || !kindFilter.trim()) return sortedCategories
300
+ const term = kindFilter.toLowerCase()
301
+ return sortedCategories
302
+ .map(category => {
303
+ const categoryMatches = category.name.toLowerCase().includes(term)
304
+ // If the group name matches, show all its resources
305
+ if (categoryMatches) return category
306
+ const matchingResources = category.visibleResources.filter((resource: any) =>
307
+ resource.kind.toLowerCase().includes(term) ||
308
+ resource.name.toLowerCase().includes(term)
309
+ )
310
+ if (matchingResources.length === 0) return null
311
+ return {
312
+ ...category,
313
+ visibleResources: matchingResources,
314
+ }
315
+ })
316
+ .filter(Boolean) as typeof sortedCategories
317
+ }, [sortedCategories, kindFilter])
318
+
319
+ // Auto-expand all categories when filtering
320
+ const isKindFiltering = kindFilter.trim().length > 0
321
+ const effectiveExpandedCategories = useMemo(() => {
322
+ if (!isKindFiltering || !filteredCategories) return expandedCategories
323
+ return new Set(filteredCategories.map(c => c.name))
324
+ }, [isKindFiltering, filteredCategories, expandedCategories])
325
+
326
+ const toggleCategory = (categoryName: string) => {
327
+ setExpandedCategories(prev => {
328
+ const next = new Set(prev)
329
+ if (next.has(categoryName)) {
330
+ next.delete(categoryName)
331
+ } else {
332
+ next.add(categoryName)
333
+ }
334
+ return next
335
+ })
336
+ }
337
+
338
+ // Scroll sidebar to show selected kind on mount (deep linking) and on kind changes (keyboard nav)
339
+ const lastScrolledKind = useRef<string | null>(null)
340
+ const isInitialScroll = useRef(true)
341
+ useEffect(() => {
342
+ const kindKey = `${effectiveSelectedKind.group}/${effectiveSelectedKind.name}`
343
+ if (lastScrolledKind.current === kindKey) return
344
+ lastScrolledKind.current = kindKey
345
+
346
+ const instant = isInitialScroll.current
347
+ isInitialScroll.current = false
348
+
349
+ requestAnimationFrame(() => {
350
+ if (selectedSidebarRef.current) {
351
+ selectedSidebarRef.current.scrollIntoView({
352
+ behavior: instant ? 'instant' : 'smooth',
353
+ block: 'center',
354
+ })
355
+ }
356
+ })
357
+ }, [effectiveSelectedKind.name, effectiveSelectedKind.group])
358
+
359
+ return (
360
+ <div className={clsx('w-56 2xl:w-72 bg-theme-surface border-r border-theme-border overflow-y-auto overflow-x-hidden shrink-0', className)}>
361
+ <div className="px-2 py-2 border-b border-theme-border">
362
+ <div className="relative">
363
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
364
+ <input
365
+ type="text"
366
+ placeholder="Filter resources..."
367
+ value={kindFilter}
368
+ onChange={(e) => setKindFilter(e.target.value)}
369
+ onKeyDown={(e) => { if (e.key === 'Escape') { setKindFilter(''); (e.target as HTMLInputElement).blur() } }}
370
+ className="w-full pl-7 pr-7 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
371
+ />
372
+ {kindFilter && (
373
+ <button
374
+ onClick={() => setKindFilter('')}
375
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-theme-surface text-theme-text-tertiary hover:text-theme-text-secondary"
376
+ >
377
+ <X className="w-3 h-3" />
378
+ </button>
379
+ )}
380
+ </div>
381
+ </div>
382
+ <nav className="p-2">
383
+ {/* Favorites (pinned kinds) section — always visible */}
384
+ <div className="mb-2">
385
+ <button
386
+ onClick={() => setFavoritesExpanded((v) => !v)}
387
+ className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
388
+ >
389
+ {favoritesExpanded ? (
390
+ <ChevronDown className="w-3 h-3" />
391
+ ) : (
392
+ <ChevronRight className="w-3 h-3" />
393
+ )}
394
+ <span className="flex-1 text-left">Favorites</span>
395
+ {!favoritesExpanded && pinned.length > 0 && (
396
+ <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center', pinned.length < 1000 ? 'w-8' : 'w-9')}>
397
+ {pinned.length}
398
+ </span>
399
+ )}
400
+ </button>
401
+ {favoritesExpanded && (
402
+ <div className="space-y-0.5">
403
+ {pinned.length === 0 ? (
404
+ <div className="px-3 py-2 text-xs text-theme-text-disabled">
405
+ No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
406
+ </div>
407
+ ) : (
408
+ pinned.map((p) => {
409
+ const isResourceSelected =
410
+ (effectiveSelectedKind.name === p.name && effectiveSelectedKind.group === p.group) ||
411
+ (effectiveSelectedKind.kind.toLowerCase() === p.kind.toLowerCase() && effectiveSelectedKind.group === p.group)
412
+ return (
413
+ <ResourceTypeButton
414
+ key={`${p.name}-${p.group}`}
415
+ ref={isResourceSelected ? selectedSidebarRef : null}
416
+ resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
417
+ count={counts?.[(p.group ? `${p.group}/${p.kind}` : p.kind)] ?? 0}
418
+ isSelected={isResourceSelected}
419
+ isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
420
+ isPinned={true}
421
+ onTogglePin={() => togglePin(p)}
422
+ onClick={() => selectKind({ name: p.name, kind: p.kind, group: p.group })}
423
+ />
424
+ )
425
+ })
426
+ )}
427
+ </div>
428
+ )}
429
+ </div>
430
+ {filteredCategories ? (
431
+ // Dynamic categories from API
432
+ filteredCategories.map((category) => {
433
+ const isExpanded = effectiveExpandedCategories.has(category.name)
434
+ return (
435
+ <div key={category.name} className="mb-2">
436
+ <button
437
+ onClick={() => toggleCategory(category.name)}
438
+ className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
439
+ >
440
+ {isExpanded ? (
441
+ <ChevronDown className="w-3 h-3" />
442
+ ) : (
443
+ <ChevronRight className="w-3 h-3" />
444
+ )}
445
+ <span className="flex-1 text-left">{category.name}</span>
446
+ {!isExpanded && (
447
+ <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center', category.total < 1000 ? 'w-8' : 'w-9')}>
448
+ {category.total}
449
+ </span>
450
+ )}
451
+ </button>
452
+ {isExpanded && (
453
+ <div className="space-y-0.5">
454
+ {category.visibleResources.map((resource) => {
455
+ const resourceIsPinned = isPinned(resource.name, resource.group)
456
+ const isResourceSelected =
457
+ (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
458
+ (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
459
+ // If the resource is pinned, let the Favorites section own the highlight
460
+ const showSelected = isResourceSelected && !resourceIsPinned
461
+ return (
462
+ <ResourceTypeButton
463
+ key={resource.name}
464
+ ref={isResourceSelected ? selectedSidebarRef : null}
465
+ resource={resource}
466
+ count={counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0}
467
+ isSelected={showSelected}
468
+ isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
469
+ isPinned={resourceIsPinned}
470
+ onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
471
+ onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
472
+ />
473
+ )
474
+ })}
475
+ </div>
476
+ )}
477
+ </div>
478
+ )
479
+ })
480
+ ) : (
481
+ // Fallback to core resources while loading
482
+ CORE_RESOURCE_TYPES.map((type) => {
483
+ // Fallback: type.label is display name like 'Pods', counts are keyed by Kind like 'Pod'
484
+ // Remove trailing 's' for singular kind lookup (hacky but works for fallback)
485
+ const kindKey = type.label.endsWith('s') && !type.label.endsWith('ss')
486
+ ? type.label.slice(0, -1)
487
+ : type.label
488
+ const Icon = getResourceIcon(kindKey)
489
+ const count = counts?.[kindKey] ?? 0
490
+ const isSelected = effectiveSelectedKind.name === type.kind && !effectiveSelectedKind.group
491
+ return (
492
+ <button
493
+ key={type.kind}
494
+ onClick={() => {
495
+ selectKind({ name: type.kind, kind: type.label, group: '' })
496
+ }}
497
+ className={clsx(
498
+ 'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
499
+ isSelected
500
+ ? 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
501
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
502
+ )}
503
+ >
504
+ <Icon className="w-4 h-4 shrink-0" />
505
+ <span className="flex-1 text-left">{type.label}</span>
506
+ <span className={clsx(
507
+ 'text-xs px-2 py-0.5 rounded',
508
+ isSelected ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated'
509
+ )}>
510
+ {count}
511
+ </span>
512
+ </button>
513
+ )
514
+ })
515
+ )}
516
+
517
+ {/* Toggle for showing/hiding empty kinds and groups */}
518
+ {hiddenKindsCount > 0 || hiddenGroupsCount > 0 || showEmptyKinds ? (
519
+ <button
520
+ onClick={() => setShowEmptyKinds(!showEmptyKinds)}
521
+ className="w-full flex items-center gap-2 px-3 py-2 mt-2 text-xs text-theme-text-tertiary hover:text-theme-text-secondary border-t border-theme-border"
522
+ >
523
+ {showEmptyKinds ? (
524
+ <>
525
+ <EyeOff className="w-3.5 h-3.5" />
526
+ <span>Hide empty</span>
527
+ </>
528
+ ) : (
529
+ <>
530
+ <Eye className="w-3.5 h-3.5" />
531
+ <span>
532
+ Show {hiddenKindsCount + hiddenGroupsCount} empty
533
+ {hiddenGroupsCount > 0 && ` (${hiddenGroupsCount} groups)`}
534
+ </span>
535
+ </>
536
+ )}
537
+ </button>
538
+ ) : null}
539
+ </nav>
540
+ </div>
541
+ )
542
+ }
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo, useEffect, useCallback, useRef, forwardRef, useContext } from 'react'
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
4
  import type { TopPodMetrics, TopNodeMetrics } from '../../types'
@@ -9,17 +9,13 @@ import {
9
9
  Globe,
10
10
  Shield,
11
11
  ChevronDown,
12
- ChevronRight,
13
12
  ChevronUp,
14
- Eye,
15
- EyeOff,
16
13
  ArrowUpDown,
17
14
  Clock,
18
15
  ListFilter,
19
16
  X,
20
17
  Columns3,
21
18
  RotateCcw,
22
- Pin,
23
19
  Trash2,
24
20
  Tag,
25
21
  Copy,
@@ -121,7 +117,6 @@ import {
121
117
  serializeColumnFilters,
122
118
  } from './resource-utils'
123
119
  import { Tooltip } from '../ui/Tooltip'
124
- import { getResourceIcon } from '../../utils/resource-icons'
125
120
  // CRD-specific cell components (extracted)
126
121
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
127
122
  import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
@@ -139,6 +134,8 @@ import { KnativeServiceCell, ConfigurationCell as KnativeConfigurationCell, Revi
139
134
  import { IngressRouteCell, MiddlewareCell, TraefikServiceCell, ServersTransportCell, TLSOptionCell } from './renderers/traefik-cells'
140
135
  import { HTTPProxyCell } from './renderers/contour-cells'
141
136
  import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
137
+ import { ResourcesSidebar } from './ResourcesSidebar'
138
+ import type { SelectedKindInfo } from './ResourcesSidebar'
142
139
 
143
140
  // Pod problem filter options (special multi-select, not a single column value)
144
141
  const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts'] as const
@@ -158,53 +155,6 @@ const SKIP_FILTER_COLUMNS = new Set([
158
155
  'secrets', 'subjects', 'role', 'entrypoint', 'templates',
159
156
  ])
160
157
 
161
- // Fallback resource types when API resources aren't loaded yet
162
- const CORE_RESOURCE_TYPES = [
163
- { kind: 'pods', label: 'Pods' },
164
- { kind: 'deployments', label: 'Deployments' },
165
- { kind: 'daemonsets', label: 'DaemonSets' },
166
- { kind: 'statefulsets', label: 'StatefulSets' },
167
- { kind: 'replicasets', label: 'ReplicaSets' },
168
- { kind: 'services', label: 'Services' },
169
- { kind: 'ingresses', label: 'Ingresses' },
170
- { kind: 'configmaps', label: 'ConfigMaps' },
171
- { kind: 'secrets', label: 'Secrets' },
172
- { kind: 'jobs', label: 'Jobs' },
173
- { kind: 'cronjobs', label: 'CronJobs' },
174
- { kind: 'hpas', label: 'HPAs' },
175
- ] as const
176
-
177
- // Core kinds that are always shown even with 0 instances
178
- // These are the most commonly used Kubernetes resources (using Kind names, not plural names)
179
- const ALWAYS_SHOWN_KINDS = new Set([
180
- 'Pod',
181
- 'Deployment',
182
- 'DaemonSet',
183
- 'StatefulSet',
184
- 'ReplicaSet',
185
- 'Service',
186
- 'Ingress',
187
- 'ConfigMap',
188
- 'Secret',
189
- 'Job',
190
- 'CronJob',
191
- 'HorizontalPodAutoscaler',
192
- 'PersistentVolumeClaim',
193
- 'Node',
194
- 'Namespace',
195
- 'ServiceAccount',
196
- 'NetworkPolicy',
197
- 'Event',
198
- ])
199
-
200
-
201
- // Selected resource type info (need both name for API and kind for display)
202
- interface SelectedKindInfo {
203
- name: string // Plural name for API calls (e.g., 'pods')
204
- kind: string // Kind for display (e.g., 'Pod')
205
- group: string // API group for disambiguation (e.g., '', 'metrics.k8s.io')
206
- }
207
-
208
158
  // Column definitions per resource kind
209
159
  interface Column {
210
160
  key: string
@@ -1468,6 +1418,8 @@ interface ResourcesViewProps {
1468
1418
  onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
1469
1419
  // Callback when selected kind changes — used by parent to fetch data for the selected kind
1470
1420
  onSelectedKindChange?: (kind: { name: string; kind: string; group: string }) => void
1421
+ /** When true, the sidebar is not rendered. Useful when a standalone ResourcesSidebar is used externally. */
1422
+ hideSidebar?: boolean
1471
1423
  }
1472
1424
 
1473
1425
  // Default selected kind
@@ -1517,10 +1469,6 @@ function getInitialFiltersFromURL() {
1517
1469
  // Sort state type
1518
1470
  type SortDirection = 'asc' | 'desc' | null
1519
1471
 
1520
- // Persisted across remounts so collapsed categories survive tab switches
1521
- let persistedExpandedCategories: Set<string> | null = null
1522
- let lastAutoExpandedKind: string | null = null
1523
-
1524
1472
  export function ResourcesView({
1525
1473
  namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange,
1526
1474
  apiResources: apiResourcesProp,
@@ -1542,20 +1490,23 @@ export function ResourcesView({
1542
1490
  onOpenLogs,
1543
1491
  onOpenWorkloadLogs,
1544
1492
  onSelectedKindChange,
1493
+ hideSidebar = false,
1545
1494
  }: ResourcesViewProps) {
1546
1495
  const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
1547
1496
  const initialFilters = getInitialFiltersFromURL()
1548
1497
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath))
1498
+ // Sync selectedKind from URL when locationPathname changes (e.g., browser back, external sidebar navigation)
1499
+ useEffect(() => {
1500
+ const kindFromURL = getInitialKindFromURL(basePath)
1501
+ if (kindFromURL.name !== selectedKind.name || kindFromURL.group !== selectedKind.group) {
1502
+ setSelectedKind(kindFromURL)
1503
+ }
1504
+ }, [locationPathname]) // eslint-disable-line react-hooks/exhaustive-deps
1549
1505
  // Notify parent of selected kind changes (including initial mount)
1550
1506
  useEffect(() => {
1551
1507
  onSelectedKindChange?.(selectedKind)
1552
1508
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
1553
1509
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
1554
- const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
1555
- () => persistedExpandedCategories ?? new Set(['Workloads', 'Networking', 'Configuration'])
1556
- )
1557
- const [showEmptyKinds, setShowEmptyKinds] = useState(false)
1558
- const [kindFilter, setKindFilter] = useState('')
1559
1510
  const [sortColumn, setSortColumn] = useState<string | null>(null)
1560
1511
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
1561
1512
  const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
@@ -1610,10 +1561,6 @@ export function ResourcesView({
1610
1561
  })
1611
1562
  }, [])
1612
1563
 
1613
- // Pinned kinds (favorites) — provided via props
1614
- const [favoritesExpanded, setFavoritesExpanded] = useState(() => pinned.length > 0)
1615
-
1616
- useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
1617
1564
  // Track if this is the initial mount to avoid re-syncing on first render
1618
1565
  const isInitialMount = useRef(true)
1619
1566
  const isSyncingFromURL = useRef(false)
@@ -1622,8 +1569,6 @@ export function ResourcesView({
1622
1569
  // Set by sidebar kind change to push a browser history entry (vs replace for filter changes)
1623
1570
  const shouldPushHistory = useRef(false)
1624
1571
 
1625
- // Ref to selected sidebar item for scrolling into view on deeplink
1626
- const selectedSidebarRef = useRef<HTMLButtonElement>(null)
1627
1572
  // Ref to search input for keyboard shortcut
1628
1573
  const searchInputRef = useRef<HTMLInputElement>(null)
1629
1574
  // Resize state
@@ -1997,7 +1942,7 @@ export function ResourcesView({
1997
1942
  const filteredResourceCountRef = useRef(0)
1998
1943
  const highlightedResourceRef = useRef<any>(null)
1999
1944
 
2000
- // Ref for flat kind list used by [ / ] sidebar navigation (populated after filteredCategories is computed)
1945
+ // Ref for flat kind list used by [ / ] sidebar navigation (populated from categories)
2001
1946
  const flatKindListRef = useRef<SelectedKindInfo[]>([])
2002
1947
 
2003
1948
  // Sidebar kind navigation: [ = previous kind, ] = next kind
@@ -2155,8 +2100,9 @@ export function ResourcesView({
2155
2100
  } else {
2156
2101
  params.delete('showInactive')
2157
2102
  }
2158
- if (resourceNs && resourceName) {
2159
- params.set('resource', `${resourceNs}/${resourceName}`)
2103
+ if (resourceName) {
2104
+ // Namespaced: ns/name, cluster-scoped: just name
2105
+ params.set('resource', resourceNs ? `${resourceNs}/${resourceName}` : resourceName)
2160
2106
  } else {
2161
2107
  params.delete('resource')
2162
2108
  }
@@ -2204,9 +2150,15 @@ export function ResourcesView({
2204
2150
  const params = new URLSearchParams(window.location.search)
2205
2151
  const resourceParam = params.get('resource')
2206
2152
  if (resourceParam && onResourceClick) {
2207
- const [ns, name] = resourceParam.split('/')
2208
- if (ns && name) {
2153
+ const slashIndex = resourceParam.indexOf('/')
2154
+ if (slashIndex > 0) {
2155
+ // Namespaced: ?resource=namespace/name
2156
+ const ns = resourceParam.slice(0, slashIndex)
2157
+ const name = resourceParam.slice(slashIndex + 1)
2209
2158
  onResourceClick({ kind: selectedKind.name, namespace: ns, name, group: selectedKind.group })
2159
+ } else {
2160
+ // Cluster-scoped: ?resource=name (no namespace)
2161
+ onResourceClick({ kind: selectedKind.name, namespace: '', name: resourceParam, group: selectedKind.group })
2210
2162
  }
2211
2163
  }
2212
2164
  // Signal that initial resource param has been processed — URL update effect can now run
@@ -2291,22 +2243,6 @@ export function ResourcesView({
2291
2243
  return categorizeResources(apiResources)
2292
2244
  }, [apiResources])
2293
2245
 
2294
- // Auto-expand the sidebar category containing the selected kind (e.g., when deep-linking to a CRD)
2295
- // Skip if the kind hasn't changed since last auto-expand (preserves user's collapsed state on remount)
2296
- useEffect(() => {
2297
- if (!categories) return
2298
- const kindKey = `${selectedKind.group}/${selectedKind.kind}`
2299
- if (lastAutoExpandedKind === kindKey) return
2300
- lastAutoExpandedKind = kindKey
2301
- for (const cat of categories) {
2302
- const match = cat.resources.some(r => r.kind === selectedKind.kind || r.name === selectedKind.name)
2303
- if (match && !expandedCategories.has(cat.name)) {
2304
- setExpandedCategories(prev => new Set([...prev, cat.name]))
2305
- break
2306
- }
2307
- }
2308
- }, [categories, selectedKind.kind, selectedKind.name])
2309
-
2310
2246
  // Get resources to count - use kind as unique key since name can conflict (e.g., pods vs PodMetrics)
2311
2247
  const resourcesToCount = useMemo(() => {
2312
2248
  if (categories) {
@@ -2757,91 +2693,7 @@ export function ResourcesView({
2757
2693
  return () => clearTimeout(timer)
2758
2694
  }, [selectedResource, filteredResources])
2759
2695
 
2760
- // Scroll sidebar to show selected kind when deep linking (but not on manual category expand)
2761
- const lastScrolledKind = useRef<string | null>(null)
2762
- useEffect(() => {
2763
- const kindKey = `${selectedKind.group}/${selectedKind.name}`
2764
-
2765
- if (lastScrolledKind.current === kindKey) return
2766
- lastScrolledKind.current = kindKey
2767
-
2768
- requestAnimationFrame(() => {
2769
- if (selectedSidebarRef.current) {
2770
- selectedSidebarRef.current.scrollIntoView({
2771
- behavior: 'smooth',
2772
- block: 'center',
2773
- })
2774
- }
2775
- })
2776
- }, [selectedKind.name, selectedKind.group, expandedCategories])
2777
-
2778
- // Calculate category totals, filter empty kinds/groups, and sort (empty categories at bottom)
2779
- const { sortedCategories, hiddenKindsCount, hiddenGroupsCount } = useMemo(() => {
2780
- if (!categories) return { sortedCategories: null, hiddenKindsCount: 0, hiddenGroupsCount: 0 }
2781
-
2782
- let totalHiddenKinds = 0
2783
- let totalHiddenGroups = 0
2784
-
2785
- const withTotals = categories.map(category => {
2786
- const total = category.resources.reduce(
2787
- (sum, resource) => sum + (counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0),
2788
- 0
2789
- )
2790
-
2791
- // Filter resources: show if has instances, is core kind, or showEmptyKinds is true
2792
- const visibleResources = category.resources.filter(resource => {
2793
- const count = counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0
2794
- const isCore = ALWAYS_SHOWN_KINDS.has(resource.kind)
2795
- const shouldShow = count > 0 || isCore || showEmptyKinds
2796
- if (!shouldShow) totalHiddenKinds++
2797
- return shouldShow
2798
- })
2799
-
2800
- return { ...category, total, visibleResources }
2801
- })
2802
-
2803
- // Sort: categories with resources first, empty ones at bottom
2804
- const sorted = withTotals.sort((a, b) => {
2805
- if (a.total === 0 && b.total > 0) return 1
2806
- if (a.total > 0 && b.total === 0) return -1
2807
- return 0
2808
- })
2809
-
2810
- // Filter out empty groups unless they have visible resources (core kinds) or showEmptyKinds is true
2811
- const visibleCategories = sorted.filter(category => {
2812
- // Show if: has resources with instances, OR has visible resources (core kinds), OR showEmptyKinds
2813
- const shouldShow = category.total > 0 || category.visibleResources.length > 0 || showEmptyKinds
2814
- if (!shouldShow) totalHiddenGroups++
2815
- return shouldShow
2816
- })
2817
-
2818
- return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
2819
- }, [categories, counts, showEmptyKinds])
2820
-
2821
- // Filter sidebar categories/kinds by the kind search term
2822
- const filteredCategories = useMemo(() => {
2823
- if (!sortedCategories || !kindFilter.trim()) return sortedCategories
2824
- const term = kindFilter.toLowerCase()
2825
- return sortedCategories
2826
- .map(category => {
2827
- const categoryMatches = category.name.toLowerCase().includes(term)
2828
- // If the group name matches, show all its resources
2829
- if (categoryMatches) return category
2830
- const matchingResources = category.visibleResources.filter((resource: any) =>
2831
- resource.kind.toLowerCase().includes(term) ||
2832
- resource.name.toLowerCase().includes(term)
2833
- )
2834
- if (matchingResources.length === 0) return null
2835
- return {
2836
- ...category,
2837
- visibleResources: matchingResources,
2838
- }
2839
- })
2840
- .filter(Boolean) as typeof sortedCategories
2841
- }, [sortedCategories, kindFilter])
2842
-
2843
2696
  // Build flat kind list for [ / ] sidebar navigation
2844
- // Includes pinned kinds first, then all visible kinds from categories (deduped)
2845
2697
  useEffect(() => {
2846
2698
  const list: SelectedKindInfo[] = []
2847
2699
  const seen = new Set<string>()
@@ -2850,34 +2702,15 @@ export function ResourcesView({
2850
2702
  if (!seen.has(key)) { seen.add(key); list.push(k) }
2851
2703
  }
2852
2704
  for (const p of pinned) addKind({ name: p.name, kind: p.kind, group: p.group })
2853
- if (filteredCategories) {
2854
- for (const cat of filteredCategories) {
2855
- for (const r of cat.visibleResources) {
2705
+ if (categories) {
2706
+ for (const cat of categories) {
2707
+ for (const r of cat.resources) {
2856
2708
  addKind({ name: r.name, kind: r.kind, group: r.group })
2857
2709
  }
2858
2710
  }
2859
2711
  }
2860
2712
  flatKindListRef.current = list
2861
- }, [pinned, filteredCategories])
2862
-
2863
- // Auto-expand all categories when filtering
2864
- const isKindFiltering = kindFilter.trim().length > 0
2865
- const effectiveExpandedCategories = useMemo(() => {
2866
- if (!isKindFiltering || !filteredCategories) return expandedCategories
2867
- return new Set(filteredCategories.map(c => c.name))
2868
- }, [isKindFiltering, filteredCategories, expandedCategories])
2869
-
2870
- const toggleCategory = (categoryName: string) => {
2871
- setExpandedCategories(prev => {
2872
- const next = new Set(prev)
2873
- if (next.has(categoryName)) {
2874
- next.delete(categoryName)
2875
- } else {
2876
- next.add(categoryName)
2877
- }
2878
- return next
2879
- })
2880
- }
2713
+ }, [pinned, categories])
2881
2714
 
2882
2715
  // Filter columns by visibility
2883
2716
  const columns = useMemo(() => {
@@ -3070,194 +2903,22 @@ export function ResourcesView({
3070
2903
  <ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
3071
2904
  <div className="flex h-full w-full">
3072
2905
  {/* Sidebar - Resource Types */}
3073
- <div className="w-56 2xl:w-72 bg-theme-surface border-r border-theme-border overflow-y-auto overflow-x-hidden shrink-0">
3074
- <div className="px-2 py-2 border-b border-theme-border">
3075
- <div className="relative">
3076
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
3077
- <input
3078
- type="text"
3079
- placeholder="Filter resources..."
3080
- value={kindFilter}
3081
- onChange={(e) => setKindFilter(e.target.value)}
3082
- onKeyDown={(e) => { if (e.key === 'Escape') { setKindFilter(''); (e.target as HTMLInputElement).blur() } }}
3083
- className="w-full pl-7 pr-7 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
3084
- />
3085
- {kindFilter && (
3086
- <button
3087
- onClick={() => setKindFilter('')}
3088
- className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-theme-surface text-theme-text-tertiary hover:text-theme-text-secondary"
3089
- >
3090
- <X className="w-3 h-3" />
3091
- </button>
3092
- )}
3093
- </div>
3094
- </div>
3095
- <nav className="p-2">
3096
- {/* Favorites (pinned kinds) section — always visible */}
3097
- <div className="mb-2">
3098
- <button
3099
- onClick={() => setFavoritesExpanded((v) => !v)}
3100
- className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
3101
- >
3102
- {favoritesExpanded ? (
3103
- <ChevronDown className="w-3 h-3" />
3104
- ) : (
3105
- <ChevronRight className="w-3 h-3" />
3106
- )}
3107
- <span className="flex-1 text-left">Favorites</span>
3108
- {!favoritesExpanded && pinned.length > 0 && (
3109
- <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center', pinned.length < 1000 ? 'w-8' : 'w-9')}>
3110
- {pinned.length}
3111
- </span>
3112
- )}
3113
- </button>
3114
- {favoritesExpanded && (
3115
- <div className="space-y-0.5">
3116
- {pinned.length === 0 ? (
3117
- <div className="px-3 py-2 text-xs text-theme-text-disabled">
3118
- No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
3119
- </div>
3120
- ) : (
3121
- pinned.map((p) => {
3122
- const isResourceSelected =
3123
- (selectedKind.name === p.name && selectedKind.group === p.group) ||
3124
- (selectedKind.kind.toLowerCase() === p.kind.toLowerCase() && selectedKind.group === p.group)
3125
- return (
3126
- <ResourceTypeButton
3127
- key={`${p.name}-${p.group}`}
3128
- ref={isResourceSelected ? selectedSidebarRef : null}
3129
- resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
3130
- count={counts?.[(p.group ? `${p.group}/${p.kind}` : p.kind)] ?? 0}
3131
- isSelected={isResourceSelected}
3132
- isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
3133
- isPinned={true}
3134
- onTogglePin={() => togglePin(p)}
3135
- onClick={() => {
3136
- shouldPushHistory.current = true
3137
- setSelectedKind({ name: p.name, kind: p.kind, group: p.group })
3138
- onKindChange?.()
3139
- }}
3140
- />
3141
- )
3142
- })
3143
- )}
3144
- </div>
3145
- )}
3146
- </div>
3147
- {filteredCategories ? (
3148
- // Dynamic categories from API
3149
- filteredCategories.map((category) => {
3150
- const isExpanded = effectiveExpandedCategories.has(category.name)
3151
- return (
3152
- <div key={category.name} className="mb-2">
3153
- <button
3154
- onClick={() => toggleCategory(category.name)}
3155
- className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
3156
- >
3157
- {isExpanded ? (
3158
- <ChevronDown className="w-3 h-3" />
3159
- ) : (
3160
- <ChevronRight className="w-3 h-3" />
3161
- )}
3162
- <span className="flex-1 text-left">{category.name}</span>
3163
- {!isExpanded && (
3164
- <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center', category.total < 1000 ? 'w-8' : 'w-9')}>
3165
- {category.total}
3166
- </span>
3167
- )}
3168
- </button>
3169
- {isExpanded && (
3170
- <div className="space-y-0.5">
3171
- {category.visibleResources.map((resource) => {
3172
- const isResourceSelected =
3173
- (selectedKind.name === resource.name && selectedKind.group === resource.group) ||
3174
- (selectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && selectedKind.group === resource.group)
3175
- return (
3176
- <ResourceTypeButton
3177
- key={resource.name}
3178
- ref={isResourceSelected ? selectedSidebarRef : null}
3179
- resource={resource}
3180
- count={counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0}
3181
- isSelected={isResourceSelected}
3182
- isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
3183
- isPinned={isPinned(resource.name, resource.group)}
3184
- onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
3185
- onClick={() => {
3186
- shouldPushHistory.current = true
3187
- setSelectedKind({ name: resource.name, kind: resource.kind, group: resource.group })
3188
- onKindChange?.()
3189
- }}
3190
- />
3191
- )
3192
- })}
3193
- </div>
3194
- )}
3195
- </div>
3196
- )
3197
- })
3198
- ) : (
3199
- // Fallback to core resources while loading
3200
- CORE_RESOURCE_TYPES.map((type) => {
3201
- // Fallback: type.label is display name like 'Pods', counts are keyed by Kind like 'Pod'
3202
- // Remove trailing 's' for singular kind lookup (hacky but works for fallback)
3203
- const kindKey = type.label.endsWith('s') && !type.label.endsWith('ss')
3204
- ? type.label.slice(0, -1)
3205
- : type.label
3206
- const Icon = getResourceIcon(kindKey)
3207
- const count = counts?.[kindKey] ?? 0
3208
- const isSelected = selectedKind.name === type.kind && !selectedKind.group
3209
- return (
3210
- <button
3211
- key={type.kind}
3212
- onClick={() => {
3213
- shouldPushHistory.current = true
3214
- setSelectedKind({ name: type.kind, kind: type.label, group: '' })
3215
- onKindChange?.()
3216
- }}
3217
- className={clsx(
3218
- 'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
3219
- isSelected
3220
- ? 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
3221
- : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
3222
- )}
3223
- >
3224
- <Icon className="w-4 h-4 shrink-0" />
3225
- <span className="flex-1 text-left">{type.label}</span>
3226
- <span className={clsx(
3227
- 'text-xs px-2 py-0.5 rounded',
3228
- isSelected ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated'
3229
- )}>
3230
- {count}
3231
- </span>
3232
- </button>
3233
- )
3234
- })
3235
- )}
3236
-
3237
- {/* Toggle for showing/hiding empty kinds and groups */}
3238
- {hiddenKindsCount > 0 || hiddenGroupsCount > 0 || showEmptyKinds ? (
3239
- <button
3240
- onClick={() => setShowEmptyKinds(!showEmptyKinds)}
3241
- className="w-full flex items-center gap-2 px-3 py-2 mt-2 text-xs text-theme-text-tertiary hover:text-theme-text-secondary border-t border-theme-border"
3242
- >
3243
- {showEmptyKinds ? (
3244
- <>
3245
- <EyeOff className="w-3.5 h-3.5" />
3246
- <span>Hide empty</span>
3247
- </>
3248
- ) : (
3249
- <>
3250
- <Eye className="w-3.5 h-3.5" />
3251
- <span>
3252
- Show {hiddenKindsCount + hiddenGroupsCount} empty
3253
- {hiddenGroupsCount > 0 && ` (${hiddenGroupsCount} groups)`}
3254
- </span>
3255
- </>
3256
- )}
3257
- </button>
3258
- ) : null}
3259
- </nav>
3260
- </div>
2906
+ {!hideSidebar && (
2907
+ <ResourcesSidebar
2908
+ selectedKind={selectedKind}
2909
+ onSelectedKindChange={(kind) => {
2910
+ shouldPushHistory.current = true
2911
+ setSelectedKind(kind)
2912
+ }}
2913
+ onKindChange={onKindChange}
2914
+ apiResources={apiResourcesProp}
2915
+ resourceCounts={counts}
2916
+ resourceForbidden={Array.from(forbiddenKinds)}
2917
+ pinned={pinned}
2918
+ togglePin={togglePin}
2919
+ isPinned={isPinned}
2920
+ />
2921
+ )}
3261
2922
 
3262
2923
  {/* Main Content - Resource Table */}
3263
2924
  <div className="flex-1 flex flex-col overflow-hidden min-w-0">
@@ -3789,77 +3450,6 @@ export function ResourcesView({
3789
3450
  )
3790
3451
  }
3791
3452
 
3792
- // Resource type button in sidebar
3793
- interface ResourceTypeButtonProps {
3794
- resource: APIResource
3795
- count: number
3796
- isSelected: boolean
3797
- isForbidden?: boolean
3798
- isPinned?: boolean
3799
- onTogglePin?: () => void
3800
- onClick: () => void
3801
- }
3802
-
3803
- const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
3804
- function ResourceTypeButton({ resource, count, isSelected, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
3805
- const Icon = getResourceIcon(resource.kind)
3806
- return (
3807
- <button
3808
- ref={ref}
3809
- onClick={onClick}
3810
- className={clsx(
3811
- 'w-full flex items-center gap-2 px-2 xl:px-3 py-1.5 rounded-lg text-sm transition-colors group/kind min-w-0',
3812
- isSelected
3813
- ? 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
3814
- : forbidden
3815
- ? 'text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary'
3816
- : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
3817
- )}
3818
- >
3819
- <Icon className="w-4 h-4 shrink-0" />
3820
- <Tooltip content={forbidden ? `${resource.kind} (no access)` : resource.kind} position="right" wrapperClassName="min-w-0 flex-1 overflow-hidden">
3821
- <span className="text-left truncate block">
3822
- {resource.kind}
3823
- </span>
3824
- </Tooltip>
3825
- <div className="ml-auto flex items-center gap-1 shrink-0">
3826
- {onTogglePin && (
3827
- <span
3828
- role="button"
3829
- onClick={(e) => {
3830
- e.stopPropagation()
3831
- onTogglePin()
3832
- }}
3833
- className={clsx(
3834
- 'p-0.5 rounded transition-all hover:bg-theme-hover',
3835
- isPinned
3836
- ? 'text-theme-text-secondary'
3837
- : 'opacity-0 group-hover/kind:opacity-100 text-theme-text-disabled'
3838
- )}
3839
- title={isPinned ? 'Unpin from favorites' : 'Pin to favorites'}
3840
- >
3841
- <Pin className={clsx('w-3 h-3', isPinned && 'fill-current')} />
3842
- </span>
3843
- )}
3844
- {forbidden ? (
3845
- <Tooltip content="Insufficient permissions" position="left">
3846
- <Shield className="w-3.5 h-3.5 text-amber-400/60" />
3847
- </Tooltip>
3848
- ) : (
3849
- <span className={clsx(
3850
- 'text-xs py-0.5 rounded text-center',
3851
- isSelected ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-theme-elevated',
3852
- count < 1000 ? 'w-8' : 'w-9'
3853
- )}>
3854
- {count}
3855
- </span>
3856
- )}
3857
- </div>
3858
- </button>
3859
- )
3860
- }
3861
- )
3862
-
3863
3453
  interface ResourceRowCellsProps {
3864
3454
  resource: any
3865
3455
  kind: string
@@ -15,3 +15,5 @@ export * from './resource-utils-traefik'
15
15
  export * from './resource-utils-velero'
16
16
  export { ResourcesView, ResourcesViewDataContext } from './ResourcesView'
17
17
  export type { ResourceQueryResult } from './ResourcesView'
18
+ export { ResourcesSidebar } from './ResourcesSidebar'
19
+ export type { ResourcesSidebarProps, SelectedKindInfo, PinnedItem } from './ResourcesSidebar'