@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.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. 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, X } from 'lucide-react'
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
- <div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
289
- Radar is watching one namespace to stay fast on large clusters.
290
- {scope.namespaceRescope
291
- ? ' Pick another to re-point it — takes a moment and closes open terminals.'
292
- : ' This instance is locked to its startup namespace.'}
293
- </div>
294
- ) : (
295
- <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
296
- <button
297
- onClick={canClearAll ? clearAll : undefined}
298
- disabled={!canClearAll || activeCount === 0}
299
- className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
300
- aria-label="Clear namespace selection"
301
- >
302
- <X className="w-3 h-3" />
303
- Clear all
304
- </button>
305
- <button
306
- onClick={allVisibleSelected ? clearVisible : selectAllVisible}
307
- disabled={filteredItems.length === 0}
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
- <ul className="max-h-80 overflow-y-auto py-1">
320
- {filteredItems.length === 0 && (
321
- <li className="px-3 py-2 text-xs text-theme-text-tertiary">
322
- {search ? 'No matches.' : 'No namespaces available.'}
323
- </li>
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
- {filteredItems.map(ns => {
327
- const isChecked = draft.has(ns)
328
- const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
329
- return (
330
- <li key={ns}>
331
- <label
332
- 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"
333
- >
334
- <span className="flex items-center gap-2 min-w-0">
335
- <input
336
- type={scope.cacheScoped ? 'radio' : 'checkbox'}
337
- name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
338
- checked={isChecked}
339
- onChange={() => toggle(ns)}
340
- className="shrink-0 accent-current"
341
- />
342
- <span className="truncate">{ns}</span>
343
- {isContextDefault && (
344
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
345
- kubeconfig
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
- </span>
349
- </label>
350
- </li>
351
- )
352
- })}
353
- </ul>
308
+ </label>
309
+ </li>
310
+ )
311
+ })}
312
+ </ul>
354
313
 
355
- <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
356
- <span>
357
- {scope.cacheScoped
358
- ? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
359
- : draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
360
- </span>
361
- <button
362
- onClick={closeAndApply}
363
- className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
364
- >
365
- Done
366
- </button>
367
- </div>
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 === null
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 === null ? '–' : 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] = 0
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: show if has instances, is core kind, has an
311
- // unknown count (loading — don't pre-emptively hide), or
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 isCore = ALWAYS_SHOWN_KINDS.has(resource.kind)
316
- const isLoading = count === null
317
- const shouldShow = (count ?? 0) > 0 || isCore || isLoading || showEmptyKinds
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 (core kinds) or showEmptyKinds is true
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: any) =>
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
- <input
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
- <span className={clsx(
633
- 'badge font-mono',
634
- isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
635
- count === null && 'text-theme-text-disabled',
636
- )}>
637
- {count === null ? '–' : count}
638
- </span>
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
  })