@skyhook-io/k8s-ui 1.17.0 → 1.18.0

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 (250) hide show
  1. package/README.md +41 -0
  2. package/package.json +6 -6
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +7 -3
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +142 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -81
  67. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  68. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  69. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +91 -2
  70. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +76 -76
  71. package/src/components/gitops/tree/layout.test.ts +111 -0
  72. package/src/components/gitops/tree/layout.ts +128 -0
  73. package/src/components/gitops/tree/tree-helpers.test.ts +23 -0
  74. package/src/components/gitops/tree/tree-helpers.ts +12 -2
  75. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  76. package/src/components/issues/IssuesView.tsx +44 -6
  77. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  78. package/src/components/issues/ResourceIssuesSection.tsx +8 -2
  79. package/src/components/issues/diagnostic.ts +2 -0
  80. package/src/components/issues/index.ts +3 -1
  81. package/src/components/issues/issue-timing.ts +12 -1
  82. package/src/components/issues/issues.test.ts +75 -1
  83. package/src/components/issues/severity.ts +30 -2
  84. package/src/components/issues/types.ts +55 -4
  85. package/src/components/logs/LogCore.theme.test.tsx +206 -3
  86. package/src/components/logs/LogCore.tsx +180 -53
  87. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  88. package/src/components/logs/LogsViewer.test.tsx +369 -0
  89. package/src/components/logs/LogsViewer.tsx +91 -13
  90. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  91. package/src/components/logs/StructuredLogLine.tsx +23 -31
  92. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  93. package/src/components/logs/WorkloadLogsViewer.tsx +46 -11
  94. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  95. package/src/components/logs/useLogBuffer.test.ts +8 -0
  96. package/src/components/logs/useLogBuffer.ts +48 -18
  97. package/src/components/logs/useLogSearch.ts +3 -1
  98. package/src/components/logs/useLogStream.ts +17 -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/ColumnPickerList.test.tsx +46 -0
  105. package/src/components/resources/ColumnPickerList.tsx +174 -0
  106. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  107. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  108. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  109. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  110. package/src/components/resources/ResourcesView.kind-view.test.tsx +296 -0
  111. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  112. package/src/components/resources/ResourcesView.tsx +392 -181
  113. package/src/components/resources/column-order.test.ts +104 -0
  114. package/src/components/resources/column-order.ts +64 -0
  115. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  116. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  117. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  118. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  119. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  120. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  121. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  122. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  123. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  124. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  126. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  127. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  128. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  129. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  130. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  131. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  132. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  133. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  134. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  135. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  136. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  137. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  138. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  139. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  140. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  141. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  142. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  143. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  144. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  145. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  146. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  147. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  148. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  149. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  150. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  151. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  152. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  153. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  154. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  155. package/src/components/resources/renderers/PodRenderer.tsx +81 -40
  156. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  157. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  158. package/src/components/resources/resource-utils-capi.ts +1 -1
  159. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  160. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  161. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  162. package/src/components/resources/resource-utils-contour.ts +16 -0
  163. package/src/components/resources/resource-utils-istio.ts +12 -8
  164. package/src/components/resources/resource-utils-knative.ts +18 -0
  165. package/src/components/resources/resource-utils.ts +96 -7
  166. package/src/components/resources/rules-column-width.test.ts +1 -1
  167. package/src/components/resources/summarize-scheduler-message.test.ts +31 -0
  168. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  169. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  170. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  171. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  172. package/src/components/shared/DetailShell.test.tsx +80 -0
  173. package/src/components/shared/DetailShell.tsx +85 -10
  174. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  175. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  176. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  177. package/src/components/shared/EditableYamlView.tsx +1 -1
  178. package/src/components/shared/ResourceActionsBar.tsx +14 -7
  179. package/src/components/shared/ResourceRendererDispatch.tsx +15 -10
  180. package/src/components/shared/index.ts +2 -0
  181. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  182. package/src/components/timeline/DiffViewer.tsx +113 -4
  183. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  184. package/src/components/timeline/shared.test.tsx +48 -0
  185. package/src/components/timeline/shared.tsx +14 -19
  186. package/src/components/timeline/timeline-filters.ts +1 -0
  187. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  188. package/src/components/topology/K8sResourceNode.tsx +44 -2
  189. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  190. package/src/components/topology/TopologyGraph.tsx +3 -71
  191. package/src/components/topology/TopologySearch.tsx +1 -0
  192. package/src/components/topology/internet-groups.test.ts +35 -0
  193. package/src/components/topology/internet-groups.ts +55 -0
  194. package/src/components/topology/layout.ts +1 -1
  195. package/src/components/ui/Badge.tsx +1 -0
  196. package/src/components/ui/FoldSection.tsx +68 -0
  197. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  198. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  199. package/src/components/ui/RefLink.tsx +20 -0
  200. package/src/components/ui/RowActionMenu.tsx +8 -3
  201. package/src/components/ui/Toast.tsx +15 -4
  202. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  203. package/src/components/ui/drawer-components.test.tsx +110 -1
  204. package/src/components/ui/drawer-components.tsx +62 -9
  205. package/src/components/ui/index.ts +4 -2
  206. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  207. package/src/components/ui/monacoRuntime.ts +14 -19
  208. package/src/components/ui/status-tone.test.ts +10 -1
  209. package/src/components/ui/status-tone.tsx +29 -0
  210. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  211. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  212. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  213. package/src/components/workload/WorkloadView.tsx +193 -71
  214. package/src/index.ts +14 -1
  215. package/src/monaco-deep.d.ts +19 -12
  216. package/src/theme/components.css +13 -0
  217. package/src/types/capacity.ts +11 -2
  218. package/src/types/core.ts +53 -2
  219. package/src/types/gitops-flux-status.test.ts +32 -1
  220. package/src/types/gitops-insights.ts +20 -2
  221. package/src/types/gitops-tree.ts +12 -0
  222. package/src/types/gitops.ts +26 -6
  223. package/src/utils/api-resources.ts +7 -0
  224. package/src/utils/application-history.ts +1 -0
  225. package/src/utils/cnpg-backup.test.ts +26 -0
  226. package/src/utils/cnpg-backup.ts +70 -0
  227. package/src/utils/format.test.ts +9 -0
  228. package/src/utils/format.ts +4 -0
  229. package/src/utils/gitops-owner.ts +3 -2
  230. package/src/utils/grant.test.ts +19 -0
  231. package/src/utils/grant.ts +35 -0
  232. package/src/utils/index.ts +4 -0
  233. package/src/utils/k8s-errors.ts +7 -7
  234. package/src/utils/log-format.test.ts +41 -0
  235. package/src/utils/log-format.ts +71 -15
  236. package/src/utils/log-level.ts +12 -8
  237. package/src/utils/navigation.test.ts +57 -0
  238. package/src/utils/navigation.ts +86 -8
  239. package/src/utils/object-reference-navigation.test.ts +43 -0
  240. package/src/utils/resource-hierarchy.test.ts +37 -0
  241. package/src/utils/resource-hierarchy.ts +15 -7
  242. package/src/utils/resource-icons.ts +2 -0
  243. package/src/utils/skeleton-yaml.test.ts +21 -0
  244. package/src/utils/skeleton-yaml.ts +16 -0
  245. package/src/utils/url-path.test.ts +35 -0
  246. package/src/utils/url-path.ts +21 -0
  247. package/src/utils/validators.test.ts +4 -13
  248. package/src/utils/validators.ts +5 -19
  249. package/src/utils/view-transition.test.ts +35 -1
  250. package/src/utils/view-transition.ts +16 -1
@@ -1,10 +1,13 @@
1
1
  import React, { useState, useMemo, useEffect, useCallback, useDeferredValue, useRef, useContext, useId } from 'react'
2
2
  import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
3
3
  import { useDebouncedValue } from '../../hooks/useDebouncedValue'
4
+ import { Badge } from '../ui/Badge'
4
5
  import { PaneLoader } from '../ui/PaneLoader'
5
6
  import { RestrictedState } from '../ui/RestrictedState'
6
7
  import { FetchResult } from '../ui/FetchResult'
7
8
  import { Input } from '../ui/Input'
9
+ import { ColumnPickerList } from './ColumnPickerList'
10
+ import { applyColumnOrder, sanitizeColumnOrder } from './column-order'
8
11
  import type { TopPodMetrics, TopNodeMetrics, ContainerResourceMetrics } from '../../types'
9
12
  import {
10
13
  Search,
@@ -137,11 +140,16 @@ import {
137
140
  parseColumnFilters,
138
141
  serializeColumnFilters,
139
142
  parseColumnFilterExcludes,
143
+ kindFiltersFromSearch,
144
+ kindFiltersToSearch,
145
+ columnFiltersParam,
146
+ RESOURCE_LIST_FILTER_PARAMS,
140
147
  podMatchesProblemCategory,
141
148
  SEVERITY_DOT_COLOR,
142
149
  healthColors,
143
150
  } from './resource-utils'
144
151
  import { getGenericResourceStatus } from './generic-status'
152
+ import { ActiveFilterBar } from './ActiveFilterBar'
145
153
  import { getIstioGatewayServerCount, getIstioGatewaySelectorString } from './resource-utils-istio'
146
154
  import {
147
155
  type PrinterColumnDef, type PrinterTable, PRINTER_COLUMN_PREFIX, formatPrinterCell, printerCellSortValue,
@@ -156,6 +164,8 @@ import { isRolloutActivityVisible } from '../../utils/workload-rollout'
156
164
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
157
165
  import { Tooltip } from '../ui/Tooltip'
158
166
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
167
+ import type { Issue } from '../issues/types'
168
+ import { issueTitle } from '../issues/severity'
159
169
  import { SEVERITY_TEXT_CLASS } from '../checks/severity'
160
170
  // CRD-specific cell components (extracted)
161
171
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
@@ -203,7 +213,7 @@ import { GCPManagedControlPlaneCell, GCPManagedMachinePoolCell, GCPMachineCell,
203
213
  import { AzureManagedControlPlaneCell, AzureManagedMachinePoolCell, AzureMachineCell, AzureMachineTemplateCell, AzureManagedClusterCell } from './renderers/azure-capi-cells'
204
214
  import { CalicoInfraCell, CalicoPolicyCell } from './renderers/calico-cells'
205
215
  import { isCalicoPolicyResource, isCoreNetworkPolicyKind } from './resource-utils-calico'
206
- import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
216
+ import { useRegisterShortcut, useRegisterShortcuts, type KeyboardShortcut } from '../../hooks/useKeyboardShortcuts'
207
217
  import { ResourcesSidebar } from './ResourcesSidebar'
208
218
  import type { SelectedKindInfo, SidebarCategoryWorkspace } from './ResourcesSidebar'
209
219
  import { CompareTray, togglePick, pickIndex, refToParam, SIDE_TONES, type CompareTrayPick, type NamespacedRef } from '../compare'
@@ -1460,6 +1470,15 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1460
1470
  { key: 'rules', label: 'Rules', width: 'w-20' },
1461
1471
  { key: 'age', label: 'Age', width: 'w-24' },
1462
1472
  ],
1473
+ udproutes: [
1474
+ { key: 'name', label: 'Name' },
1475
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
1476
+ { key: 'status', label: 'Status', width: 'w-28', tooltip: 'Accepted = controllers accepted this route and confirmed it can use the backends and other objects it names. Traffic has not been tested. Degraded or Not Accepted: open the route to see which Gateway or backend failed. Pending or Unknown: check the named Gateways or Services and their controllers.' },
1477
+ { key: 'parents', label: 'Gateways', width: 'w-36' },
1478
+ { key: 'backends', label: 'Backends', width: 'w-48', tooltip: 'Backend services receiving traffic' },
1479
+ { key: 'rules', label: 'Rules', width: 'w-20' },
1480
+ { key: 'age', label: 'Age', width: 'w-24' },
1481
+ ],
1463
1482
  tlsroutes: [
1464
1483
  { key: 'name', label: 'Name' },
1465
1484
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -2884,6 +2903,7 @@ const CURATED_COLUMN_GROUPS: Record<string, readonly string[]> = {
2884
2903
  grpcroutes: ['gateway.networking.k8s.io'],
2885
2904
  httproutes: ['gateway.networking.k8s.io'],
2886
2905
  tcproutes: ['gateway.networking.k8s.io'],
2906
+ udproutes: ['gateway.networking.k8s.io'],
2887
2907
  tlsroutes: ['gateway.networking.k8s.io'],
2888
2908
  helmreleases: ['helm.toolkit.fluxcd.io'],
2889
2909
  awsmachines: ['infrastructure.cluster.x-k8s.io'],
@@ -3240,6 +3260,7 @@ function filterHostExtras(
3240
3260
 
3241
3261
  // localStorage helpers for column settings
3242
3262
  const COLUMN_SETTINGS_PREFIX = 'radar-columns-'
3263
+ const CHECKBOX_VISIBILITY_KEY = 'radar-resource-checkboxes'
3243
3264
 
3244
3265
  // Storage identity for a kind's column settings. normalizeKindToPlural falls
3245
3266
  // through to the bare plural for anything outside the curated collision
@@ -3265,6 +3286,8 @@ interface ColumnSettings {
3265
3286
  * load tell a column they hid from one that did not exist yet. Absent on
3266
3287
  * blobs written before this field. */
3267
3288
  known?: string[]
3289
+ /** Optional display order for movable columns, including hidden ones. */
3290
+ order?: string[]
3268
3291
  }
3269
3292
 
3270
3293
  function loadColumnSettings(kind: string, group?: string): ColumnSettings | null {
@@ -3327,6 +3350,9 @@ interface ResourcesViewData {
3327
3350
  // cluster-scoped kind. An ExternalSecret names a store; only the store
3328
3351
  // knows which backend it reads from.
3329
3352
  storeProviders?: Record<string, string>
3353
+ /** Current CronJob Issues keyed by "namespace/name", supplied by the host. */
3354
+ operationalIssuesIncomplete?: boolean
3355
+ operationalIssues?: Record<string, Issue>
3330
3356
  /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
3331
3357
  * counts render danger as High and warning as Medium. */
3332
3358
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
@@ -3391,6 +3417,9 @@ interface ResourcesViewProps {
3391
3417
  // cluster-scoped kind. An ExternalSecret names a store; only the store
3392
3418
  // knows which backend it reads from.
3393
3419
  storeProviders?: Record<string, string>
3420
+ /** Current CronJob Issues keyed by "namespace/name", supplied by the host. */
3421
+ operationalIssuesIncomplete?: boolean
3422
+ operationalIssues?: Record<string, Issue>
3394
3423
  /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
3395
3424
  * counts render danger as High and warning as Medium. */
3396
3425
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
@@ -3415,6 +3444,11 @@ interface ResourcesViewProps {
3415
3444
  sidebarCategoryWorkspaces?: Record<string, SidebarCategoryWorkspace>
3416
3445
  /** Callback when the [+] create button is clicked. Receives the currently selected kind info. */
3417
3446
  onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void
3447
+ /** A kind's own view, rendered in place of the table: the sidebar, kind
3448
+ * selection and the drawer (`?resource=`) stay; the table, its toolbar and
3449
+ * its row/search/bulk shortcuts do not run. Sidebar kind navigation stays.
3450
+ * Return null or false to keep the table. */
3451
+ renderKindView?: (kind: SelectedKindInfo) => React.ReactNode | null
3418
3452
  /** Default kind when the URL does not include one. */
3419
3453
  defaultKind?: SelectedKindInfo
3420
3454
  /** Columns prepended to KNOWN_COLUMNS for every kind. For example, a
@@ -3480,6 +3514,29 @@ interface ResourcesViewProps {
3480
3514
  * the switcher lives outside this component and may persist server-side.
3481
3515
  */
3482
3516
  onClearNamespaces?: () => void
3517
+ /**
3518
+ * A kind's saved filters, as last reported through `onKindFiltersChange`
3519
+ * (host-owned, because this view unmounts on section switches). A user kind
3520
+ * switch, or a URL with no list state of its own (see `hasFilterIntent`),
3521
+ * restores that kind's column and problem filters from it. Pass undefined
3522
+ * until the host can answer: the first defined value is still applied to a
3523
+ * bare URL.
3524
+ */
3525
+ rememberedKindSearch?: (kind: { name: string; group: string }) => string | undefined
3526
+ /**
3527
+ * Called with a kind's serialized `filters` + `problems` query ('' when
3528
+ * none) whenever the filters the kind shows change, including the ones a
3529
+ * link arrives with. A list that arrives with no filters does not report
3530
+ * '', so a link to one resource keeps what was saved.
3531
+ */
3532
+ onKindFiltersChange?: (kind: { name: string; group: string }, search: string) => void
3533
+ /**
3534
+ * Promotes the Namespace column's included values to the global namespace
3535
+ * selection (replacing it). When wired, the column's filter dropdown offers
3536
+ * "Apply to all sections" and clears the column filter once the returned
3537
+ * promise resolves (a rejection keeps it).
3538
+ */
3539
+ onApplyNamespacesToAllSections?: (namespaces: string[]) => Promise<unknown> | void
3483
3540
  // Bulk operations
3484
3541
  onBulkDelete?: (items: BulkResourceItem[], options?: { force?: boolean; onSuccess?: () => void }) => void
3485
3542
  isBulkDeleting?: boolean
@@ -3603,19 +3660,24 @@ export function deriveSidebarResourceCounts(
3603
3660
  return results
3604
3661
  }
3605
3662
 
3663
+ const FILTER_INTENT_PARAMS = [...RESOURCE_LIST_FILTER_PARAMS, 'resource']
3664
+
3665
+ /**
3666
+ * Whether a URL states its own list state. Only a URL without any of these
3667
+ * (a bare `/resources/<kind>`) gets the kind's remembered filters.
3668
+ */
3669
+ export function hasFilterIntent(search: string): boolean {
3670
+ const params = new URLSearchParams(search)
3671
+ return FILTER_INTENT_PARAMS.some((key) => params.has(key))
3672
+ }
3673
+
3606
3674
  // Get initial filters from URL
3607
3675
  function getInitialFiltersFromURL() {
3608
3676
  const params = new URLSearchParams(window.location.search)
3609
- // Parse generic column filters
3610
- const filtersParam = params.get('filters')
3611
- const columnFilters = parseColumnFilters(filtersParam)
3612
- const columnFilterExcludes = parseColumnFilterExcludes(filtersParam)
3613
3677
  const result = {
3614
3678
  search: params.get('search') || '',
3615
3679
  regex: params.get('regex') === 'true',
3616
- columnFilters,
3617
- columnFilterExcludes,
3618
- problemFilters: params.get('problems')?.split(',').filter(Boolean) || [],
3680
+ ...kindFiltersFromSearch(window.location.search),
3619
3681
  showInactive: params.get('showInactive') === 'true',
3620
3682
  labelSelector: params.get('labels') || '', // e.g., "app=caretta,version=v1"
3621
3683
  ownerKind: params.get('ownerKind') || '', // e.g., "DaemonSet"
@@ -3644,6 +3706,8 @@ export function ResourcesView({
3644
3706
  certExpiryError,
3645
3707
  storeProviders,
3646
3708
  auditBadges,
3709
+ operationalIssues,
3710
+ operationalIssuesIncomplete,
3647
3711
  pinned = [],
3648
3712
  togglePin = () => {},
3649
3713
  isPinned = () => false,
@@ -3657,6 +3721,7 @@ export function ResourcesView({
3657
3721
  hideSidebar = false,
3658
3722
  sidebarCategoryWorkspaces,
3659
3723
  onCreateResource,
3724
+ renderKindView,
3660
3725
  defaultKind = DEFAULT_KIND_INFO,
3661
3726
  extraLeadingColumns,
3662
3727
  printerTable,
@@ -3665,6 +3730,9 @@ export function ResourcesView({
3665
3730
  onCompareSubmit,
3666
3731
  resolveRowCluster,
3667
3732
  onClearNamespaces,
3733
+ rememberedKindSearch,
3734
+ onKindFiltersChange,
3735
+ onApplyNamespacesToAllSections,
3668
3736
  onBulkDelete,
3669
3737
  isBulkDeleting = false,
3670
3738
  onBulkRestart,
@@ -3685,16 +3753,22 @@ export function ResourcesView({
3685
3753
  kindFromURL.name !== prev.name || kindFromURL.group !== prev.group ? kindFromURL : prev,
3686
3754
  )
3687
3755
  }, [basePath, defaultKind, locationPathname, locationSearch])
3756
+ const contributedKindView = renderKindView?.(selectedKind)
3757
+ const kindView = contributedKindView === false ? null : contributedKindView ?? null
3758
+ const tableActive = kindView === null
3759
+
3688
3760
  // Notify parent of selected kind changes (including initial mount)
3689
3761
  useEffect(() => {
3690
3762
  onSelectedKindChange?.(selectedKind)
3691
- setBulkMode(false)
3763
+ }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
3764
+
3765
+ useEffect(() => {
3692
3766
  setCheckedResources(new Set())
3693
3767
  setShowBulkDeleteConfirm(false)
3694
3768
  setShowBulkRestartConfirm(false)
3695
3769
  setShowBulkScaleDialog(false)
3696
3770
  setBulkForceDelete(false)
3697
- }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
3771
+ }, [selectedKind.name, selectedKind.group, tableActive])
3698
3772
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
3699
3773
  // Typing must never wait on the list or the router. The input renders raw
3700
3774
  // searchTerm; filtering consumes the deferred copy (React yields to keep
@@ -3712,6 +3786,30 @@ export function ResourcesView({
3712
3786
  const [columnFilters, setColumnFilters] = useState<Record<string, string[]>>(initialFilters.columnFilters)
3713
3787
  const [columnFilterExcludes, setColumnFilterExcludes] = useState<Record<string, boolean>>(initialFilters.columnFilterExcludes)
3714
3788
  const [problemFilters, setProblemFilters] = useState<string[]>(initialFilters.problemFilters)
3789
+ // The kind the filter state belongs to. During a kind switch one render
3790
+ // pairs the new kind with the old kind's filters; saving then would file
3791
+ // them under the wrong kind.
3792
+ const [filtersKind, setFiltersKind] = useState(() => selectedKindIdentityOf(selectedKind))
3793
+ // Where the current filter state came from when it wasn't a user edit.
3794
+ const filtersArrivedRef = useRef<'url' | 'restore' | null>('url')
3795
+ // The serialized filters last restored from rememberedKindSearch ('' when none).
3796
+ const [restoredKindSearch, setRestoredKindSearch] = useState('')
3797
+ // A bare URL at mount takes the kind's saved filters. Adjusted during render
3798
+ // (not in an effect) so the unfiltered list never paints, and so it still
3799
+ // applies when the host's lookup arrives after mount.
3800
+ const [mountRestorePending, setMountRestorePending] = useState(() => !hasFilterIntent(locationSearch ?? window.location.search))
3801
+ if (mountRestorePending && rememberedKindSearch) {
3802
+ setMountRestorePending(false)
3803
+ const restored = kindFiltersToSearch(kindFiltersFromSearch(rememberedKindSearch(selectedKind)))
3804
+ if (restored && !hasFilterIntent(locationSearch ?? window.location.search)) {
3805
+ const remembered = kindFiltersFromSearch(restored)
3806
+ setColumnFilters(remembered.columnFilters)
3807
+ setColumnFilterExcludes(remembered.columnFilterExcludes)
3808
+ setProblemFilters(remembered.problemFilters)
3809
+ setRestoredKindSearch(restored)
3810
+ filtersArrivedRef.current = 'restore'
3811
+ }
3812
+ }
3715
3813
  const [openColumnFilter, setOpenColumnFilter] = useState<string | null>(null)
3716
3814
  const [columnFilterSearch, setColumnFilterSearch] = useState('')
3717
3815
  const columnFilterDropdownRef = useRef<HTMLDivElement>(null)
@@ -3725,6 +3823,7 @@ export function ResourcesView({
3725
3823
  // Column visibility and resize state
3726
3824
  const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set())
3727
3825
  const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
3826
+ const [columnOrder, setColumnOrder] = useState<string[] | null>(null)
3728
3827
  const [showColumnPicker, setShowColumnPicker] = useState(false)
3729
3828
  const [customColumns, setCustomColumns] = useState<CustomColumnDef[]>([])
3730
3829
  const [customColumnDraft, setCustomColumnDraft] = useState<CustomColumnDef>({ source: 'label', path: '' })
@@ -3737,10 +3836,10 @@ export function ResourcesView({
3737
3836
  const [ownerKind, setOwnerKind] = useState<string>(initialFilters.ownerKind)
3738
3837
  const [ownerName, setOwnerName] = useState<string>(initialFilters.ownerName)
3739
3838
 
3740
- // Multi-select state for bulk operations. Checkboxes only render while
3741
- // bulk mode is active — entered via the toolbar toggle — so mutating
3742
- // actions stay out of the way during normal browsing.
3743
- const [bulkMode, setBulkMode] = useState(false)
3839
+ const [showCheckboxes, setShowCheckboxes] = useState(() => {
3840
+ try { return localStorage.getItem(CHECKBOX_VISIBILITY_KEY) === 'true' }
3841
+ catch { return false }
3842
+ })
3744
3843
  const [checkedResources, setCheckedResources] = useState<Set<string>>(new Set())
3745
3844
  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
3746
3845
  const [showBulkRestartConfirm, setShowBulkRestartConfirm] = useState(false)
@@ -3748,8 +3847,7 @@ export function ResourcesView({
3748
3847
  const [bulkForceDelete, setBulkForceDelete] = useState(false)
3749
3848
  const [bulkScaleReplicas, setBulkScaleReplicas] = useState(0)
3750
3849
 
3751
- const exitBulkMode = useCallback(() => {
3752
- setBulkMode(false)
3850
+ const clearBulkSelection = useCallback(() => {
3753
3851
  setCheckedResources(new Set())
3754
3852
  }, [])
3755
3853
 
@@ -3866,13 +3964,16 @@ export function ResourcesView({
3866
3964
  [printerTable, builtPrinterColumns],
3867
3965
  )
3868
3966
 
3869
- const allColumns = useMemo(() => {
3967
+ const definedColumns = useMemo(() => {
3870
3968
  const kindColumns = columnsForKindWithPrinter(selectedKind.name, selectedKind.group, builtPrinterColumns)
3871
3969
  if (!extraLeadingColumns?.length && !builtCustomColumns.length) return kindColumns
3872
3970
  const builtinKeys = new Set(kindColumns.map(c => c.key))
3873
3971
  const filteredExtras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
3874
3972
  return [...filteredExtras, ...kindColumns, ...builtCustomColumns]
3875
3973
  }, [selectedKind.name, selectedKind.group, extraLeadingColumns, builtCustomColumns, builtPrinterColumns])
3974
+ // Display order. Everything downstream reads this, so the picker, the header
3975
+ // and the resize width snapshot (which maps <th> index to key) agree.
3976
+ const allColumns = useMemo(() => applyColumnOrder(definedColumns, columnOrder), [definedColumns, columnOrder])
3876
3977
 
3877
3978
  // The offered key set, and a stable signature for it. allColumns is a new
3878
3979
  // identity on every printer-column refetch; depending on the array itself
@@ -3935,6 +4036,8 @@ export function ResourcesView({
3935
4036
  // depends on visibleColumns) and instantly reverts the user's hide.
3936
4037
  const gpuAutoShownKinds = useRef<Set<string>>(new Set())
3937
4038
  useEffect(() => {
4039
+ // The table's own settings: nothing to load (or clear) while a kind view replaces it.
4040
+ if (!tableActive) return
3938
4041
  // Re-arm the skip-initial-save guard per kind. This effect repopulates
3939
4042
  // visible/widths/custom for the new kind via async setState, so the save
3940
4043
  // effect (also keyed to selectedKind) that runs in this same commit still
@@ -4005,24 +4108,28 @@ export function ResourcesView({
4005
4108
  hadSavedColumnSettings.current = false
4006
4109
  setVisibleColumns(getDefaultVisibleColumns(effective))
4007
4110
  setColumnWidths({})
4111
+ setColumnOrder(null)
4008
4112
  } else {
4009
4113
  setVisibleColumns(mergeSavedVisibleColumns(
4010
4114
  saved.visible, extraKeys, builtPrinterColumns, effective, savedKnown,
4011
4115
  ))
4012
4116
  setColumnWidths(saved.widths || {})
4117
+ setColumnOrder(sanitizeColumnOrder(saved.order))
4013
4118
  }
4014
4119
  } else {
4015
4120
  setVisibleColumns(getDefaultVisibleColumns(effective))
4016
4121
  setColumnWidths({})
4122
+ setColumnOrder(null)
4017
4123
  }
4018
4124
  // printerColumnsKey, not builtPrinterColumns: the built array is a new
4019
4125
  // identity on every refetch, and re-running this effect would reset the
4020
4126
  // user's in-session column choices each time the list polls. The key only
4021
4127
  // changes when the kind changes or an operator upgrade changes the CRD.
4022
- }, [selectedKind.name, selectedKind.group, extraLeadingColumns, printerColumnsKey])
4128
+ }, [selectedKind.name, selectedKind.group, extraLeadingColumns, printerColumnsKey, tableActive])
4023
4129
 
4024
4130
  // Save column settings when they change (skip the initial load of each kind)
4025
4131
  useEffect(() => {
4132
+ if (!tableActive) return
4026
4133
  if (visibleColumns.size === 0) return // not loaded yet
4027
4134
  for (const k of allColumnKeys) knownColumnKeys.current.add(k)
4028
4135
  if (!isColumnSettingsLoaded.current) {
@@ -4034,12 +4141,13 @@ export function ResourcesView({
4034
4141
  widths: columnWidths,
4035
4142
  custom: customColumns,
4036
4143
  known: Array.from(knownColumnKeys.current),
4144
+ order: columnOrder ?? undefined,
4037
4145
  })
4038
4146
  // A persisted blob blocks GPU auto-show from here on — same rule in-session
4039
4147
  // as across sessions, so a later data refresh can't override user choices.
4040
4148
  hadSavedColumnSettings.current = true
4041
4149
  // allColumnKeysSig, not allColumnKeys: see the memo above.
4042
- }, [visibleColumns, columnWidths, customColumns, selectedKind.name, selectedKind.group, allColumnKeysSig])
4150
+ }, [visibleColumns, columnWidths, customColumns, columnOrder, selectedKind.name, selectedKind.group, allColumnKeysSig, tableActive])
4043
4151
 
4044
4152
  // Close column picker on outside click or Escape
4045
4153
  useEffect(() => {
@@ -4147,6 +4255,7 @@ export function ResourcesView({
4147
4255
  const customKeys = new Set(builtCustomColumns.map(c => c.key))
4148
4256
  setVisibleColumns(getDefaultVisibleColumns(allColumns.filter(c => !customKeys.has(c.key))))
4149
4257
  setColumnWidths({})
4258
+ setColumnOrder(null)
4150
4259
  setSortColumn(null)
4151
4260
  setSortDirection(null)
4152
4261
  saveSavedSort(selectedKind.name, selectedKind.group, null)
@@ -4208,6 +4317,7 @@ export function ResourcesView({
4208
4317
  category: 'Search',
4209
4318
  scope: 'resources',
4210
4319
  handler: () => searchInputRef.current?.focus(),
4320
+ enabled: tableActive,
4211
4321
  })
4212
4322
 
4213
4323
  // Keyboard navigation: highlighted row state
@@ -4227,7 +4337,7 @@ export function ResourcesView({
4227
4337
  useEffect(() => {
4228
4338
  setComparePicks([])
4229
4339
  setCompareMode(false)
4230
- }, [selectedKind.name, selectedKind.group])
4340
+ }, [selectedKind.name, selectedKind.group, tableActive])
4231
4341
 
4232
4342
  const exitCompareMode = useCallback(() => {
4233
4343
  setCompareMode(false)
@@ -4275,6 +4385,15 @@ export function ResourcesView({
4275
4385
  }
4276
4386
  }, [highlightedIndex])
4277
4387
 
4388
+ useEffect(() => {
4389
+ if (tableActive) return
4390
+ setHighlightedIndex(-1)
4391
+ setShowColumnPicker(false)
4392
+ setOpenColumnFilter(null)
4393
+ setShowProblemsDropdown(false)
4394
+ setShowLabelsDropdown(false)
4395
+ }, [tableActive])
4396
+
4278
4397
  // Open logs for pod / workload resources — provided via props
4279
4398
  const openLogs = onOpenLogs
4280
4399
  const openWorkloadLogs = onOpenWorkloadLogs
@@ -4307,8 +4426,7 @@ export function ResourcesView({
4307
4426
  onResourceClick?.(isSelected ? null : stripped)
4308
4427
  }, [onRowSelect, onResourceClick, selectedKind.name, selectedKind.group])
4309
4428
 
4310
- // Register navigation shortcuts
4311
- useRegisterShortcuts([
4429
+ useRegisterShortcuts(([
4312
4430
  {
4313
4431
  id: 'resources-nav-down',
4314
4432
  keys: 'j',
@@ -4457,14 +4575,11 @@ export function ResourcesView({
4457
4575
  {
4458
4576
  id: 'resources-bulk-select',
4459
4577
  keys: 'b',
4460
- description: 'Toggle bulk select',
4578
+ description: 'Show or hide selection checkboxes',
4461
4579
  category: 'Table',
4462
4580
  scope: 'resources',
4463
4581
  handler: () => {
4464
- if (bulkMode) { exitBulkMode(); return }
4465
- if (!canBulkSelect) return
4466
- exitCompareMode()
4467
- setBulkMode(true)
4582
+ if (canBulkSelect) toggleCheckboxVisibility()
4468
4583
  },
4469
4584
  },
4470
4585
  {
@@ -4479,12 +4594,12 @@ export function ResourcesView({
4479
4594
  if (showProblemsDropdown) { setShowProblemsDropdown(false); return }
4480
4595
  if (showLabelsDropdown) { setShowLabelsDropdown(false); return }
4481
4596
  if (compareMode) { exitCompareMode(); return }
4482
- if (bulkMode) { exitBulkMode(); return }
4597
+ if (checkedResources.size > 0) { clearBulkSelection(); return }
4483
4598
  if (highlightedIndex >= 0) setHighlightedIndex(-1)
4484
4599
  else searchInputRef.current?.blur()
4485
4600
  },
4486
4601
  },
4487
- ])
4602
+ ] as KeyboardShortcut[]).map((sc) => ({ ...sc, enabled: tableActive && (sc.enabled ?? true) })))
4488
4603
 
4489
4604
  // Refs for accessing filteredResources inside shortcuts (computed later in component)
4490
4605
  const filteredResourceCountRef = useRef(0)
@@ -4564,8 +4679,21 @@ export function ResourcesView({
4564
4679
  const newFilters = getInitialFiltersFromURL()
4565
4680
 
4566
4681
  // Update kind if it changed
4567
- if (newKind.name !== selectedKind.name || newKind.group !== selectedKind.group) {
4682
+ const kindChanged = newKind.name !== selectedKind.name || newKind.group !== selectedKind.group
4683
+ let restored = ''
4684
+ if (kindChanged) {
4568
4685
  setSelectedKind(newKind)
4686
+ // A bare URL for another kind links to the list, not to a state of it:
4687
+ // take that kind's saved filters and put them in the address bar. Never
4688
+ // for the same kind, where a bare URL is the echo of Clear filters.
4689
+ restored = hasFilterIntent(locationSearch ?? window.location.search)
4690
+ ? ''
4691
+ : kindFiltersToSearch(kindFiltersFromSearch(rememberedKindSearch?.(newKind)))
4692
+ setRestoredKindSearch(restored)
4693
+ if (restored) {
4694
+ Object.assign(newFilters, kindFiltersFromSearch(restored))
4695
+ updateURL(newKind, newFilters.search, newFilters.regex, newFilters.columnFilters, newFilters.columnFilterExcludes, newFilters.problemFilters, newFilters.showInactive)
4696
+ }
4569
4697
  }
4570
4698
 
4571
4699
  // Update owner filter if it changed
@@ -4606,6 +4734,13 @@ export function ResourcesView({
4606
4734
  setColumnFilterExcludes(newFilters.columnFilterExcludes)
4607
4735
  }
4608
4736
 
4737
+ if (newFilters.problemFilters.join(',') !== problemFilters.join(',')) {
4738
+ setProblemFilters(newFilters.problemFilters)
4739
+ }
4740
+ if (kindChanged || kindFiltersToSearch(newFilters) !== kindFiltersSearch) {
4741
+ filtersArrivedRef.current = restored ? 'restore' : 'url'
4742
+ }
4743
+
4609
4744
  // Reset the flag after a tick to allow normal URL updates
4610
4745
  requestAnimationFrame(() => {
4611
4746
  isSyncingFromURL.current = false
@@ -4660,11 +4795,7 @@ export function ResourcesView({
4660
4795
  }
4661
4796
  // Write column filters as `filters` param; the exclude operator is folded
4662
4797
  // into each column entry, so it can never drift from the values it negates.
4663
- // Guard against a stale exclude flag on a column with no active values.
4664
- const activeExcludes = Object.fromEntries(
4665
- Object.entries(colExcludes).filter(([k, on]) => on && (colFilters[k]?.length ?? 0) > 0)
4666
- )
4667
- const filtersStr = serializeColumnFilters(colFilters, activeExcludes)
4798
+ const filtersStr = columnFiltersParam(colFilters, colExcludes)
4668
4799
  if (filtersStr) {
4669
4800
  params.set('filters', filtersStr)
4670
4801
  } else {
@@ -4736,13 +4867,19 @@ export function ResourcesView({
4736
4867
  // params are out of scope here; the host's onClearNamespaces (and its
4737
4868
  // own state→URL sync) owns namespace cleanup.
4738
4869
  const params = new URLSearchParams(window.location.search)
4739
- for (const key of ['search', 'regex', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive']) {
4870
+ for (const key of RESOURCE_LIST_FILTER_PARAMS) {
4740
4871
  params.delete(key)
4741
4872
  }
4742
4873
  navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
4743
4874
  onClearNamespaces?.()
4744
4875
  }, [navigate, onClearNamespaces])
4745
4876
 
4877
+ const clearKindFilters = useCallback(() => {
4878
+ setColumnFilters({})
4879
+ setColumnFilterExcludes({})
4880
+ setProblemFilters([])
4881
+ }, [])
4882
+
4746
4883
  // Update URL when any filter changes
4747
4884
  useEffect(() => {
4748
4885
  // Skip URL update if we're syncing FROM the URL (e.g., browser back button)
@@ -4755,6 +4892,8 @@ export function ResourcesView({
4755
4892
  prevSelectedResourceRef.current = selectedResource ?? null
4756
4893
  return
4757
4894
  }
4895
+ // Right after a kind switch the filters are still the previous kind's.
4896
+ if (filtersKind !== selectedKindIdentityOf(selectedKind)) return
4758
4897
  // Skip URL update if selectedResource's kind doesn't match selectedKind (still syncing)
4759
4898
  if (selectedResource) {
4760
4899
  const resourceKindLower = selectedResource.kind.toLowerCase()
@@ -4780,7 +4919,12 @@ export function ResourcesView({
4780
4919
  : typeof window !== 'undefined'
4781
4920
  ? window.location.pathname
4782
4921
  : ''
4783
- const pathChanged = currentPath !== targetPath
4922
+ // The router commits a navigation as a transition, so the injected path can
4923
+ // still be the previous kind's right after a push to this one (e.g. the
4924
+ // write of filters restored on a kind switch); the address bar is already
4925
+ // there, and pushing again would add a second entry for the same visit.
4926
+ const pathChanged = currentPath !== targetPath &&
4927
+ !(typeof window !== 'undefined' && window.location.pathname.endsWith(targetPath))
4784
4928
  const prev = prevSelectedResourceRef.current
4785
4929
  const current = selectedResource ?? null
4786
4930
  const drawerSwitched =
@@ -4794,7 +4938,7 @@ export function ResourcesView({
4794
4938
  prevSelectedResourceRef.current = current
4795
4939
 
4796
4940
  updateURL(selectedKind, debouncedSearchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
4797
- }, [selectedKind, debouncedSearchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
4941
+ }, [selectedKind, filtersKind, debouncedSearchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
4798
4942
 
4799
4943
  // Handle resource click from URL on mount
4800
4944
  useEffect(() => {
@@ -4815,6 +4959,12 @@ export function ResourcesView({
4815
4959
  // Signal that initial resource param has been processed — URL update effect can now run
4816
4960
  hasProcessedInitialResource.current = true
4817
4961
 
4962
+ // Filters restored at mount go into the address bar (with the kind segment).
4963
+ if (restoredKindSearch) {
4964
+ updateURL(selectedKind, searchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets)
4965
+ return
4966
+ }
4967
+
4818
4968
  // If the URL has no kind segment (e.g., /resources), update to include the default kind.
4819
4969
  // Route through `navigate` so an onNavigate-suppressing host can opt out.
4820
4970
  const base = basePath.replace(/\/$/, '')
@@ -5083,7 +5233,7 @@ export function ResourcesView({
5083
5233
  isForbiddenError(selectedQueryError) ||
5084
5234
  (!selectedHasRows && !selectedQueryError && forbiddenKinds.has(selectedKindCountKey))
5085
5235
 
5086
- // Reset filters when kind changes (but not when syncing from URL navigation)
5236
+ // On a kind change, restore that kind's remembered filters, or start empty (but not when syncing from URL navigation)
5087
5237
  // Track previous kind to skip on mount (where the effect fires but kind hasn't actually changed)
5088
5238
  // Keyed on group as well as plural: two CRDs can share a plural across API
5089
5239
  // groups, and their printer columns are unrelated. Resetting on the plural
@@ -5097,12 +5247,39 @@ export function ResourcesView({
5097
5247
  }
5098
5248
  prevKindRef.current = selectedKindIdentity
5099
5249
  setOpenColumnFilter(null)
5100
- if (!isSyncingFromURL.current) {
5101
- setColumnFilters({})
5102
- setColumnFilterExcludes({})
5103
- }
5104
- setProblemFilters([])
5105
- }, [selectedKindIdentity])
5250
+ setFiltersKind(selectedKindIdentity)
5251
+ // The URL sync already set this kind's filters (and restoredKindSearch).
5252
+ if (isSyncingFromURL.current) return
5253
+ filtersArrivedRef.current = 'restore'
5254
+ const restored = kindFiltersToSearch(kindFiltersFromSearch(rememberedKindSearch?.(selectedKind)))
5255
+ const remembered = kindFiltersFromSearch(restored)
5256
+ setColumnFilters(remembered.columnFilters)
5257
+ setColumnFilterExcludes(remembered.columnFilterExcludes)
5258
+ setProblemFilters(remembered.problemFilters)
5259
+ setRestoredKindSearch(restored)
5260
+ }, [selectedKindIdentity, selectedKind, rememberedKindSearch])
5261
+
5262
+ const kindFiltersSearch = useMemo(
5263
+ () => kindFiltersToSearch({ columnFilters, columnFilterExcludes, problemFilters }),
5264
+ [columnFilters, columnFilterExcludes, problemFilters],
5265
+ )
5266
+ // Saved filters are the last ones chosen for a kind: by the user, or by a
5267
+ // link that brought its own. A restore only shows what was saved (saving it
5268
+ // again would overwrite another tab's newer choice); a list that arrives
5269
+ // with no filters (a link to one resource, an owner's pods) keeps what was
5270
+ // saved; Clear filters saves none.
5271
+ const lastSeenKindFiltersRef = useRef<{ kind: string; search: string } | null>(null)
5272
+ useEffect(() => {
5273
+ // The host may wire saving after mount; until then nothing counts as seen.
5274
+ if (!onKindFiltersChange || filtersKind !== selectedKindIdentity) return
5275
+ const last = lastSeenKindFiltersRef.current
5276
+ lastSeenKindFiltersRef.current = { kind: filtersKind, search: kindFiltersSearch }
5277
+ const arrived = filtersArrivedRef.current
5278
+ filtersArrivedRef.current = null
5279
+ if (last?.kind === filtersKind && last.search === kindFiltersSearch) return
5280
+ if (arrived === 'restore' || (arrived === 'url' && !kindFiltersSearch)) return
5281
+ onKindFiltersChange(selectedKind, kindFiltersSearch)
5282
+ }, [filtersKind, selectedKindIdentity, kindFiltersSearch, selectedKind, onKindFiltersChange])
5106
5283
 
5107
5284
  // Toggle sort for a column
5108
5285
  const handleSort = useCallback((column: string) => {
@@ -5135,6 +5312,15 @@ export function ResourcesView({
5135
5312
  saveSavedSort(selectedKind.name, selectedKind.group, newColumn && newDirection ? { column: newColumn, direction: newDirection } : null)
5136
5313
  }, [sortColumn, sortDirection, canSortBy, selectedKind.name, selectedKind.group])
5137
5314
 
5315
+ const getDisplayFilterValue = useCallback((resource: { metadata?: { namespace?: string; name?: string } }, column: string, kind: string): string => {
5316
+ if (column === 'status' && selectedKind.group === 'batch' && kind === 'cronjobs') {
5317
+ const issue = operationalIssues?.[`${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`]
5318
+ if (issue) return issueTitle(issue)
5319
+ if (operationalIssuesIncomplete) return 'Unknown'
5320
+ }
5321
+ return getCellFilterValue(resource, column, kind)
5322
+ }, [operationalIssues, operationalIssuesIncomplete, selectedKind.group])
5323
+
5138
5324
  // Get sortable value from a resource for a given column
5139
5325
  const getSortValue = useCallback((resource: any, column: string, kind?: string): string | number => {
5140
5326
  const meta = resource.metadata || {}
@@ -5163,7 +5349,7 @@ export function ResourcesView({
5163
5349
  if (hasCuratedColumns(selectedKind.name, selectedKind.group) && status.phase) {
5164
5350
  return status.phase
5165
5351
  }
5166
- return getCellFilterValue(resource, 'status', kindLower)
5352
+ return getDisplayFilterValue(resource, 'status', kindLower)
5167
5353
  case 'servers':
5168
5354
  // Numeric so 10 does not sort before 9.
5169
5355
  if (kindLower === 'istiogateways') return getIstioGatewayServerCount(resource)
@@ -5253,7 +5439,7 @@ export function ResourcesView({
5253
5439
  default:
5254
5440
  return ''
5255
5441
  }
5256
- }, [metricsLookup, selectedKind.name, selectedKind.group])
5442
+ }, [metricsLookup, selectedKind.name, selectedKind.group, getDisplayFilterValue])
5257
5443
 
5258
5444
  // Helper to check if a pod matches problem filters
5259
5445
  const podMatchesProblemFilter = useCallback((pod: any, filters: string[]): boolean => {
@@ -5278,7 +5464,7 @@ export function ResourcesView({
5278
5464
 
5279
5465
  // Filter resources by search term, status, problems, and sort
5280
5466
  const filteredResources = useMemo(() => {
5281
- if (!resources) return []
5467
+ if (!tableActive || !resources) return []
5282
5468
 
5283
5469
  let result = resources
5284
5470
 
@@ -5310,7 +5496,7 @@ export function ResourcesView({
5310
5496
  result = result.filter((r: any) =>
5311
5497
  activeColFilters.every(([col, vals]) => {
5312
5498
  const extra = extraColumnsByKey.get(col)
5313
- const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col, cellFilterKind)
5499
+ const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getDisplayFilterValue(r, col, cellFilterKind)
5314
5500
  const match = vals.includes(cellVal)
5315
5501
  return columnFilterExcludes[col] ? !match : match
5316
5502
  })
@@ -5464,7 +5650,7 @@ export function ResourcesView({
5464
5650
  }
5465
5651
 
5466
5652
  return result
5467
- }, [resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, effectiveSortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
5653
+ }, [tableActive, resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, effectiveSortColumn, sortDirection, getSortValue, getDisplayFilterValue, extraColumnsByKey, podMatchesProblemFilter])
5468
5654
 
5469
5655
  // For nodes table: compute the majority minor version so outliers can be highlighted
5470
5656
  const majorityNodeMinorVersion = useMemo(() => {
@@ -5589,16 +5775,25 @@ export function ResourcesView({
5589
5775
  setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
5590
5776
  }, [allVisibleChecked, filteredResources, getResourceKey])
5591
5777
 
5592
- const isCheckboxMode = canBulkSelect && bulkMode
5778
+ const toggleCheckboxVisibility = useCallback(() => {
5779
+ const next = compareMode || !showCheckboxes
5780
+ exitCompareMode()
5781
+ clearBulkSelection()
5782
+ setShowCheckboxes(next)
5783
+ try { localStorage.setItem(CHECKBOX_VISIBILITY_KEY, String(next)) }
5784
+ catch { /* Keep the choice for this session when storage is unavailable. */ }
5785
+ }, [compareMode, showCheckboxes, exitCompareMode, clearBulkSelection])
5786
+
5787
+ const isCheckboxMode = canBulkSelect && !compareMode && showCheckboxes
5788
+ const showBulkBar = isCheckboxMode && checkedItems.length > 0
5593
5789
 
5594
5790
  // Stable row handlers so the memoized ResourceRowCells skips re-rendering
5595
5791
  // rows whose data didn't change on a refetch (React Query's structural
5596
5792
  // sharing keeps unchanged resources referentially identical).
5597
5793
  const handleRowClick = useCallback((resource: any, isSelected: boolean) => {
5598
5794
  if (compareMode) toggleComparePick(resource)
5599
- else if (isCheckboxMode) toggleChecked(resource)
5600
5795
  else selectResource(resource, isSelected)
5601
- }, [compareMode, isCheckboxMode, toggleComparePick, toggleChecked, selectResource])
5796
+ }, [compareMode, toggleComparePick, selectResource])
5602
5797
  const handleRowMouseEnter = useCallback(() => setHighlightedIndex(-1), [])
5603
5798
 
5604
5799
  // Filter columns by visibility
@@ -5660,7 +5855,7 @@ export function ResourcesView({
5660
5855
 
5661
5856
  // Calculate filter options with counts based on current resources (before filtering)
5662
5857
  const filterOptions = useMemo(() => {
5663
- if (!resources || resources.length === 0) return null
5858
+ if (!tableActive || !resources || resources.length === 0) return null
5664
5859
 
5665
5860
  const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
5666
5861
  const cellFilterKind = getCellFilterKind(selectedKind.name, selectedKind.group)
@@ -5690,7 +5885,7 @@ export function ResourcesView({
5690
5885
  // Count distinct values for this column
5691
5886
  const valueCounts: Record<string, number> = {}
5692
5887
  for (const r of resources) {
5693
- const val = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col.key, cellFilterKind)
5888
+ const val = extra?.getFilterValue ? extra.getFilterValue(r) : getDisplayFilterValue(r, col.key, cellFilterKind)
5694
5889
  if (val) {
5695
5890
  valueCounts[val] = (valueCounts[val] || 0) + 1
5696
5891
  }
@@ -5773,7 +5968,7 @@ export function ResourcesView({
5773
5968
 
5774
5969
  if (filterableColumns.length === 0 && !problems && labelValues.length === 0) return null
5775
5970
  return { columns: filterableColumns, problems, labels: labelValues }
5776
- }, [resources, selectedKind.name, selectedKind.group, allColumns])
5971
+ }, [tableActive, resources, selectedKind.name, selectedKind.group, allColumns, getDisplayFilterValue])
5777
5972
 
5778
5973
  // Map filterable columns by key for O(1) lookup in header rendering
5779
5974
  const filterableColumnMap = useMemo(() => {
@@ -5795,15 +5990,16 @@ export function ResourcesView({
5795
5990
  // Namespace contribution gated on a host-wired clearer: without it the
5796
5991
  // Clear filters button can't drop the namespace, so showing it would be
5797
5992
  // a no-op for that case.
5798
- const hasAnyFilter =
5799
- !!searchTerm ||
5800
- regexMode ||
5801
- !!labelSelector ||
5802
- hasOwnerFilter ||
5803
- problemFilters.length > 0 ||
5804
- Object.values(columnFilters).some((vals) => vals.length > 0) ||
5805
- showInactiveReplicaSets ||
5806
- (!!onClearNamespaces && namespaces.length > 0)
5993
+ const activeFilterCount =
5994
+ Object.values(columnFilters).filter((vals) => vals.length > 0).length +
5995
+ (problemFilters.length > 0 ? 1 : 0) +
5996
+ (labelSelector ? 1 : 0) +
5997
+ (searchTerm || regexMode ? 1 : 0) +
5998
+ (hasOwnerFilter ? 1 : 0) +
5999
+ (showInactiveReplicaSets ? 1 : 0) +
6000
+ (!!onClearNamespaces && namespaces.length > 0 ? 1 : 0)
6001
+ const hasAnyFilter = activeFilterCount > 0
6002
+ const filtersRestored = !!kindFiltersSearch && kindFiltersSearch === restoredKindSearch
5807
6003
 
5808
6004
 
5809
6005
  // Toggle problem filter
@@ -5847,9 +6043,11 @@ export function ResourcesView({
5847
6043
  certExpiry,
5848
6044
  certExpiryError,
5849
6045
  auditBadges,
6046
+ operationalIssues,
6047
+ operationalIssuesIncomplete,
5850
6048
  onOpenLogs,
5851
6049
  onOpenWorkloadLogs,
5852
- }), [onNavigate, certExpiry, certExpiryError, auditBadges, onOpenLogs, onOpenWorkloadLogs])
6050
+ }), [onNavigate, certExpiry, certExpiryError, auditBadges, operationalIssues, operationalIssuesIncomplete, onOpenLogs, onOpenWorkloadLogs])
5853
6051
 
5854
6052
  return (
5855
6053
  <ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
@@ -5879,7 +6077,9 @@ export function ResourcesView({
5879
6077
  />
5880
6078
  )}
5881
6079
 
5882
- {/* Main Content - Resource Table */}
6080
+ {kindView !== null ? (
6081
+ <div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-theme-surface">{kindView}</div>
6082
+ ) : (
5883
6083
  <div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-theme-surface">
5884
6084
  {/* Toolbar */}
5885
6085
  <div className="flex items-center gap-3 px-4 py-3 border-b border-theme-border bg-theme-base shrink-0">
@@ -6119,10 +6319,10 @@ export function ResourcesView({
6119
6319
  <button
6120
6320
  type="button"
6121
6321
  onClick={clearAllFilters}
6122
- className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated transition-colors"
6322
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs selection selection-text hover:selection-strong transition-colors"
6123
6323
  >
6124
6324
  <RotateCcw className="w-3.5 h-3.5" />
6125
- <span>Clear filters</span>
6325
+ <span>Clear filters ({activeFilterCount})</span>
6126
6326
  </button>
6127
6327
  </Tooltip>
6128
6328
  )}
@@ -6156,41 +6356,14 @@ export function ResourcesView({
6156
6356
  {/* Column list scrolls; the add-column footer stays pinned so
6157
6357
  its input isn't pushed below the fold on kinds with many columns. */}
6158
6358
  <div className="overflow-y-auto flex-1 min-h-0">
6159
- {allColumns.map(col => {
6160
- const isCustom = customColumnKeySet.has(col.key)
6161
- return (
6162
- <div
6163
- key={col.key}
6164
- className="group flex items-center gap-2 px-3 py-1.5 hover:bg-theme-elevated"
6165
- >
6166
- <label className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer">
6167
- <input
6168
- type="checkbox"
6169
- checked={visibleColumns.has(col.key)}
6170
- onChange={() => toggleColumnVisibility(col.key)}
6171
- disabled={col.key === 'name'}
6172
- className="rounded border-theme-border"
6173
- />
6174
- <span className={clsx(
6175
- 'text-sm truncate',
6176
- col.key === 'name' ? 'text-theme-text-tertiary' : 'text-theme-text-primary'
6177
- )} title={col.tooltip || col.label}>
6178
- {col.label}
6179
- </span>
6180
- </label>
6181
- {isCustom && (
6182
- <button
6183
- type="button"
6184
- onClick={() => removeCustomColumn(col.key)}
6185
- className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-red-500 opacity-0 group-hover:opacity-100 focus:opacity-100"
6186
- title="Remove column"
6187
- >
6188
- <X className="w-3.5 h-3.5" />
6189
- </button>
6190
- )}
6191
- </div>
6192
- )
6193
- })}
6359
+ <ColumnPickerList
6360
+ columns={allColumns}
6361
+ visibleColumns={visibleColumns}
6362
+ customColumnKeys={customColumnKeySet}
6363
+ onToggle={toggleColumnVisibility}
6364
+ onRemoveCustom={removeCustomColumn}
6365
+ onReorder={setColumnOrder}
6366
+ />
6194
6367
  </div>
6195
6368
  {/* Add a label/annotation column */}
6196
6369
  <div className="shrink-0 border-t border-theme-border pt-2 px-3 pb-1">
@@ -6260,7 +6433,7 @@ export function ResourcesView({
6260
6433
  <button
6261
6434
  onClick={() => {
6262
6435
  if (compareMode) { exitCompareMode(); return }
6263
- exitBulkMode()
6436
+ clearBulkSelection()
6264
6437
  setCompareMode(true)
6265
6438
  }}
6266
6439
  aria-pressed={compareMode}
@@ -6276,17 +6449,14 @@ export function ResourcesView({
6276
6449
  </Tooltip>
6277
6450
  )}
6278
6451
  {canBulkSelect && (
6279
- <Tooltip content={bulkMode ? 'Exit bulk select mode' : 'Select multiple resources'}>
6452
+ <Tooltip content={isCheckboxMode ? 'Hide selection checkboxes' : 'Show selection checkboxes'}>
6280
6453
  <button
6281
- onClick={() => {
6282
- if (bulkMode) { exitBulkMode(); return }
6283
- exitCompareMode()
6284
- setBulkMode(true)
6285
- }}
6286
- aria-pressed={bulkMode}
6454
+ onClick={toggleCheckboxVisibility}
6455
+ aria-label={isCheckboxMode ? 'Hide selection checkboxes' : 'Show selection checkboxes'}
6456
+ aria-pressed={isCheckboxMode}
6287
6457
  className={clsx(
6288
6458
  'p-2 rounded-lg transition-colors',
6289
- bulkMode
6459
+ isCheckboxMode
6290
6460
  ? 'bg-skyhook-500/15 text-skyhook-300 border border-skyhook-400/50'
6291
6461
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated',
6292
6462
  )}
@@ -6306,7 +6476,7 @@ export function ResourcesView({
6306
6476
  </div>
6307
6477
 
6308
6478
  {/* Bulk actions bar */}
6309
- {isCheckboxMode && (
6479
+ {showBulkBar && (
6310
6480
  <div className="flex items-center gap-3 px-4 py-2 bg-skyhook-500/10 border-b border-skyhook-400/20 shrink-0">
6311
6481
  <span className="text-sm font-medium text-theme-text-primary">
6312
6482
  {checkedItems.length} selected
@@ -6346,14 +6516,29 @@ export function ResourcesView({
6346
6516
  )}
6347
6517
  <button
6348
6518
  type="button"
6349
- onClick={exitBulkMode}
6519
+ onClick={clearBulkSelection}
6350
6520
  className="px-3 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
6351
6521
  >
6352
- Cancel
6522
+ Clear selection
6353
6523
  </button>
6354
6524
  </div>
6355
6525
  )}
6356
6526
 
6527
+ {!isLoading && !isSelectedForbidden && !largeListGuard && filteredResources.length > 0 && (
6528
+ <ActiveFilterBar
6529
+ columnFilters={columnFilters}
6530
+ columnFilterExcludes={columnFilterExcludes}
6531
+ problemFilters={problemFilters}
6532
+ labelSelector={labelSelector}
6533
+ onClearColumn={clearColumnFilter}
6534
+ onClearProblems={() => setProblemFilters([])}
6535
+ onClearLabels={() => setLabelSelector('')}
6536
+ restored={filtersRestored}
6537
+ onClearAll={clearKindFilters}
6538
+ className="shrink-0 px-4 py-1.5 border-b border-theme-border"
6539
+ />
6540
+ )}
6541
+
6357
6542
  {/* Table */}
6358
6543
  <div
6359
6544
  className="flex-1 overflow-auto relative"
@@ -6430,46 +6615,17 @@ export function ResourcesView({
6430
6615
  </button>
6431
6616
  )}
6432
6617
  {namespaces.length > 0 && <p className="text-sm mt-1 text-theme-text-disabled">Searching in {namespaces.length === 1 ? `namespace: ${namespaces[0]}` : `${namespaces.length} namespaces`}</p>}
6433
- {/* Show active filters as dismissible badges so user can clear them */}
6434
- {(() => {
6435
- const activeColEntries = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
6436
- if (activeColEntries.length === 0 && problemFilters.length === 0 && !labelSelector) return null
6437
- return (
6438
- <div className="flex flex-wrap items-center gap-1.5 mt-3">
6439
- {activeColEntries.map(([key, vals]) => (
6440
- <button
6441
- key={key}
6442
- onClick={() => clearColumnFilter(key)}
6443
- className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
6444
- >
6445
- <ListFilter className="w-3 h-3" />
6446
- <span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
6447
- <X className="w-3 h-3" />
6448
- </button>
6449
- ))}
6450
- {problemFilters.length > 0 && (
6451
- <button
6452
- onClick={() => setProblemFilters([])}
6453
- 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"
6454
- >
6455
- <AlertTriangle className="w-3 h-3" />
6456
- <span>Problems: {problemFilters.join(', ')}</span>
6457
- <X className="w-3 h-3" />
6458
- </button>
6459
- )}
6460
- {labelSelector && (
6461
- <button
6462
- onClick={() => setLabelSelector('')}
6463
- 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"
6464
- >
6465
- <Tag className="w-3 h-3" />
6466
- <span>{labelSelector}</span>
6467
- <X className="w-3 h-3" />
6468
- </button>
6469
- )}
6470
- </div>
6471
- )
6472
- })()}
6618
+ <ActiveFilterBar
6619
+ columnFilters={columnFilters}
6620
+ columnFilterExcludes={columnFilterExcludes}
6621
+ problemFilters={problemFilters}
6622
+ labelSelector={labelSelector}
6623
+ onClearColumn={clearColumnFilter}
6624
+ onClearProblems={() => setProblemFilters([])}
6625
+ onClearLabels={() => setLabelSelector('')}
6626
+ restored={filtersRestored}
6627
+ className="mt-3"
6628
+ />
6473
6629
  {hasAnyFilter && (
6474
6630
  <button
6475
6631
  type="button"
@@ -6697,6 +6853,22 @@ export function ResourcesView({
6697
6853
  <div className="px-3 py-2 text-xs text-theme-text-disabled">No matches</div>
6698
6854
  )}
6699
6855
  </div>
6856
+ {col.key === 'namespace' && onApplyNamespacesToAllSections && activeFilterValues.length > 0 && !columnFilterExcludes[col.key] && (
6857
+ <div className="p-1 border-t border-theme-border">
6858
+ <button
6859
+ onClick={() => {
6860
+ setOpenColumnFilter(null)
6861
+ // Keep the column filter until the scope change lands; a failed one keeps the user's filter.
6862
+ Promise.resolve(onApplyNamespacesToAllSections(activeFilterValues))
6863
+ .then(() => clearColumnFilter(col.key), () => {})
6864
+ }}
6865
+ className="w-full text-left px-2 py-1.5 text-xs rounded text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary transition-colors"
6866
+ title="Scope every section to these namespaces in the header namespace picker"
6867
+ >
6868
+ Apply to all sections
6869
+ </button>
6870
+ </div>
6871
+ )}
6700
6872
  </div>
6701
6873
  )
6702
6874
  })()}
@@ -6775,17 +6947,18 @@ export function ResourcesView({
6775
6947
  />
6776
6948
  )}
6777
6949
  </div>
6950
+ )}
6778
6951
  </div>
6779
6952
 
6780
6953
  {/* Bulk delete confirmation */}
6781
6954
  <ConfirmDialog
6782
- open={showBulkDeleteConfirm}
6955
+ open={tableActive && showBulkDeleteConfirm}
6783
6956
  onClose={() => { setShowBulkDeleteConfirm(false); setBulkForceDelete(false) }}
6784
6957
  onConfirm={() => {
6785
6958
  onBulkDelete?.(checkedBulkItems, {
6786
6959
  force: bulkForceDelete,
6787
6960
  onSuccess: () => {
6788
- exitBulkMode()
6961
+ clearBulkSelection()
6789
6962
  setShowBulkDeleteConfirm(false)
6790
6963
  setBulkForceDelete(false)
6791
6964
  },
@@ -6810,12 +6983,12 @@ export function ResourcesView({
6810
6983
  </label>
6811
6984
  </ConfirmDialog>
6812
6985
  <ConfirmDialog
6813
- open={showBulkRestartConfirm}
6986
+ open={tableActive && showBulkRestartConfirm}
6814
6987
  onClose={() => setShowBulkRestartConfirm(false)}
6815
6988
  onConfirm={() => {
6816
6989
  onBulkRestart?.(checkedBulkItems, {
6817
6990
  onSuccess: () => {
6818
- exitBulkMode()
6991
+ clearBulkSelection()
6819
6992
  setShowBulkRestartConfirm(false)
6820
6993
  },
6821
6994
  })
@@ -6829,12 +7002,12 @@ export function ResourcesView({
6829
7002
  isClosable
6830
7003
  />
6831
7004
  <ConfirmDialog
6832
- open={showBulkScaleDialog}
7005
+ open={tableActive && showBulkScaleDialog}
6833
7006
  onClose={() => setShowBulkScaleDialog(false)}
6834
7007
  onConfirm={() => {
6835
7008
  onBulkScale?.(checkedBulkItems, bulkScaleReplicas, {
6836
7009
  onSuccess: () => {
6837
- exitBulkMode()
7010
+ clearBulkSelection()
6838
7011
  setShowBulkScaleDialog(false)
6839
7012
  },
6840
7013
  })
@@ -7235,6 +7408,7 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
7235
7408
  case 'grpcroutes':
7236
7409
  case 'tcproutes':
7237
7410
  case 'tlsroutes':
7411
+ case 'udproutes':
7238
7412
  return <RouteCell resource={resource} column={column} />
7239
7413
  case 'gatewayclasses':
7240
7414
  return <GatewayClassCell resource={resource} column={column} />
@@ -7925,9 +8099,9 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
7925
8099
  const label = isCPU ? 'CPU' : 'Memory'
7926
8100
  const format = isCPU ? formatCPU : formatMemoryShort
7927
8101
 
7928
- // Multi-container pods carry a per-container breakdown; single-container
7929
- // pods fall back to the (union-summed) pod-level fields as one synthetic
7930
- // container so the rest of the logic is uniform.
8102
+ // Multi-container pods (and any pod with a pod-level budget) carry a
8103
+ // per-container breakdown; other single-container pods fall back to the
8104
+ // pod-level fields as one synthetic container so the logic is uniform.
7931
8105
  const list: ContainerResourceMetrics[] = (m.containers && m.containers.length > 0)
7932
8106
  ? m.containers
7933
8107
  : [{
@@ -7936,7 +8110,15 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
7936
8110
  memory: m.memory, memoryRequest: m.memoryRequest, memoryLimit: m.memoryLimit,
7937
8111
  }]
7938
8112
 
7939
- const { mode, totalUsage, denom, markerPct, unlimitedCount } = podAggregate(list, kind)
8113
+ // The pod totals already carry any pod-level value (overlaid server-side
8114
+ // with exact quantity parsing); spec.resources only says whether one is set.
8115
+ const podLevel = resource.spec?.resources
8116
+ const podRequest = podLevel?.requests?.[kind] ? (isCPU ? m.cpuRequest : m.memoryRequest) : undefined
8117
+ const podLimit = podLevel?.limits?.[kind] ? (isCPU ? m.cpuLimit : m.memoryLimit) : undefined
8118
+ const { mode, totalUsage, denom, markerPct, unlimitedCount } = podAggregate(list, kind, {
8119
+ request: podRequest,
8120
+ limit: podLimit,
8121
+ })
7940
8122
  if (totalUsage === 0) return <span className="text-sm text-theme-text-tertiary">-</span>
7941
8123
 
7942
8124
  // Pod-vs-node context: how full the pod's node is (the risk signal) and
@@ -7956,7 +8138,12 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
7956
8138
  }
7957
8139
  : undefined
7958
8140
 
7959
- const tip = buildContainerResourceTooltip(label, list, kind, format, nodeCtx)
8141
+ // The synthetic single-container row already carries the pod totals, so
8142
+ // only a real per-container breakdown needs the shared budget spelled out.
8143
+ const podBudget = podLimit && m.containers?.length
8144
+ ? { usage: totalUsage, limit: podLimit, request: podRequest }
8145
+ : undefined
8146
+ const tip = buildContainerResourceTooltip(label, list, kind, format, nodeCtx, podBudget)
7960
8147
 
7961
8148
  // Every container is limited → aggregate bar against the summed limit; a
7962
8149
  // real pod-level ceiling, so it may go red.
@@ -8452,6 +8639,7 @@ function JobCell({ resource, column }: { resource: any; column: string }) {
8452
8639
  }
8453
8640
 
8454
8641
  function CronJobCell({ resource, column }: { resource: any; column: string }) {
8642
+ const { operationalIssues, operationalIssuesIncomplete } = useContext(ResourcesViewDataContext)
8455
8643
  switch (column) {
8456
8644
  case 'schedule': {
8457
8645
  const { cron, readable } = getCronJobSchedule(resource)
@@ -8463,6 +8651,13 @@ function CronJobCell({ resource, column }: { resource: any; column: string }) {
8463
8651
  )
8464
8652
  }
8465
8653
  case 'status': {
8654
+ const issue = operationalIssues?.[`${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`]
8655
+ if (issue) {
8656
+ return <Tooltip content={issue.message}><Badge severity={issue.severity === 'critical' ? 'error' : 'warning'}>{issueTitle(issue)}</Badge></Tooltip>
8657
+ }
8658
+ if (operationalIssuesIncomplete) {
8659
+ return <Tooltip content="The issue list is incomplete. This CronJob's status could not be checked."><Badge severity="neutral">Unknown</Badge></Tooltip>
8660
+ }
8466
8661
  const status = getCronJobStatus(resource)
8467
8662
  return (
8468
8663
  <span className={clsx('badge', status.color)}>
@@ -8581,12 +8776,16 @@ interface PodAggregate {
8581
8776
  // headline renders — total usage measured against a pod-level yardstick. It
8582
8777
  // keeps the dominant consumer visible instead of demoting it behind a small
8583
8778
  // limited container, and stays honest about partial limits (which don't form a
8584
- // real ceiling).
8585
- export function podAggregate(list: ContainerResourceMetrics[], kind: 'cpu' | 'memory'): PodAggregate {
8779
+ // real ceiling). A pod-level budget (spec.resources) bounds every container, so
8780
+ // its limit is a real ceiling regardless of what the containers set.
8781
+ export function podAggregate(
8782
+ list: ContainerResourceMetrics[],
8783
+ kind: 'cpu' | 'memory',
8784
+ podLevel?: { request?: number; limit?: number },
8785
+ ): PodAggregate {
8586
8786
  const isCPU = kind === 'cpu'
8587
8787
  let totalUsage = 0
8588
8788
  let limitedCount = 0
8589
- let requestedCount = 0
8590
8789
  let unlimitedCount = 0
8591
8790
  let summedLimit = 0
8592
8791
  let summedRequest = 0
@@ -8601,25 +8800,28 @@ export function podAggregate(list: ContainerResourceMetrics[], kind: 'cpu' | 'me
8601
8800
  } else {
8602
8801
  unlimitedCount++
8603
8802
  }
8604
- if (request > 0) {
8605
- requestedCount++
8606
- summedRequest += request
8607
- }
8803
+ if (request > 0) summedRequest += request
8608
8804
  }
8805
+ if (podLevel?.request) summedRequest = podLevel.request
8609
8806
  const allLimited = list.length > 0 && limitedCount === list.length
8610
- if (allLimited) {
8807
+ // Container limits still apply inside a pod-level budget, so when every
8808
+ // container has one the tighter of the two totals is the real ceiling.
8809
+ const ceiling = podLevel?.limit && allLimited
8810
+ ? Math.min(podLevel.limit, summedLimit)
8811
+ : podLevel?.limit || (allLimited ? summedLimit : 0)
8812
+ if (ceiling) {
8611
8813
  return {
8612
8814
  mode: 'limit',
8613
8815
  totalUsage,
8614
- denom: summedLimit,
8615
- markerPct: summedRequest > 0 ? (summedRequest / summedLimit) * 100 : undefined,
8816
+ denom: ceiling,
8817
+ markerPct: summedRequest > 0 ? (summedRequest / ceiling) * 100 : undefined,
8616
8818
  unlimitedCount,
8617
8819
  }
8618
8820
  }
8619
8821
  if (limitedCount > 0) {
8620
8822
  return { mode: 'partial', totalUsage, denom: 0, unlimitedCount }
8621
8823
  }
8622
- if (requestedCount > 0) {
8824
+ if (summedRequest > 0) {
8623
8825
  return { mode: 'request', totalUsage, denom: summedRequest, unlimitedCount }
8624
8826
  }
8625
8827
  return { mode: 'none', totalUsage, denom: 0, unlimitedCount }
@@ -8647,12 +8849,13 @@ function formatSharePct(pct: number): string {
8647
8849
  return `${Math.round(pct)}%`
8648
8850
  }
8649
8851
 
8650
- function buildContainerResourceTooltip(
8852
+ export function buildContainerResourceTooltip(
8651
8853
  label: 'CPU' | 'Memory',
8652
8854
  containers: ContainerResourceMetrics[],
8653
8855
  kind: 'cpu' | 'memory',
8654
8856
  formatFn: (n: number) => string,
8655
8857
  nodeCtx?: NodeContext,
8858
+ podBudget?: { usage: number; limit: number; request?: number },
8656
8859
  ) {
8657
8860
  const rows = [...containers].sort((a, b) => {
8658
8861
  const diff = readContainer(b, kind).pct - readContainer(a, kind).pct
@@ -8675,7 +8878,7 @@ function buildContainerResourceTooltip(
8675
8878
  ? `${formatFn(r.usage)} · ${Math.round(r.pct)}% · ${formatFn(r.denom)} limit${r.request > 0 ? ` · req ${formatFn(r.request)}` : ''}`
8676
8879
  : r.yardstick === 'request'
8677
8880
  ? `${formatFn(r.usage)} · ${Math.round(r.pct)}% · ${formatFn(r.denom)} request`
8678
- : `${formatFn(r.usage)} · no limit`
8881
+ : `${formatFn(r.usage)} · ${podBudget ? 'pod limit' : 'no limit'}`
8679
8882
  return (
8680
8883
  <div key={c.name} className="flex flex-col leading-snug">
8681
8884
  <span className="text-[11px] text-theme-text-tertiary truncate">{c.name}</span>
@@ -8689,6 +8892,14 @@ function buildContainerResourceTooltip(
8689
8892
  +{remaining} more → open pod
8690
8893
  </div>
8691
8894
  )}
8895
+ {podBudget && (
8896
+ <div className="flex flex-col leading-snug border-t border-theme-border/50 pt-1">
8897
+ <span className="text-[11px] text-theme-text-tertiary truncate">Pod (shared)</span>
8898
+ <span className="text-xs font-mono text-theme-text-primary">
8899
+ {`${formatFn(podBudget.usage)} · ${Math.round((podBudget.usage / podBudget.limit) * 100)}% · ${formatFn(podBudget.limit)} limit${podBudget.request ? ` · req ${formatFn(podBudget.request)}` : ''}`}
8900
+ </span>
8901
+ </div>
8902
+ )}
8692
8903
  {nodeCtx && (
8693
8904
  <div className="flex flex-col leading-snug border-t border-theme-border/50 pt-1">
8694
8905
  <span className="text-[11px] text-theme-text-tertiary truncate">Node {nodeCtx.name}</span>
@@ -9130,7 +9341,7 @@ function GatewayClassCell({ resource, column }: { resource: any; column: string
9130
9341
  }
9131
9342
  }
9132
9343
 
9133
- // Shared cell renderer for all Gateway API route types (HTTPRoute, GRPCRoute, TCPRoute, TLSRoute)
9344
+ // Shared cell renderer for all Gateway API route types (HTTPRoute, GRPCRoute, TCPRoute, TLSRoute, UDPRoute)
9134
9345
  function RouteCell({ resource, column }: { resource: any; column: string }) {
9135
9346
  switch (column) {
9136
9347
  case 'status': {