@skyhook-io/k8s-ui 1.2.0 → 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
|
@@ -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,
|
|
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
|
|
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 (
|
|
2159
|
-
|
|
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
|
|
2208
|
-
if (
|
|
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 (
|
|
2854
|
-
for (const cat of
|
|
2855
|
-
for (const r of cat.
|
|
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,
|
|
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
|
-
|
|
3074
|
-
<
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
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'
|