@skyhook-io/k8s-ui 1.8.10 → 1.8.12

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 (168) hide show
  1. package/package.json +7 -6
  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 +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. package/src/utils/workload-colors.ts +2 -3
@@ -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">
@@ -13,6 +13,16 @@ const sqlInstance: APIResource = {
13
13
  verbs: ['list'],
14
14
  }
15
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
+
16
26
  describe('ResourcesSidebar CRD group labels', () => {
17
27
  it('keeps raw API groups available for filtering and hover recovery', () => {
18
28
  expect(resourceMatchesSidebarFilter(sqlInstance, 'cnrm')).toBe(true)
@@ -34,3 +44,64 @@ describe('ResourcesSidebar CRD group labels', () => {
34
44
  expect(html).toContain('title="API group: sql.cnrm.cloud.google.com"')
35
45
  })
36
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 = [
@@ -173,15 +152,25 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
173
152
  <Tooltip content="Insufficient permissions" position="left">
174
153
  <Shield className="w-3.5 h-3.5 text-amber-400/60" />
175
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>
176
167
  ) : (
177
168
  <span className={clsx(
178
169
  'text-xs py-0.5 rounded text-center font-mono',
179
170
  isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
180
- count === null
181
- ? 'w-8 text-theme-text-disabled'
182
- : count < 1000 ? 'w-8' : 'w-9',
171
+ count < 1000 ? 'w-8' : 'w-9',
183
172
  )}>
184
- {count === null ? '–' : count}
173
+ {count}
185
174
  </span>
186
175
  )}
187
176
  </div>
@@ -299,7 +288,7 @@ export function ResourcesSidebar({
299
288
  } else if (key in resourceCounts) {
300
289
  results[key] = resourceCounts[key] ?? null
301
290
  } else {
302
- results[key] = 0
291
+ results[key] = null
303
292
  }
304
293
  }
305
294
  return results
@@ -326,14 +315,14 @@ export function ResourcesSidebar({
326
315
  0
327
316
  )
328
317
 
329
- // Filter resources: show if has instances, is core kind, has an
330
- // unknown count (loading — don't pre-emptively hide), or
331
- // 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.
332
320
  const visibleResources = category.resources.filter(resource => {
333
321
  const count = counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]
334
- const isCore = ALWAYS_SHOWN_KINDS.has(resource.kind)
335
- const isLoading = count === null
336
- 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
337
326
  if (!shouldShow) totalHiddenKinds++
338
327
  return shouldShow
339
328
  })
@@ -348,16 +337,15 @@ export function ResourcesSidebar({
348
337
  return 0
349
338
  })
350
339
 
351
- // 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.
352
341
  const visibleCategories = sorted.filter(category => {
353
- // Show if: has resources with instances, OR has visible resources (core kinds), OR showEmptyKinds
354
342
  const shouldShow = category.total > 0 || category.visibleResources.length > 0 || showEmptyKinds
355
343
  if (!shouldShow) totalHiddenGroups++
356
344
  return shouldShow
357
345
  })
358
346
 
359
347
  return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
360
- }, [categories, counts, showEmptyKinds])
348
+ }, [categories, counts, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group])
361
349
 
362
350
  // Filter sidebar categories/kinds by the kind search term
363
351
  const filteredCategories = useMemo(() => {
@@ -489,9 +477,8 @@ export function ResourcesSidebar({
489
477
  <div className="px-2 py-3 border-b border-theme-border">
490
478
  <div className="relative">
491
479
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
492
- <input
480
+ <Input
493
481
  ref={kindSearchRef}
494
- type="text"
495
482
  placeholder="Filter resources..."
496
483
  value={kindFilter}
497
484
  onChange={(e) => setKindFilter(e.target.value)}
@@ -647,13 +634,26 @@ export function ResourcesSidebar({
647
634
  >
648
635
  <Icon className="w-4 h-4 shrink-0" />
649
636
  <span className="flex-1 text-left">{type.label}</span>
650
- <span className={clsx(
651
- 'badge font-mono',
652
- isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
653
- count === null && 'text-theme-text-disabled',
654
- )}>
655
- {count === null ? '–' : count}
656
- </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
+ )}
657
657
  </button>
658
658
  )
659
659
  })
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { APIResource } from '../../types'
3
+ import {
4
+ canonicalizeSelectedKind,
5
+ deriveSidebarResourceCounts,
6
+ LOADED_RESOURCE_COUNT_TTL_MS,
7
+ resourceQueryMatchesSelectedKind,
8
+ } from './ResourcesView'
9
+
10
+ const endpoints: APIResource = {
11
+ group: '',
12
+ version: 'v1',
13
+ kind: 'Endpoints',
14
+ name: 'endpoints',
15
+ namespaced: true,
16
+ isCrd: false,
17
+ verbs: ['list', 'get', 'watch'],
18
+ }
19
+
20
+ const httpRoute: APIResource = {
21
+ group: 'gateway.networking.k8s.io',
22
+ version: 'v1',
23
+ kind: 'HTTPRoute',
24
+ name: 'httproutes',
25
+ namespaced: true,
26
+ isCrd: true,
27
+ verbs: ['list', 'get', 'watch'],
28
+ }
29
+
30
+ describe('canonicalizeSelectedKind', () => {
31
+ it('restores the canonical Kind when URL hydration only has the plural resource name', () => {
32
+ expect(
33
+ canonicalizeSelectedKind(
34
+ { name: 'endpoints', kind: 'endpoints', group: '' },
35
+ [endpoints],
36
+ [endpoints]
37
+ )
38
+ ).toEqual({ name: 'endpoints', kind: 'Endpoints', group: '' })
39
+ })
40
+
41
+ it('leaves an already canonical selection alone', () => {
42
+ expect(
43
+ canonicalizeSelectedKind(
44
+ { name: 'endpoints', kind: 'Endpoints', group: '' },
45
+ [endpoints],
46
+ [endpoints]
47
+ )
48
+ ).toBeNull()
49
+ })
50
+
51
+ it('resolves CRD deep links from Kind to plural resource name', () => {
52
+ expect(
53
+ canonicalizeSelectedKind(
54
+ { name: 'HTTPRoute', kind: 'HTTPRoute', group: 'gateway.networking.k8s.io' },
55
+ [],
56
+ [httpRoute]
57
+ )
58
+ ).toEqual({ name: 'httproutes', kind: 'HTTPRoute', group: 'gateway.networking.k8s.io' })
59
+ })
60
+ })
61
+
62
+ describe('deriveSidebarResourceCounts', () => {
63
+ it('keeps recently loaded counts for resources omitted from resource-counts', () => {
64
+ const now = 10_000
65
+
66
+ expect(
67
+ deriveSidebarResourceCounts(
68
+ [endpoints],
69
+ {},
70
+ undefined,
71
+ { Endpoints: { count: 132, expiresAt: now + LOADED_RESOURCE_COUNT_TTL_MS } },
72
+ now
73
+ )
74
+ ).toEqual({ Endpoints: 132 })
75
+ })
76
+
77
+ it('expires loaded counts after the TTL', () => {
78
+ const now = 10_000
79
+
80
+ expect(
81
+ deriveSidebarResourceCounts(
82
+ [endpoints],
83
+ {},
84
+ undefined,
85
+ { Endpoints: { count: 132, expiresAt: now } },
86
+ now
87
+ )
88
+ ).toEqual({ Endpoints: null })
89
+ })
90
+
91
+ it('prefers authoritative resource-counts over cached loaded counts', () => {
92
+ const now = 10_000
93
+
94
+ expect(
95
+ deriveSidebarResourceCounts(
96
+ [endpoints],
97
+ { Endpoints: 7 },
98
+ undefined,
99
+ { Endpoints: { count: 132, expiresAt: now + LOADED_RESOURCE_COUNT_TTL_MS } },
100
+ now
101
+ )
102
+ ).toEqual({ Endpoints: 7 })
103
+ })
104
+
105
+ it('lets a fresh loaded count override an unavailable count marker', () => {
106
+ const now = 10_000
107
+
108
+ expect(
109
+ deriveSidebarResourceCounts(
110
+ [endpoints],
111
+ {},
112
+ ['Endpoints'],
113
+ { Endpoints: { count: 132, expiresAt: now + LOADED_RESOURCE_COUNT_TTL_MS } },
114
+ now
115
+ )
116
+ ).toEqual({ Endpoints: 132 })
117
+ })
118
+ })
119
+
120
+ describe('resourceQueryMatchesSelectedKind', () => {
121
+ it('accepts untagged legacy query results', () => {
122
+ expect(resourceQueryMatchesSelectedKind(undefined, { name: 'endpoints', kind: 'Endpoints', group: '' })).toBe(true)
123
+ expect(resourceQueryMatchesSelectedKind({}, { name: 'endpoints', kind: 'Endpoints', group: '' })).toBe(true)
124
+ })
125
+
126
+ it('rejects stale selected-kind query results from the previous kind', () => {
127
+ expect(
128
+ resourceQueryMatchesSelectedKind(
129
+ { resourceName: 'pods', group: '' },
130
+ { name: 'endpoints', kind: 'Endpoints', group: '' }
131
+ )
132
+ ).toBe(false)
133
+ })
134
+
135
+ it('matches grouped built-in resources by plural name and group', () => {
136
+ expect(
137
+ resourceQueryMatchesSelectedKind(
138
+ { resourceName: 'endpointslices', group: 'discovery.k8s.io' },
139
+ { name: 'endpointslices', kind: 'EndpointSlice', group: 'discovery.k8s.io' }
140
+ )
141
+ ).toBe(true)
142
+ })
143
+ })