@skyhook-io/k8s-ui 1.16.3 → 1.17.1

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 (309) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  4. package/src/components/applications/ApplicationDetail.tsx +17 -15
  5. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  6. package/src/components/charts/AreaChart.test.tsx +25 -0
  7. package/src/components/charts/AreaChart.tsx +141 -12
  8. package/src/components/charts/index.ts +1 -0
  9. package/src/components/checks/ChecksView.tsx +18 -1
  10. package/src/components/checks/types.ts +2 -13
  11. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  12. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  13. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  14. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  15. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  16. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  17. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  18. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  19. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  20. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  21. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  22. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  23. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  24. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  25. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  26. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  27. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  28. package/src/components/cnpg/backupRuns.test.ts +17 -0
  29. package/src/components/cnpg/backupRuns.ts +19 -0
  30. package/src/components/cnpg/connect.test.ts +124 -0
  31. package/src/components/cnpg/connect.ts +155 -0
  32. package/src/components/cnpg/databaseRole.test.ts +38 -0
  33. package/src/components/cnpg/databaseRole.ts +80 -0
  34. package/src/components/cnpg/ha.test.ts +446 -0
  35. package/src/components/cnpg/ha.ts +621 -0
  36. package/src/components/cnpg/index.ts +18 -0
  37. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  38. package/src/components/cnpg/logicalReplication.ts +252 -0
  39. package/src/components/cnpg/pooler.test.ts +84 -0
  40. package/src/components/cnpg/pooler.ts +225 -0
  41. package/src/components/cnpg/primitives.tsx +21 -117
  42. package/src/components/cnpg/problems.ts +12 -0
  43. package/src/components/cnpg/relations.test.ts +63 -13
  44. package/src/components/cnpg/relations.ts +61 -52
  45. package/src/components/cnpg/schedule.ts +35 -0
  46. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  47. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  48. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  49. package/src/components/cnpg/workspace.test.ts +380 -9
  50. package/src/components/cnpg/workspace.ts +1137 -136
  51. package/src/components/dock/DockContext.test.tsx +32 -0
  52. package/src/components/dock/DockContext.tsx +18 -4
  53. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  54. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  55. package/src/components/dock/TerminalTab.tsx +20 -8
  56. package/src/components/facts/certainty.tsx +36 -0
  57. package/src/components/facts/facts.test.tsx +24 -0
  58. package/src/components/facts/facts.tsx +62 -0
  59. package/src/components/facts/index.ts +6 -0
  60. package/src/components/facts/managed-by.test.tsx +17 -0
  61. package/src/components/facts/managed-by.tsx +36 -0
  62. package/src/components/facts/public-exports.test.tsx +35 -0
  63. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  64. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  65. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  66. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  67. package/src/components/gitops/detail-helpers.ts +1 -1
  68. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  69. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  70. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  71. package/src/components/gitops/insights/GitOpsInsightViews.tsx +29 -83
  72. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  73. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +40 -60
  74. package/src/components/gitops/tree/layout.test.ts +111 -0
  75. package/src/components/gitops/tree/layout.ts +128 -0
  76. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  77. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  78. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  79. package/src/components/issues/IssuesView.tsx +40 -5
  80. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  81. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  82. package/src/components/issues/index.ts +3 -1
  83. package/src/components/issues/issue-timing.ts +14 -3
  84. package/src/components/issues/issues.test.ts +50 -2
  85. package/src/components/issues/severity.ts +21 -0
  86. package/src/components/issues/types.ts +23 -0
  87. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  88. package/src/components/logs/LogCore.tsx +25 -17
  89. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  90. package/src/components/logs/LogsViewer.test.tsx +291 -0
  91. package/src/components/logs/LogsViewer.tsx +72 -11
  92. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  93. package/src/components/logs/StructuredLogLine.tsx +22 -30
  94. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  95. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  96. package/src/components/logs/WorkloadLogsViewer.tsx +44 -10
  97. package/src/components/logs/useLogBuffer.test.ts +8 -0
  98. package/src/components/logs/useLogStream.ts +28 -1
  99. package/src/components/problems/index.ts +1 -0
  100. package/src/components/problems/problems.test.tsx +63 -0
  101. package/src/components/problems/problems.tsx +165 -0
  102. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  103. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  104. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  105. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  106. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  107. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  108. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  109. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  110. package/src/components/resources/ResourcesView.tsx +309 -117
  111. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  112. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  113. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  114. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  115. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +5 -4
  116. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  117. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  118. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  119. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  120. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  121. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  122. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  124. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +7 -8
  125. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  127. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +9 -17
  128. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  130. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  131. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  132. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  133. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  134. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  135. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  136. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  137. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  138. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +62 -0
  139. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +15 -14
  140. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  141. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  142. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  143. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  144. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  145. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  146. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  147. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  148. package/src/components/resources/renderers/HPARenderer.tsx +57 -17
  149. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  150. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  151. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  152. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  153. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  154. package/src/components/resources/renderers/IngressRenderer.tsx +36 -5
  155. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  156. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  157. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  158. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  159. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  160. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +15 -13
  161. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  163. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  164. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  165. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  166. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  167. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  170. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  171. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  172. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  173. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  174. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  175. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  176. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  177. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  178. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  179. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  180. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  181. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  182. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  183. package/src/components/resources/renderers/PodRenderer.tsx +62 -36
  184. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  185. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  186. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  187. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  188. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  189. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  190. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  191. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +9 -22
  192. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  193. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  194. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  195. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  196. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  197. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  198. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  199. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  200. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  201. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  202. package/src/components/resources/resource-utils-capi.ts +1 -1
  203. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  204. package/src/components/resources/resource-utils-cnpg.ts +51 -24
  205. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  206. package/src/components/resources/resource-utils-contour.ts +16 -0
  207. package/src/components/resources/resource-utils-istio.ts +12 -8
  208. package/src/components/resources/resource-utils-keda.ts +1 -7
  209. package/src/components/resources/resource-utils-knative.ts +18 -0
  210. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  211. package/src/components/resources/resource-utils-velero.ts +0 -12
  212. package/src/components/resources/resource-utils.ts +99 -39
  213. package/src/components/resources/rules-column-width.test.ts +1 -1
  214. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  215. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  216. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  217. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  218. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  219. package/src/components/shared/DetailShell.test.tsx +80 -0
  220. package/src/components/shared/DetailShell.tsx +85 -10
  221. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  222. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  223. package/src/components/shared/EditableYamlView.tsx +1 -1
  224. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  225. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  226. package/src/components/shared/ResourceActionsBar.tsx +20 -24
  227. package/src/components/shared/ResourceRendererDispatch.tsx +13 -10
  228. package/src/components/shared/SetImageDialog.test.ts +75 -6
  229. package/src/components/shared/SetImageDialog.tsx +92 -84
  230. package/src/components/shared/index.ts +4 -1
  231. package/src/components/timeline/TimelineList.tsx +2 -15
  232. package/src/components/timeline/TimelineSwimlanes.tsx +3 -11
  233. package/src/components/timeline/timeline-filters.ts +1 -0
  234. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  235. package/src/components/topology/K8sResourceNode.tsx +44 -2
  236. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  237. package/src/components/topology/TopologyGraph.tsx +8 -74
  238. package/src/components/topology/TopologySearch.tsx +1 -0
  239. package/src/components/topology/internet-groups.test.ts +35 -0
  240. package/src/components/topology/internet-groups.ts +55 -0
  241. package/src/components/topology/layout.ts +1 -1
  242. package/src/components/ui/Badge.tsx +1 -0
  243. package/src/components/ui/FoldSection.tsx +68 -0
  244. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  245. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  246. package/src/components/ui/RefLink.tsx +20 -0
  247. package/src/components/ui/RowActionMenu.tsx +8 -3
  248. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  249. package/src/components/ui/ScheduleValue.tsx +3 -2
  250. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  251. package/src/components/ui/drawer-components.test.tsx +175 -1
  252. package/src/components/ui/drawer-components.tsx +103 -36
  253. package/src/components/ui/index.ts +3 -1
  254. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  255. package/src/components/ui/monacoRuntime.ts +14 -19
  256. package/src/components/ui/status-tone.test.ts +10 -1
  257. package/src/components/ui/status-tone.tsx +29 -0
  258. package/src/components/workload/WorkloadView.deleted.test.tsx +101 -0
  259. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  260. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  261. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  262. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  263. package/src/components/workload/WorkloadView.tsx +297 -104
  264. package/src/index.ts +14 -1
  265. package/src/monaco-deep.d.ts +19 -12
  266. package/src/theme/components.css +13 -0
  267. package/src/types/capacity.ts +21 -2
  268. package/src/types/core.ts +60 -2
  269. package/src/types/gitops-flux-status.test.ts +32 -1
  270. package/src/types/gitops-insights.ts +19 -2
  271. package/src/types/gitops-tree.ts +10 -0
  272. package/src/types/gitops.ts +26 -6
  273. package/src/utils/api-resources.ts +7 -0
  274. package/src/utils/application-history.ts +1 -0
  275. package/src/utils/cnpg-backup.test.ts +26 -0
  276. package/src/utils/cnpg-backup.ts +70 -0
  277. package/src/utils/format.test.ts +49 -2
  278. package/src/utils/format.ts +9 -1
  279. package/src/utils/gateway-references.test.ts +17 -0
  280. package/src/utils/gateway-references.ts +29 -0
  281. package/src/utils/gitops-owner.ts +3 -2
  282. package/src/utils/gitops-write-guard.test.ts +401 -0
  283. package/src/utils/gitops-write-guard.ts +509 -0
  284. package/src/utils/grant.test.ts +19 -0
  285. package/src/utils/grant.ts +35 -0
  286. package/src/utils/index.ts +6 -0
  287. package/src/utils/ingress-class-references.ts +22 -0
  288. package/src/utils/k8s-errors.ts +7 -7
  289. package/src/utils/log-format.test.ts +41 -0
  290. package/src/utils/log-format.ts +71 -15
  291. package/src/utils/log-level.ts +12 -8
  292. package/src/utils/navigation.test.ts +70 -0
  293. package/src/utils/navigation.ts +91 -8
  294. package/src/utils/node-lifecycle.test.ts +36 -0
  295. package/src/utils/node-lifecycle.ts +77 -0
  296. package/src/utils/object-reference-navigation.test.ts +43 -0
  297. package/src/utils/resource-hierarchy.test.ts +69 -0
  298. package/src/utils/resource-hierarchy.ts +41 -7
  299. package/src/utils/resource-icons.ts +2 -0
  300. package/src/utils/resource-refs.test.ts +23 -0
  301. package/src/utils/resource-refs.ts +30 -0
  302. package/src/utils/skeleton-yaml.test.ts +21 -0
  303. package/src/utils/skeleton-yaml.ts +16 -0
  304. package/src/utils/url-path.test.ts +35 -0
  305. package/src/utils/url-path.ts +21 -0
  306. package/src/utils/validators.test.ts +4 -13
  307. package/src/utils/validators.ts +5 -19
  308. package/src/utils/view-transition.test.ts +35 -1
  309. package/src/utils/view-transition.ts +16 -1
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { ActiveFilterBar, type ActiveFilterBarProps } from './ActiveFilterBar'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ let root: Root | null = null
10
+ let element: HTMLDivElement
11
+
12
+ async function render(overrides: Partial<ActiveFilterBarProps> = {}) {
13
+ const props: ActiveFilterBarProps = {
14
+ columnFilters: {},
15
+ columnFilterExcludes: {},
16
+ problemFilters: [],
17
+ labelSelector: '',
18
+ onClearColumn: vi.fn(),
19
+ onClearProblems: vi.fn(),
20
+ onClearLabels: vi.fn(),
21
+ ...overrides,
22
+ }
23
+ element = document.createElement('div')
24
+ root = createRoot(element)
25
+ await act(async () => root!.render(<ActiveFilterBar {...props} />))
26
+ return props
27
+ }
28
+
29
+ function buttonWithText(text: string): HTMLButtonElement {
30
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(text))
31
+ if (!button) throw new Error(`no button containing "${text}"`)
32
+ return button
33
+ }
34
+
35
+ afterEach(async () => {
36
+ await act(async () => root?.unmount())
37
+ root = null
38
+ })
39
+
40
+ describe('ActiveFilterBar', () => {
41
+ it('renders nothing without active filters or a restore', async () => {
42
+ await render({ columnFilters: { status: [] } })
43
+ expect(element.innerHTML).toBe('')
44
+ })
45
+
46
+ it('renders one chip per active filter, marking excluded columns', async () => {
47
+ await render({
48
+ columnFilters: { status: ['Running', 'Pending'], node: ['n1'], empty: [] },
49
+ columnFilterExcludes: { node: true },
50
+ problemFilters: ['crashloop'],
51
+ labelSelector: 'app=nginx',
52
+ })
53
+ expect(element.querySelectorAll('button')).toHaveLength(4)
54
+ expect(element.textContent).toContain('status: Running, Pending')
55
+ expect(element.textContent).toContain('node: not n1')
56
+ expect(element.textContent).toContain('Problems: crashloop')
57
+ expect(element.textContent).toContain('app=nginx')
58
+ expect(element.textContent).not.toContain('restored')
59
+ })
60
+
61
+ it('calls the matching clear handler when a chip is clicked', async () => {
62
+ const props = await render({ columnFilters: { status: ['Running'] }, problemFilters: ['oom'], labelSelector: 'tier=web' })
63
+ await act(async () => buttonWithText('status:').click())
64
+ await act(async () => buttonWithText('Problems:').click())
65
+ await act(async () => buttonWithText('tier=web').click())
66
+ expect(props.onClearColumn).toHaveBeenCalledWith('status')
67
+ expect(props.onClearProblems).toHaveBeenCalledOnce()
68
+ expect(props.onClearLabels).toHaveBeenCalledOnce()
69
+ })
70
+
71
+ it('shows the restored line only when restored, with Clear calling onClearAll', async () => {
72
+ const onClearAll = vi.fn()
73
+ await render({ restored: true, onClearAll, columnFilters: { status: ['Running'] } })
74
+ expect(element.textContent).toContain('Filters restored from your last visit')
75
+ await act(async () => buttonWithText('Clear').click())
76
+ expect(onClearAll).toHaveBeenCalledOnce()
77
+ })
78
+ })
@@ -0,0 +1,86 @@
1
+ import { clsx } from 'clsx'
2
+ import { AlertTriangle, History, ListFilter, Tag, X } from 'lucide-react'
3
+
4
+ export interface ActiveFilterBarProps {
5
+ columnFilters: Record<string, string[]>
6
+ columnFilterExcludes: Record<string, boolean>
7
+ problemFilters: string[]
8
+ labelSelector: string
9
+ onClearColumn: (key: string) => void
10
+ onClearProblems: () => void
11
+ onClearLabels: () => void
12
+ restored?: boolean
13
+ onClearAll?: () => void
14
+ className?: string
15
+ }
16
+
17
+ export function ActiveFilterBar({
18
+ columnFilters,
19
+ columnFilterExcludes,
20
+ problemFilters,
21
+ labelSelector,
22
+ onClearColumn,
23
+ onClearProblems,
24
+ onClearLabels,
25
+ restored = false,
26
+ onClearAll,
27
+ className,
28
+ }: ActiveFilterBarProps) {
29
+ const activeColEntries = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
30
+ const hasChips = activeColEntries.length > 0 || problemFilters.length > 0 || !!labelSelector
31
+ if (!hasChips && !restored) return null
32
+
33
+ return (
34
+ <div className={clsx('flex flex-wrap items-center gap-1.5', className)}>
35
+ {restored && (
36
+ <span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
37
+ <History className="w-3 h-3" />
38
+ <span>Filters restored from your last visit.</span>
39
+ {onClearAll && (
40
+ <button
41
+ type="button"
42
+ onClick={onClearAll}
43
+ className="text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2"
44
+ >
45
+ Clear
46
+ </button>
47
+ )}
48
+ </span>
49
+ )}
50
+ {activeColEntries.map(([key, vals]) => (
51
+ <button
52
+ type="button"
53
+ key={key}
54
+ onClick={() => onClearColumn(key)}
55
+ className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
56
+ >
57
+ <ListFilter className="w-3 h-3" />
58
+ <span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
59
+ <X className="w-3 h-3" />
60
+ </button>
61
+ ))}
62
+ {problemFilters.length > 0 && (
63
+ <button
64
+ type="button"
65
+ onClick={onClearProblems}
66
+ className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/15 text-red-700 dark:text-red-300 rounded-md hover:bg-red-500/25 transition-colors"
67
+ >
68
+ <AlertTriangle className="w-3 h-3" />
69
+ <span>Problems: {problemFilters.join(', ')}</span>
70
+ <X className="w-3 h-3" />
71
+ </button>
72
+ )}
73
+ {labelSelector && (
74
+ <button
75
+ type="button"
76
+ onClick={onClearLabels}
77
+ className="flex items-center gap-1 px-2 py-1 text-xs bg-green-500/15 text-green-700 dark:text-green-300 rounded-md hover:bg-green-500/25 transition-colors"
78
+ >
79
+ <Tag className="w-3 h-3" />
80
+ <span>{labelSelector}</span>
81
+ <X className="w-3 h-3" />
82
+ </button>
83
+ )}
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,42 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, expect, it, vi } from 'vitest'
5
+ import { ResourcesSidebar } from './ResourcesSidebar'
6
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
7
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
8
+ let root: ReturnType<typeof createRoot>
9
+ let host: HTMLDivElement
10
+ afterEach(() => { act(() => root?.unmount()); host?.remove() })
11
+ it('navigates Favorites, Views and grouped kinds in their visible order', () => {
12
+ Element.prototype.scrollIntoView = vi.fn()
13
+ const view = vi.fn(), kind = vi.fn()
14
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
15
+ const cluster = { group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }
16
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={kind} apiResources={[cluster]} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} pinned={[{ name: 'pods', kind: 'Pod', group: '' }]} categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Clusters view', onSelect: view }], defaultKindsCollapsed: true } }} />))
17
+ const category = [...host.querySelectorAll('button')].find((b) => b.textContent?.includes('CloudNativePG'))!
18
+ if (category.getAttribute('aria-expanded') !== 'true') act(() => category.click())
19
+ const input = host.querySelector('input')!
20
+ const key = (name: string) => act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true })))
21
+ key('ArrowDown'); key('ArrowDown'); key('Enter')
22
+ expect(view).toHaveBeenCalledTimes(1)
23
+ expect(kind).not.toHaveBeenCalled()
24
+ act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Resource kinds')!.click())
25
+ key('ArrowDown'); key('ArrowDown'); key('ArrowDown'); key('Enter')
26
+ expect(kind).toHaveBeenCalledWith(expect.objectContaining({ name: 'clusters', group: 'postgresql.cnpg.io' }))
27
+ })
28
+ it('opens a matching View with Enter when search finds no kind', () => {
29
+ Element.prototype.scrollIntoView = vi.fn()
30
+ const view = vi.fn()
31
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
32
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[{ group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }]} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'operator', label: 'Operator', onSelect: view }], defaultKindsCollapsed: true } }} />))
33
+ const input = host.querySelector('input')!
34
+ act(() => {
35
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'operator')
36
+ input.dispatchEvent(new Event('input', { bubbles: true }))
37
+ })
38
+ expect(host.textContent).toContain('Operator')
39
+ expect(host.textContent).not.toContain('>Cluster<')
40
+ act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
41
+ expect(view).toHaveBeenCalledTimes(1)
42
+ })
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
3
  import type { APIResource } from '../../types'
4
- import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar } from './ResourcesSidebar'
4
+ import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar, sidebarDestinationsMatching } from './ResourcesSidebar'
5
5
 
6
6
  const sqlInstance: APIResource = {
7
7
  group: 'sql.cnrm.cloud.google.com',
@@ -144,7 +144,7 @@ describe('ResourcesSidebar category workspaces', () => {
144
144
  }}
145
145
  />
146
146
  )
147
- expect(html).toContain('Workspace')
147
+ expect(html).toContain('Views')
148
148
  expect(html).toContain('Overview')
149
149
  expect(html).toContain('pg-orders')
150
150
  expect(html).toContain('Counts for namespace payments')
@@ -153,6 +153,21 @@ describe('ResourcesSidebar category workspaces', () => {
153
153
  expect(html).not.toContain('selection-strong selection-text">Pod')
154
154
  })
155
155
 
156
+ it('marks a count taken over partly readable data as a lower bound, and never shows its zero as none', () => {
157
+ const render = (count: number) =>
158
+ renderToString(
159
+ <ResourcesSidebar
160
+ selectedKind={null}
161
+ onSelectedKindChange={() => {}}
162
+ apiResources={[cnpgCluster]}
163
+ resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }}
164
+ categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', count, countLowerBound: true, onSelect: () => {} }] } }}
165
+ />,
166
+ )
167
+ expect(render(2)).toMatch(/≥(<!-- -->)?2/)
168
+ expect(render(0)).toContain('–')
169
+ })
170
+
156
171
  it('keeps a workspace category visible when it has no resources', () => {
157
172
  const html = renderToString(
158
173
  <ResourcesSidebar
@@ -181,3 +196,22 @@ describe('ResourcesSidebar category workspaces', () => {
181
196
  expect(html).toContain('barmancloud.cnpg.io')
182
197
  })
183
198
  })
199
+
200
+ describe('sidebarDestinationsMatching', () => {
201
+ const views = ['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'].map((label) => ({ id: label, label, onSelect: () => {} }))
202
+ const resources = [{ group: 'postgresql.cnpg.io' }, { group: 'barmancloud.cnpg.io' }]
203
+ const labels = (term: string) => sidebarDestinationsMatching('CloudNativePG', resources, views, term).map((d) => d.label)
204
+
205
+ it('keeps every view when the term names the category or one of its API groups', () => {
206
+ expect(labels('cloud')).toEqual(['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'])
207
+ expect(labels('cnpg')).toEqual(['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'])
208
+ })
209
+ it('keeps the views whose label matches otherwise', () => {
210
+ expect(labels('backup')).toEqual(['Backups'])
211
+ expect(labels('declar')).toEqual(['Declarations'])
212
+ expect(labels('deployment')).toEqual([])
213
+ })
214
+ it('keeps every view without a term', () => {
215
+ expect(labels(' ')).toHaveLength(5)
216
+ })
217
+ })
@@ -13,6 +13,7 @@ import type { APIResource } from '../../types'
13
13
  import { categorizeResources, CORE_RESOURCES } from '../../utils/api-resources'
14
14
  import { getResourceIcon } from '../../utils/resource-icons'
15
15
  import { Tooltip } from '../ui/Tooltip'
16
+ import { certaintyGlyph } from '../facts'
16
17
  import { Input } from '../ui/Input'
17
18
 
18
19
  // Selected resource type info (need both name for API and kind for display)
@@ -36,6 +37,8 @@ export interface SidebarCategoryDestination {
36
37
  icon?: ComponentType<{ className?: string }>
37
38
  /** Problem count. `undefined` renders no badge; `null` renders the unknown dash. */
38
39
  count?: number | null
40
+ /** The count was taken over data read only in part: rendered "≥N", and a zero is unknown rather than none. */
41
+ countLowerBound?: boolean
39
42
  countTitle?: string
40
43
  active?: boolean
41
44
  /** The object currently open under this destination, nested beneath it. */
@@ -97,6 +100,24 @@ const CORE_RESOURCE_TYPES = [
97
100
  { kind: 'hpas', label: 'HPAs' },
98
101
  ] as const
99
102
 
103
+ /**
104
+ * The workspace views a filter keeps: all of them when the term names the
105
+ * category or one of its API groups ("cloud", "cnpg"), otherwise the views
106
+ * whose label matches. The views are the way into a workspace, so a filter
107
+ * that finds the category's kinds must not hide them.
108
+ */
109
+ export function sidebarDestinationsMatching(
110
+ categoryName: string,
111
+ resources: Pick<APIResource, 'group'>[],
112
+ destinations: SidebarCategoryDestination[],
113
+ term: string,
114
+ ): SidebarCategoryDestination[] {
115
+ const normalized = term.trim().toLowerCase()
116
+ if (!normalized) return destinations
117
+ if (categoryName.toLowerCase().includes(normalized) || resources.some((r) => r.group.toLowerCase().includes(normalized))) return destinations
118
+ return destinations.filter((d) => d.label.toLowerCase().includes(normalized))
119
+ }
120
+
100
121
  export function resourceMatchesSidebarFilter(resource: Pick<APIResource, 'kind' | 'name' | 'group'>, term: string): boolean {
101
122
  const normalized = term.trim().toLowerCase()
102
123
  if (!normalized) return true
@@ -427,14 +448,16 @@ export function ResourcesSidebar({
427
448
  // If the group name matches, show all its resources
428
449
  if (categoryMatches || rawGroupMatches) return category
429
450
  const matchingResources = category.visibleResources.filter((resource: APIResource) => resourceMatchesSidebarFilter(resource, term))
430
- if (matchingResources.length === 0) return null
451
+ const ws = categoryWorkspaces?.[category.name]
452
+ const matchingViews = ws ? sidebarDestinationsMatching(category.name, category.resources, ws.destinations, term) : []
453
+ if (matchingResources.length === 0 && matchingViews.length === 0) return null
431
454
  return {
432
455
  ...category,
433
456
  visibleResources: matchingResources,
434
457
  }
435
458
  })
436
459
  .filter(Boolean) as typeof sortedCategories
437
- }, [sortedCategories, kindFilter])
460
+ }, [sortedCategories, kindFilter, categoryWorkspaces])
438
461
 
439
462
  // Auto-expand all categories when filtering
440
463
  const isKindFiltering = kindFilter.trim().length > 0
@@ -455,26 +478,23 @@ export function ResourcesSidebar({
455
478
  })
456
479
  }
457
480
 
458
- // --- Keyboard navigation ---
459
- // Flat list of all navigable kinds in the order they appear in the sidebar.
460
- const flatVisibleKinds = useMemo<SelectedKindInfo[]>(() => {
461
- const kinds: SelectedKindInfo[] = []
481
+ const visibleResults = useMemo(() => {
482
+ const results: ({ type: 'kind'; kind: SelectedKindInfo } | { type: 'view'; category: string; destination: SidebarCategoryDestination })[] = []
462
483
  if (favoritesExpanded) {
463
- for (const p of pinned) {
464
- kinds.push({ name: p.name, kind: p.kind, group: p.group })
465
- }
484
+ for (const kind of pinned) results.push({ type: 'kind', kind })
466
485
  }
467
- if (filteredCategories) {
468
- for (const cat of filteredCategories) {
469
- if (effectiveExpandedCategories.has(cat.name) && (isKindFiltering || kindsOpen(cat.name))) {
470
- for (const r of cat.visibleResources) {
471
- kinds.push({ name: r.name, kind: r.kind, group: r.group })
472
- }
473
- }
486
+ for (const cat of filteredCategories ?? []) {
487
+ if (!effectiveExpandedCategories.has(cat.name)) continue
488
+ const workspace = categoryWorkspaces?.[cat.name]
489
+ for (const destination of workspace ? sidebarDestinationsMatching(cat.name, cat.resources, workspace.destinations, kindFilter) : []) {
490
+ results.push({ type: 'view', category: cat.name, destination })
491
+ }
492
+ if (isKindFiltering || kindsOpen(cat.name)) {
493
+ for (const kind of workspace ? sortByGroup(cat.visibleResources) : cat.visibleResources) results.push({ type: 'kind', kind })
474
494
  }
475
495
  }
476
- return kinds
477
- }, [favoritesExpanded, pinned, filteredCategories, effectiveExpandedCategories, kindsOpenOverrides, categoryWorkspaces, activeDestinationCategory]) // eslint-disable-line react-hooks/exhaustive-deps
496
+ return results
497
+ }, [favoritesExpanded, pinned, filteredCategories, effectiveExpandedCategories, kindsOpenOverrides, categoryWorkspaces, activeDestinationCategory, isKindFiltering, kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
478
498
 
479
499
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
480
500
  // Reset highlight when the filter or kind list changes
@@ -482,10 +502,12 @@ export function ResourcesSidebar({
482
502
  setHighlightedIndex(kindFilter ? 0 : -1)
483
503
  }, [kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
484
504
 
485
- const highlightedKind = highlightedIndex >= 0 && highlightedIndex < flatVisibleKinds.length
486
- ? flatVisibleKinds[highlightedIndex]
505
+ const highlightedResult = highlightedIndex >= 0 && highlightedIndex < visibleResults.length
506
+ ? visibleResults[highlightedIndex]
487
507
  : null
488
508
 
509
+ const highlightedKind = highlightedResult?.type === 'kind' ? highlightedResult.kind : null
510
+
489
511
  // Scroll the highlighted kind button into view
490
512
  const highlightedRef = useRef<HTMLButtonElement>(null)
491
513
  useEffect(() => {
@@ -501,18 +523,19 @@ export function ResourcesSidebar({
501
523
  ;(e.target as HTMLInputElement).blur()
502
524
  } else if (e.key === 'ArrowDown') {
503
525
  e.preventDefault()
504
- setHighlightedIndex(prev => Math.min(prev + 1, flatVisibleKinds.length - 1))
526
+ setHighlightedIndex(prev => Math.min(prev + 1, visibleResults.length - 1))
505
527
  } else if (e.key === 'ArrowUp') {
506
528
  e.preventDefault()
507
529
  setHighlightedIndex(prev => Math.max(prev - 1, 0))
508
- } else if (e.key === 'Enter' && highlightedKind) {
530
+ } else if (e.key === 'Enter' && highlightedResult) {
509
531
  e.preventDefault()
510
- selectKind(highlightedKind)
532
+ if (highlightedResult.type === 'view') highlightedResult.destination.onSelect()
533
+ else selectKind(highlightedResult.kind)
511
534
  setHighlightedIndex(-1)
512
535
  setKindFilter('')
513
536
  onKindNavigated?.()
514
537
  }
515
- }, [flatVisibleKinds.length, highlightedKind, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
538
+ }, [visibleResults.length, highlightedResult, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
516
539
 
517
540
  const isKindHighlighted = useCallback((name: string, group: string) => {
518
541
  return highlightedKind?.name === name && highlightedKind?.group === group
@@ -618,6 +641,7 @@ export function ResourcesSidebar({
618
641
  const rawGroupTitle = rawCRDGroupTitle(category.resources)
619
642
  const workspace = categoryWorkspaces?.[category.name]
620
643
  const showKinds = isKindFiltering || kindsOpen(category.name)
644
+ const views = workspace ? sidebarDestinationsMatching(category.name, category.resources, workspace.destinations, kindFilter) : []
621
645
  return (
622
646
  <div key={category.name} className="mb-2">
623
647
  <button
@@ -635,9 +659,14 @@ export function ResourcesSidebar({
635
659
  )}
636
660
  </button>
637
661
  <Collapse open={isExpanded} id={disclosurePanelId(categoryPanelBase, category.name)}>
638
- {workspace && !isKindFiltering && (
662
+ {workspace && views.length > 0 && (
639
663
  <WorkspaceDestinations
640
664
  workspace={workspace}
665
+ highlightedId={highlightedResult?.type === 'view' && highlightedResult.category === category.name ? highlightedResult.destination.id : undefined}
666
+ highlightedRef={highlightedRef}
667
+ destinations={views}
668
+ filtering={isKindFiltering}
669
+ hasKinds={category.visibleResources.length > 0}
641
670
  kindsOpen={showKinds}
642
671
  onToggleKinds={() => toggleKinds(category.name)}
643
672
  kindsPanelId={disclosurePanelId(categoryPanelBase, `${category.name}-kinds`)}
@@ -776,45 +805,63 @@ function groupHeaderBefore(resources: APIResource[], index: number): string | nu
776
805
  return group || 'core'
777
806
  }
778
807
 
808
+ // While the sidebar is filtered, `destinations` are the views that match and
809
+ // the kinds below are forced open, so they get a label rather than a toggle.
779
810
  function WorkspaceDestinations({
780
811
  workspace,
812
+ destinations,
813
+ filtering,
814
+ hasKinds,
781
815
  kindsOpen,
782
816
  onToggleKinds,
783
817
  kindsPanelId,
818
+ highlightedId,
819
+ highlightedRef,
784
820
  }: {
785
821
  workspace: SidebarCategoryWorkspace
822
+ highlightedId?: string
823
+ highlightedRef: React.RefObject<HTMLButtonElement | null>
824
+ destinations: SidebarCategoryDestination[]
825
+ filtering: boolean
826
+ hasKinds: boolean
786
827
  kindsOpen: boolean
787
828
  onToggleKinds: () => void
788
829
  kindsPanelId: string
789
830
  }) {
790
831
  return (
791
832
  <div className="space-y-0.5">
792
- <div className="pl-5 pr-2 pt-0.5 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Workspace</div>
793
- {workspace.destinations.map((d) => {
833
+ <div className="pl-5 pr-2 pt-0.5 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Views</div>
834
+ {destinations.map((d) => {
794
835
  const Icon = d.icon
795
836
  return (
796
837
  <div key={d.id}>
797
838
  <button
839
+ ref={highlightedId === d.id ? highlightedRef : undefined}
798
840
  onClick={d.onSelect}
799
841
  aria-current={d.active && !d.child ? 'page' : undefined}
800
842
  className={clsx(
801
843
  'w-full flex items-center gap-2 pl-5 xl:pl-6 pr-2 xl:pr-3 py-1.5 rounded-lg text-sm transition-colors min-w-0',
802
844
  d.active && !d.child
803
845
  ? 'selection-strong selection-text'
804
- : d.active
805
- ? 'text-theme-text-primary font-medium hover:bg-theme-elevated'
806
- : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary',
846
+ : highlightedId === d.id
847
+ ? 'bg-theme-hover text-theme-text-primary'
848
+ : d.active
849
+ ? 'text-theme-text-primary font-medium hover:bg-theme-elevated'
850
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary',
807
851
  )}
808
852
  >
809
853
  {Icon && <Icon className="w-4 h-4 shrink-0" />}
810
854
  <span className="flex-1 text-left truncate">{d.label}</span>
811
- {d.count === null ? (
855
+ {d.count === null || (d.count === 0 && d.countLowerBound) ? (
812
856
  <Tooltip content={d.countTitle ?? COUNT_UNAVAILABLE_MESSAGE} position="left">
813
857
  <span className="text-xs py-0.5 rounded text-center font-mono w-8 bg-theme-elevated text-theme-text-disabled">–</span>
814
858
  </Tooltip>
815
859
  ) : d.count !== undefined && d.count > 0 ? (
816
860
  <Tooltip content={d.countTitle ?? `${d.count}`} position="left">
817
- <span className="text-xs py-0.5 rounded text-center font-mono w-8 status-degraded">{d.count}</span>
861
+ <span className={clsx('text-xs py-0.5 rounded text-center font-mono status-degraded', d.countLowerBound ? 'min-w-8 px-1' : 'w-8')}>
862
+ {d.countLowerBound && certaintyGlyph('lower_bound')}
863
+ {d.count}
864
+ </span>
818
865
  </Tooltip>
819
866
  ) : null}
820
867
  </button>
@@ -833,15 +880,19 @@ function WorkspaceDestinations({
833
880
  {workspace.scopeNote && (
834
881
  <div className="pl-6 pr-2 pt-0.5 text-[11px] text-theme-text-tertiary">{workspace.scopeNote}</div>
835
882
  )}
836
- <button
837
- aria-expanded={kindsOpen}
838
- aria-controls={kindsPanelId}
839
- onClick={onToggleKinds}
840
- className="w-full flex items-center gap-1.5 pl-5 pr-2 pt-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary hover:text-theme-text-secondary"
841
- >
842
- <CollapseChevron open={kindsOpen} className="w-3 h-3" />
843
- <span>Resource kinds</span>
844
- </button>
883
+ {filtering ? (
884
+ hasKinds && <div className="pl-5 pr-2 pt-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Resource kinds</div>
885
+ ) : (
886
+ <button
887
+ aria-expanded={kindsOpen}
888
+ aria-controls={kindsPanelId}
889
+ onClick={onToggleKinds}
890
+ className="w-full flex items-center gap-1.5 pl-5 pr-2 pt-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary hover:text-theme-text-secondary"
891
+ >
892
+ <CollapseChevron open={kindsOpen} className="w-3 h-3" />
893
+ <span>Resource kinds</span>
894
+ </button>
895
+ )}
845
896
  </div>
846
897
  )
847
898
  }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { hasFilterIntent } from './ResourcesView'
3
+ import { kindFiltersFromSearch, kindFiltersToSearch, serializeColumnFilters } from './resource-utils'
4
+
5
+ describe('kindFiltersFromSearch', () => {
6
+ it('restores column filters, their exclude operator and problem filters', () => {
7
+ const filters = serializeColumnFilters({ namespace: ['a', 'b'], status: ['Running'] }, { status: true })
8
+ const search = `?${new URLSearchParams({ filters, problems: 'failed,pending', search: 'web' })}`
9
+
10
+ expect(kindFiltersFromSearch(search)).toEqual({
11
+ columnFilters: { namespace: ['a', 'b'], status: ['Running'] },
12
+ columnFilterExcludes: { status: true },
13
+ problemFilters: ['failed', 'pending'],
14
+ })
15
+ })
16
+
17
+ it('starts empty for a kind with nothing remembered', () => {
18
+ expect(kindFiltersFromSearch(undefined)).toEqual({ columnFilters: {}, columnFilterExcludes: {}, problemFilters: [] })
19
+ })
20
+ })
21
+
22
+ describe('kindFiltersToSearch', () => {
23
+ it('round-trips through kindFiltersFromSearch and keeps only the per-kind filters', () => {
24
+ const filters = serializeColumnFilters({ namespace: ['a'], status: ['Running'] }, { status: true })
25
+ const saved = kindFiltersToSearch(kindFiltersFromSearch(`?${new URLSearchParams({ filters, problems: 'failed', search: 'web', namespaces: 'x' })}`))
26
+
27
+ expect(new URLSearchParams(saved).get('search')).toBeNull()
28
+ expect(kindFiltersFromSearch(saved)).toEqual({
29
+ columnFilters: { namespace: ['a'], status: ['Running'] },
30
+ columnFilterExcludes: { status: true },
31
+ problemFilters: ['failed'],
32
+ })
33
+ })
34
+
35
+ it('is empty when there is nothing to save, ignoring an exclude flag with no values', () => {
36
+ expect(kindFiltersToSearch({ columnFilters: { status: [] }, columnFilterExcludes: { status: true }, problemFilters: [] })).toBe('')
37
+ })
38
+ })
39
+
40
+ describe('hasFilterIntent', () => {
41
+ it('is false for a bare list URL, even with the namespace pick or a kind group', () => {
42
+ expect(hasFilterIntent('')).toBe(false)
43
+ expect(hasFilterIntent('?namespaces=a,b&apiGroup=serving.knative.dev&ai-run=r1')).toBe(false)
44
+ })
45
+
46
+ it.each(['filters=status:Running', 'problems=failed', 'search=web', 'regex=true', 'labels=app%3Dweb', 'ownerKind=DaemonSet', 'ownerName=x', 'resource=a/web', 'showInactive=true'])(
47
+ 'is true when the URL carries %s',
48
+ (param) => {
49
+ expect(hasFilterIntent(`?namespaces=a&${param}`)).toBe(true)
50
+ },
51
+ )
52
+ })