@skyhook-io/k8s-ui 1.8.9 → 1.8.11
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 +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -10,6 +10,8 @@ export interface LogStreamHandlers {
|
|
|
10
10
|
onPodAdded?: (data: unknown) => void
|
|
11
11
|
/** Called when pods are terminated during streaming (workload logs only) */
|
|
12
12
|
onPodRemoved?: (data: unknown) => void
|
|
13
|
+
/** Called when the server ends the stream cleanly */
|
|
14
|
+
onEnd?: (data: unknown) => void
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -90,11 +92,16 @@ export function useLogStream() {
|
|
|
90
92
|
}
|
|
91
93
|
})
|
|
92
94
|
|
|
93
|
-
es.addEventListener('end', () => {
|
|
95
|
+
es.addEventListener('end', (event) => {
|
|
94
96
|
if (!isCurrent()) return
|
|
95
97
|
endedRef.current = true
|
|
96
98
|
setIsStreaming(false)
|
|
97
99
|
setConnecting(false)
|
|
100
|
+
if (handlers.onEnd) {
|
|
101
|
+
try { handlers.onEnd(JSON.parse((event as MessageEvent).data)) } catch (e) {
|
|
102
|
+
console.error('Failed to parse end event:', e)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
98
105
|
})
|
|
99
106
|
|
|
100
107
|
es.addEventListener('error', (event) => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { ChevronDown, Globe, Search, AlertTriangle
|
|
3
|
+
import { ChevronDown, Globe, Search, AlertTriangle } from 'lucide-react'
|
|
4
4
|
import { Tooltip } from '../ui/Tooltip'
|
|
5
|
+
import { MultiSelectPicker } from '../ui/MultiSelectPicker'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Backend-reported namespace scope. Mirrors Radar's `/cluster/namespace-scope`
|
|
@@ -192,18 +193,6 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
192
193
|
applySelection(new Set())
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
const selectAllVisible = () => {
|
|
196
|
-
const next = new Set(draft)
|
|
197
|
-
for (const ns of filteredItems) next.add(ns)
|
|
198
|
-
setDraft(next)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const clearVisible = () => {
|
|
202
|
-
const next = new Set(draft)
|
|
203
|
-
for (const ns of filteredItems) next.delete(ns)
|
|
204
|
-
setDraft(next)
|
|
205
|
-
}
|
|
206
|
-
|
|
207
196
|
const activeCount = scopeActives.length
|
|
208
197
|
const triggerLabel =
|
|
209
198
|
activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
|
|
@@ -226,11 +215,6 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
226
215
|
? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
|
|
227
216
|
: `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
|
|
228
217
|
|
|
229
|
-
// Counts used to label the bulk-action buttons; computed against the visible
|
|
230
|
-
// (filtered) set so the labels match what the action will affect.
|
|
231
|
-
const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
|
|
232
|
-
const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
|
|
233
|
-
|
|
234
218
|
return (
|
|
235
219
|
<>
|
|
236
220
|
<Tooltip
|
|
@@ -271,100 +255,95 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
|
|
|
271
255
|
style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
|
|
272
256
|
className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
|
|
273
257
|
>
|
|
274
|
-
{items.length > 6 && (
|
|
275
|
-
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
276
|
-
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
277
|
-
<input
|
|
278
|
-
autoFocus
|
|
279
|
-
value={search}
|
|
280
|
-
onChange={e => setSearch(e.target.value)}
|
|
281
|
-
placeholder="Filter namespaces"
|
|
282
|
-
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
283
|
-
/>
|
|
284
|
-
</div>
|
|
285
|
-
)}
|
|
286
|
-
|
|
287
258
|
{scope.cacheScoped ? (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
309
|
-
>
|
|
310
|
-
{allVisibleSelected
|
|
311
|
-
? `Clear ${filteredItems.length} visible`
|
|
312
|
-
: search.trim()
|
|
313
|
-
? `Select ${filteredItems.length} visible`
|
|
314
|
-
: 'Select all'}
|
|
315
|
-
</button>
|
|
316
|
-
</div>
|
|
317
|
-
)}
|
|
259
|
+
<>
|
|
260
|
+
{items.length > 6 && (
|
|
261
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
262
|
+
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
263
|
+
<input
|
|
264
|
+
autoFocus
|
|
265
|
+
value={search}
|
|
266
|
+
onChange={e => setSearch(e.target.value)}
|
|
267
|
+
placeholder="Filter namespaces"
|
|
268
|
+
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
<div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
|
|
274
|
+
Radar is watching one namespace to stay fast on large clusters.
|
|
275
|
+
{scope.namespaceRescope
|
|
276
|
+
? ' Pick another to re-point it — takes a moment and closes open terminals.'
|
|
277
|
+
: ' This instance is locked to its startup namespace.'}
|
|
278
|
+
</div>
|
|
318
279
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
280
|
+
<ul className="max-h-80 overflow-y-auto py-1">
|
|
281
|
+
{filteredItems.length === 0 && (
|
|
282
|
+
<li className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
283
|
+
{search ? 'No matches.' : 'No namespaces available.'}
|
|
284
|
+
</li>
|
|
285
|
+
)}
|
|
325
286
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
287
|
+
{filteredItems.map(ns => {
|
|
288
|
+
const isChecked = draft.has(ns)
|
|
289
|
+
const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
|
|
290
|
+
return (
|
|
291
|
+
<li key={ns}>
|
|
292
|
+
<label className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer">
|
|
293
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
294
|
+
<input
|
|
295
|
+
type="radio"
|
|
296
|
+
name="namespace-cache-scope"
|
|
297
|
+
checked={isChecked}
|
|
298
|
+
onChange={() => toggle(ns)}
|
|
299
|
+
className="shrink-0 accent-current"
|
|
300
|
+
/>
|
|
301
|
+
<span className="truncate">{ns}</span>
|
|
302
|
+
{isContextDefault && (
|
|
303
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
304
|
+
kubeconfig
|
|
305
|
+
</span>
|
|
306
|
+
)}
|
|
346
307
|
</span>
|
|
347
|
-
|
|
348
|
-
</
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
})}
|
|
353
|
-
</ul>
|
|
308
|
+
</label>
|
|
309
|
+
</li>
|
|
310
|
+
)
|
|
311
|
+
})}
|
|
312
|
+
</ul>
|
|
354
313
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
314
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
315
|
+
<span>{draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace'}</span>
|
|
316
|
+
<button
|
|
317
|
+
onClick={closeAndApply}
|
|
318
|
+
className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
|
|
319
|
+
>
|
|
320
|
+
Done
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
</>
|
|
324
|
+
) : (
|
|
325
|
+
<MultiSelectPicker
|
|
326
|
+
items={items}
|
|
327
|
+
selected={draft}
|
|
328
|
+
onSelectionChange={setDraft}
|
|
329
|
+
onClearAll={clearAll}
|
|
330
|
+
onDone={closeAndApply}
|
|
331
|
+
search={search}
|
|
332
|
+
onSearchChange={setSearch}
|
|
333
|
+
searchPlaceholder="Filter namespaces"
|
|
334
|
+
summaryEmptyLabel="All namespaces"
|
|
335
|
+
noItemsLabel="No namespaces available."
|
|
336
|
+
clearAllDisabled={!canClearAll || activeCount === 0}
|
|
337
|
+
clearAllAriaLabel="Clear namespace selection"
|
|
338
|
+
renderItemMeta={ns =>
|
|
339
|
+
ns === scope.kubeconfigNamespace && ns !== '' ? (
|
|
340
|
+
<span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
|
|
341
|
+
kubeconfig
|
|
342
|
+
</span>
|
|
343
|
+
) : null
|
|
344
|
+
}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
368
347
|
|
|
369
348
|
{!scope.authoritative && (
|
|
370
349
|
<div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import type { APIResource } from '../../types'
|
|
4
|
+
import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar } from './ResourcesSidebar'
|
|
5
|
+
|
|
6
|
+
const sqlInstance: APIResource = {
|
|
7
|
+
group: 'sql.cnrm.cloud.google.com',
|
|
8
|
+
version: 'v1beta1',
|
|
9
|
+
kind: 'SQLInstance',
|
|
10
|
+
name: 'sqlinstances',
|
|
11
|
+
namespaced: true,
|
|
12
|
+
isCrd: true,
|
|
13
|
+
verbs: ['list'],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const horizontalPodAutoscaler: APIResource = {
|
|
17
|
+
group: 'autoscaling',
|
|
18
|
+
version: 'v2',
|
|
19
|
+
kind: 'HorizontalPodAutoscaler',
|
|
20
|
+
name: 'horizontalpodautoscalers',
|
|
21
|
+
namespaced: true,
|
|
22
|
+
isCrd: false,
|
|
23
|
+
verbs: ['list'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('ResourcesSidebar CRD group labels', () => {
|
|
27
|
+
it('keeps raw API groups available for filtering and hover recovery', () => {
|
|
28
|
+
expect(resourceMatchesSidebarFilter(sqlInstance, 'cnrm')).toBe(true)
|
|
29
|
+
expect(resourceMatchesSidebarFilter(sqlInstance, 'google')).toBe(true)
|
|
30
|
+
expect(rawCRDGroupTitle([sqlInstance])).toBe('API group: sql.cnrm.cloud.google.com')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders the raw API group in the category title while keeping the friendly label visible', () => {
|
|
34
|
+
const html = renderToString(
|
|
35
|
+
<ResourcesSidebar
|
|
36
|
+
selectedKind={null}
|
|
37
|
+
onSelectedKindChange={() => {}}
|
|
38
|
+
apiResources={[sqlInstance]}
|
|
39
|
+
resourceCounts={{ 'sql.cnrm.cloud.google.com/SQLInstance': 1 }}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(html).toContain('Config Connector')
|
|
44
|
+
expect(html).toContain('title="API group: sql.cnrm.cloud.google.com"')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('ResourcesSidebar count visibility', () => {
|
|
49
|
+
it('keeps resources visible when their count is unknown', () => {
|
|
50
|
+
const html = renderToString(
|
|
51
|
+
<ResourcesSidebar
|
|
52
|
+
selectedKind={null}
|
|
53
|
+
onSelectedKindChange={() => {}}
|
|
54
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
55
|
+
resourceCounts={{}}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(html).toContain('HorizontalPodAutoscaler')
|
|
60
|
+
expect(html).toContain('–')
|
|
61
|
+
expect(html).toContain('aria-label="Count unavailable. Open to view resources."')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('hides confirmed-empty resources by default', () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
<ResourcesSidebar
|
|
67
|
+
selectedKind={null}
|
|
68
|
+
onSelectedKindChange={() => {}}
|
|
69
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
70
|
+
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(html).not.toContain('HorizontalPodAutoscaler')
|
|
75
|
+
expect(html).toContain('Show')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('keeps the selected confirmed-empty resource visible', () => {
|
|
79
|
+
const html = renderToString(
|
|
80
|
+
<ResourcesSidebar
|
|
81
|
+
selectedKind={{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }}
|
|
82
|
+
onSelectedKindChange={() => {}}
|
|
83
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
84
|
+
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(html).toContain('HorizontalPodAutoscaler')
|
|
89
|
+
expect(html).toContain('>0</')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('keeps pinned confirmed-empty resources visible in Favorites', () => {
|
|
93
|
+
const html = renderToString(
|
|
94
|
+
<ResourcesSidebar
|
|
95
|
+
selectedKind={null}
|
|
96
|
+
onSelectedKindChange={() => {}}
|
|
97
|
+
apiResources={[horizontalPodAutoscaler]}
|
|
98
|
+
resourceCounts={{ 'autoscaling/HorizontalPodAutoscaler': 0 }}
|
|
99
|
+
pinned={[{ name: 'horizontalpodautoscalers', kind: 'HorizontalPodAutoscaler', group: 'autoscaling' }]}
|
|
100
|
+
isPinned={(name, group) => name === 'horizontalpodautoscalers' && group === 'autoscaling'}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
expect(html).toContain('Favorites')
|
|
105
|
+
expect(html).toContain('HorizontalPodAutoscaler')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -14,6 +14,7 @@ import type { APIResource } from '../../types'
|
|
|
14
14
|
import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
|
|
15
15
|
import { getResourceIcon } from '../../utils/resource-icons'
|
|
16
16
|
import { Tooltip } from '../ui/Tooltip'
|
|
17
|
+
import { Input } from '../ui/Input'
|
|
17
18
|
|
|
18
19
|
// Selected resource type info (need both name for API and kind for display)
|
|
19
20
|
export interface SelectedKindInfo {
|
|
@@ -52,29 +53,7 @@ export interface ResourcesSidebarProps {
|
|
|
52
53
|
|
|
53
54
|
// Persisted across remounts so collapsed categories survive tab switches
|
|
54
55
|
let persistedExpandedCategories: Set<string> | null = null
|
|
55
|
-
|
|
56
|
-
// Core kinds that are always shown even with 0 instances
|
|
57
|
-
// These are the most commonly used Kubernetes resources (using Kind names, not plural names)
|
|
58
|
-
const ALWAYS_SHOWN_KINDS = new Set([
|
|
59
|
-
'Pod',
|
|
60
|
-
'Deployment',
|
|
61
|
-
'DaemonSet',
|
|
62
|
-
'StatefulSet',
|
|
63
|
-
'ReplicaSet',
|
|
64
|
-
'Service',
|
|
65
|
-
'Ingress',
|
|
66
|
-
'ConfigMap',
|
|
67
|
-
'Secret',
|
|
68
|
-
'Job',
|
|
69
|
-
'CronJob',
|
|
70
|
-
'HorizontalPodAutoscaler',
|
|
71
|
-
'PersistentVolumeClaim',
|
|
72
|
-
'Node',
|
|
73
|
-
'Namespace',
|
|
74
|
-
'ServiceAccount',
|
|
75
|
-
'NetworkPolicy',
|
|
76
|
-
'Event',
|
|
77
|
-
])
|
|
56
|
+
const COUNT_UNAVAILABLE_MESSAGE = 'Count unavailable. Open to view resources.'
|
|
78
57
|
|
|
79
58
|
// Fallback resource types when API resources aren't loaded yet
|
|
80
59
|
const CORE_RESOURCE_TYPES = [
|
|
@@ -92,6 +71,25 @@ const CORE_RESOURCE_TYPES = [
|
|
|
92
71
|
{ kind: 'hpas', label: 'HPAs' },
|
|
93
72
|
] as const
|
|
94
73
|
|
|
74
|
+
export function resourceMatchesSidebarFilter(resource: Pick<APIResource, 'kind' | 'name' | 'group'>, term: string): boolean {
|
|
75
|
+
const normalized = term.trim().toLowerCase()
|
|
76
|
+
if (!normalized) return true
|
|
77
|
+
return resource.kind.toLowerCase().includes(normalized) ||
|
|
78
|
+
resource.name.toLowerCase().includes(normalized) ||
|
|
79
|
+
resource.group.toLowerCase().includes(normalized)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function rawCRDGroupTitle(resources: Pick<APIResource, 'group' | 'isCrd'>[]): string | undefined {
|
|
83
|
+
const groups = Array.from(new Set(
|
|
84
|
+
resources
|
|
85
|
+
.filter(resource => resource.isCrd && resource.group)
|
|
86
|
+
.map(resource => resource.group)
|
|
87
|
+
)).sort()
|
|
88
|
+
|
|
89
|
+
if (groups.length === 0) return undefined
|
|
90
|
+
return groups.length === 1 ? `API group: ${groups[0]}` : `API groups: ${groups.join(', ')}`
|
|
91
|
+
}
|
|
92
|
+
|
|
95
93
|
// Resource type button in sidebar
|
|
96
94
|
interface ResourceTypeButtonProps {
|
|
97
95
|
resource: APIResource
|
|
@@ -154,15 +152,25 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
|
|
|
154
152
|
<Tooltip content="Insufficient permissions" position="left">
|
|
155
153
|
<Shield className="w-3.5 h-3.5 text-amber-400/60" />
|
|
156
154
|
</Tooltip>
|
|
155
|
+
) : count === null ? (
|
|
156
|
+
<Tooltip content={COUNT_UNAVAILABLE_MESSAGE} position="left">
|
|
157
|
+
<span
|
|
158
|
+
className={clsx(
|
|
159
|
+
'text-xs py-0.5 rounded text-center font-mono w-8 text-theme-text-disabled',
|
|
160
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
161
|
+
)}
|
|
162
|
+
aria-label={COUNT_UNAVAILABLE_MESSAGE}
|
|
163
|
+
>
|
|
164
|
+
–
|
|
165
|
+
</span>
|
|
166
|
+
</Tooltip>
|
|
157
167
|
) : (
|
|
158
168
|
<span className={clsx(
|
|
159
169
|
'text-xs py-0.5 rounded text-center font-mono',
|
|
160
170
|
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
161
|
-
count
|
|
162
|
-
? 'w-8 text-theme-text-disabled'
|
|
163
|
-
: count < 1000 ? 'w-8' : 'w-9',
|
|
171
|
+
count < 1000 ? 'w-8' : 'w-9',
|
|
164
172
|
)}>
|
|
165
|
-
{count
|
|
173
|
+
{count}
|
|
166
174
|
</span>
|
|
167
175
|
)}
|
|
168
176
|
</div>
|
|
@@ -280,7 +288,7 @@ export function ResourcesSidebar({
|
|
|
280
288
|
} else if (key in resourceCounts) {
|
|
281
289
|
results[key] = resourceCounts[key] ?? null
|
|
282
290
|
} else {
|
|
283
|
-
results[key] =
|
|
291
|
+
results[key] = null
|
|
284
292
|
}
|
|
285
293
|
}
|
|
286
294
|
return results
|
|
@@ -307,14 +315,14 @@ export function ResourcesSidebar({
|
|
|
307
315
|
0
|
|
308
316
|
)
|
|
309
317
|
|
|
310
|
-
// Filter resources:
|
|
311
|
-
//
|
|
312
|
-
// showEmptyKinds is true.
|
|
318
|
+
// Filter resources: hide only confirmed-empty kinds. Unknown counts stay
|
|
319
|
+
// visible as a dash so count coverage gaps do not masquerade as emptiness.
|
|
313
320
|
const visibleResources = category.resources.filter(resource => {
|
|
314
321
|
const count = counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
322
|
+
const isSelectedResource =
|
|
323
|
+
(effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
|
|
324
|
+
(effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
|
|
325
|
+
const shouldShow = isSelectedResource || count === null || (count ?? 0) > 0 || showEmptyKinds
|
|
318
326
|
if (!shouldShow) totalHiddenKinds++
|
|
319
327
|
return shouldShow
|
|
320
328
|
})
|
|
@@ -329,16 +337,15 @@ export function ResourcesSidebar({
|
|
|
329
337
|
return 0
|
|
330
338
|
})
|
|
331
339
|
|
|
332
|
-
// Filter out empty groups unless they have visible resources
|
|
340
|
+
// Filter out empty groups unless they have visible resources or showEmptyKinds is true.
|
|
333
341
|
const visibleCategories = sorted.filter(category => {
|
|
334
|
-
// Show if: has resources with instances, OR has visible resources (core kinds), OR showEmptyKinds
|
|
335
342
|
const shouldShow = category.total > 0 || category.visibleResources.length > 0 || showEmptyKinds
|
|
336
343
|
if (!shouldShow) totalHiddenGroups++
|
|
337
344
|
return shouldShow
|
|
338
345
|
})
|
|
339
346
|
|
|
340
347
|
return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
|
|
341
|
-
}, [categories, counts, showEmptyKinds])
|
|
348
|
+
}, [categories, counts, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group])
|
|
342
349
|
|
|
343
350
|
// Filter sidebar categories/kinds by the kind search term
|
|
344
351
|
const filteredCategories = useMemo(() => {
|
|
@@ -347,12 +354,10 @@ export function ResourcesSidebar({
|
|
|
347
354
|
return sortedCategories
|
|
348
355
|
.map(category => {
|
|
349
356
|
const categoryMatches = category.name.toLowerCase().includes(term)
|
|
357
|
+
const rawGroupMatches = category.resources.some((resource: APIResource) => resource.group.toLowerCase().includes(term))
|
|
350
358
|
// If the group name matches, show all its resources
|
|
351
|
-
if (categoryMatches) return category
|
|
352
|
-
const matchingResources = category.visibleResources.filter((resource:
|
|
353
|
-
resource.kind.toLowerCase().includes(term) ||
|
|
354
|
-
resource.name.toLowerCase().includes(term)
|
|
355
|
-
)
|
|
359
|
+
if (categoryMatches || rawGroupMatches) return category
|
|
360
|
+
const matchingResources = category.visibleResources.filter((resource: APIResource) => resourceMatchesSidebarFilter(resource, term))
|
|
356
361
|
if (matchingResources.length === 0) return null
|
|
357
362
|
return {
|
|
358
363
|
...category,
|
|
@@ -472,9 +477,8 @@ export function ResourcesSidebar({
|
|
|
472
477
|
<div className="px-2 py-3 border-b border-theme-border">
|
|
473
478
|
<div className="relative">
|
|
474
479
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
475
|
-
<
|
|
480
|
+
<Input
|
|
476
481
|
ref={kindSearchRef}
|
|
477
|
-
type="text"
|
|
478
482
|
placeholder="Filter resources..."
|
|
479
483
|
value={kindFilter}
|
|
480
484
|
onChange={(e) => setKindFilter(e.target.value)}
|
|
@@ -550,6 +554,7 @@ export function ResourcesSidebar({
|
|
|
550
554
|
// Dynamic categories from API
|
|
551
555
|
filteredCategories.map((category) => {
|
|
552
556
|
const isExpanded = effectiveExpandedCategories.has(category.name)
|
|
557
|
+
const rawGroupTitle = rawCRDGroupTitle(category.resources)
|
|
553
558
|
return (
|
|
554
559
|
<div key={category.name} className="mb-2">
|
|
555
560
|
<button
|
|
@@ -561,7 +566,7 @@ export function ResourcesSidebar({
|
|
|
561
566
|
) : (
|
|
562
567
|
<ChevronRight className="w-3 h-3" />
|
|
563
568
|
)}
|
|
564
|
-
<span className="flex-1 text-left">{category.name}</span>
|
|
569
|
+
<span className="flex-1 text-left truncate" title={rawGroupTitle}>{category.name}</span>
|
|
565
570
|
{!isExpanded && (
|
|
566
571
|
<span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono', category.total < 1000 ? 'w-8' : 'w-9')}>
|
|
567
572
|
{category.total}
|
|
@@ -629,13 +634,26 @@ export function ResourcesSidebar({
|
|
|
629
634
|
>
|
|
630
635
|
<Icon className="w-4 h-4 shrink-0" />
|
|
631
636
|
<span className="flex-1 text-left">{type.label}</span>
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
637
|
+
{count === null ? (
|
|
638
|
+
<Tooltip content={COUNT_UNAVAILABLE_MESSAGE} position="left">
|
|
639
|
+
<span
|
|
640
|
+
className={clsx(
|
|
641
|
+
'badge font-mono text-theme-text-disabled',
|
|
642
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
643
|
+
)}
|
|
644
|
+
aria-label={COUNT_UNAVAILABLE_MESSAGE}
|
|
645
|
+
>
|
|
646
|
+
–
|
|
647
|
+
</span>
|
|
648
|
+
</Tooltip>
|
|
649
|
+
) : (
|
|
650
|
+
<span className={clsx(
|
|
651
|
+
'badge font-mono',
|
|
652
|
+
isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
|
|
653
|
+
)}>
|
|
654
|
+
{count}
|
|
655
|
+
</span>
|
|
656
|
+
)}
|
|
639
657
|
</button>
|
|
640
658
|
)
|
|
641
659
|
})
|