@skyhook-io/k8s-ui 1.13.0 → 1.13.4

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 (110) hide show
  1. package/package.json +2 -2
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +41 -9
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +35 -7
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +973 -100
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  36. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  37. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  38. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  39. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  41. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  43. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  44. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  45. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  46. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  47. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  48. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  49. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  50. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  51. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  52. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  53. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  54. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  55. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  56. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  57. package/src/components/resources/resource-utils-hpa.ts +6 -2
  58. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  59. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  60. package/src/components/resources/resource-utils-kai.ts +105 -0
  61. package/src/components/resources/resource-utils-kaito.ts +114 -0
  62. package/src/components/resources/resource-utils-kserve.ts +186 -0
  63. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  64. package/src/components/resources/resource-utils-kueue.ts +198 -0
  65. package/src/components/resources/resource-utils-nim.ts +109 -0
  66. package/src/components/resources/resource-utils-ray.ts +214 -0
  67. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  68. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  69. package/src/components/resources/resource-utils-volcano.ts +176 -0
  70. package/src/components/resources/resource-utils.ts +156 -47
  71. package/src/components/resources/resources-column-filter.test.ts +27 -1
  72. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  73. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  74. package/src/components/shared/EditableYamlView.tsx +4 -2
  75. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  76. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  77. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  78. package/src/components/shared/SetImageDialog.test.ts +129 -0
  79. package/src/components/shared/SetImageDialog.tsx +577 -0
  80. package/src/components/shared/index.ts +1 -0
  81. package/src/components/shared/revision-role-badges.test.ts +56 -4
  82. package/src/components/timeline/TimelineList.tsx +19 -3
  83. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  84. package/src/components/trace/ReachabilityView.tsx +1 -1
  85. package/src/components/trace/TracePanel.tsx +1 -0
  86. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  87. package/src/components/ui/SelectMenu.tsx +202 -28
  88. package/src/components/ui/drawer-components.tsx +12 -0
  89. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  90. package/src/components/workload/WorkloadView.tsx +146 -83
  91. package/src/hooks/useProgressiveRefresh.ts +37 -0
  92. package/src/source-hygiene.test.ts +28 -0
  93. package/src/theme/tailwind-theme.css +4 -0
  94. package/src/types/core.ts +62 -4
  95. package/src/utils/api-resources.test.ts +40 -1
  96. package/src/utils/api-resources.ts +11 -1
  97. package/src/utils/applications.ts +50 -0
  98. package/src/utils/bulk-workload-actions.test.ts +20 -0
  99. package/src/utils/bulk-workload-actions.ts +15 -0
  100. package/src/utils/clipboard.test.ts +86 -0
  101. package/src/utils/clipboard.ts +50 -0
  102. package/src/utils/extended-resources.ts +4 -7
  103. package/src/utils/format.test.ts +83 -1
  104. package/src/utils/format.ts +80 -1
  105. package/src/utils/inClusterConsent.test.ts +9 -0
  106. package/src/utils/inClusterConsent.ts +9 -9
  107. package/src/utils/index.ts +1 -0
  108. package/src/utils/navigation.test.ts +31 -1
  109. package/src/utils/navigation.ts +21 -2
  110. package/src/utils/workload-rollout.ts +292 -0
@@ -34,11 +34,14 @@ import { ResourceBar } from '../ui/ResourceBar'
34
34
  import type { SelectedResource, APIResource } from '../../types'
35
35
  import { isForbiddenError } from '../../types/fetch-error'
36
36
  import type { NavigateToResource } from '../../utils/navigation'
37
- import { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute } from '../../utils/api-resources'
37
+ import { categorizeResources, CORE_RESOURCES, findAPIResourceForRoute, isCoreBatchJob } from '../../utils/api-resources'
38
+ import { copyText } from '../../utils/clipboard'
38
39
  import {
39
40
  getPodStatus,
40
41
  getPodRestarts,
41
42
  getPodProblems,
43
+ getWorkloadDisplayStatus,
44
+ workloadStatusLabel,
42
45
  getWorkloadProblems,
43
46
  workloadMatchesProblemCategory,
44
47
  getContainerSquareStates,
@@ -78,7 +81,6 @@ import {
78
81
  getPVCStatus,
79
82
  getPVCCapacity,
80
83
  getPVCAccessModes,
81
- getRolloutStatus,
82
84
  getAnalysisRunStatus,
83
85
  summarizeAnalysisMetrics,
84
86
  getRolloutStrategy,
@@ -135,11 +137,20 @@ import {
135
137
  parseColumnFilterExcludes,
136
138
  podMatchesProblemCategory,
137
139
  SEVERITY_DOT_COLOR,
140
+ healthColors,
138
141
  } from './resource-utils'
142
+ import { getGenericResourceStatus } from './generic-status'
143
+ import { getIstioGatewayServerCount, getIstioGatewaySelectorString } from './resource-utils-istio'
144
+ import {
145
+ type PrinterColumnDef, type PrinterTable, PRINTER_COLUMN_PREFIX, formatPrinterCell, printerCellSortValue,
146
+ printerCellTone, printerColumnKey, printerColumnWidth, printerTableKey, printerTableMatchesKind, readPrinterCell,
147
+ } from './printer-columns'
139
148
  import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
140
149
  import { pluralize } from '../../utils/pluralize'
141
150
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
151
+ import { parseQuantityToNumber } from '../../utils/format'
142
152
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
153
+ import { isRolloutActivityVisible } from '../../utils/workload-rollout'
143
154
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
144
155
  import { Tooltip } from '../ui/Tooltip'
145
156
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
@@ -153,6 +164,18 @@ import { NodePoolCell, NodeClaimCell, EC2NodeClassCell } from './renderers/karpe
153
164
  import { ScaledObjectCell, ScaledJobCell, TriggerAuthenticationCell, ClusterTriggerAuthenticationCell } from './renderers/keda-cells'
154
165
  import { ResourceClaimCell, ResourceClaimTemplateCell, DeviceClassCell, ResourceSliceCell } from './renderers/dra-cells'
155
166
  import { NvidiaClusterPolicyCell, NvidiaDriverCell } from './renderers/nvidia-cells'
167
+ import { ClusterQueueCell, LocalQueueCell, KueueWorkloadCell, ResourceFlavorCell, AdmissionCheckCell, ProvisioningRequestCell } from './renderers/kueue-cells'
168
+ import { RayClusterCell, RayJobCell, RayServiceCell, RayCronJobCell } from './renderers/ray-cells'
169
+ import { LeaderWorkerSetCell, JobSetCell } from './renderers/jobset-lws-cells'
170
+ import { getJobSetReadyJobs, getJobSetRestarts } from './resource-utils-jobset-lws'
171
+ import { InferenceServiceCell, ServingRuntimeCell, InferenceGraphCell, TrainedModelCell, LLMInferenceServiceCell } from './renderers/kserve-cells'
172
+ import { InferencePoolCell, InferenceObjectiveCell } from './renderers/inference-gateway-cells'
173
+ import { VolcanoJobCell, VolcanoQueueCell, VolcanoPodGroupCell, JobFlowCell, JobTemplateCell } from './renderers/volcano-cells'
174
+ import { KaiQueueCell, KaiPodGroupCell } from './renderers/kai-cells'
175
+ import { KaitoWorkspaceCell, RAGEngineCell } from './renderers/kaito-cells'
176
+ import { NIMServiceCell, NIMCacheCell, NIMPipelineCell } from './renderers/nim-cells'
177
+ import { AMDDeviceConfigCell } from './renderers/amd-gpu-cells'
178
+ import { PyTorchJobCell, TFJobCell, MPIJobCell, TrainJobCell } from './renderers/kubeflow-training-cells'
156
179
  import { ServiceMonitorCell, PrometheusRuleCell, PodMonitorCell } from './renderers/prometheus-cells'
157
180
  import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPolicyCell,
158
181
  KyvernoUpdateRequestCell,
@@ -355,6 +378,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
355
378
  deployments: [
356
379
  { key: 'name', label: 'Name' },
357
380
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
381
+ { key: 'status', label: 'Status', width: 'w-40' },
358
382
  { key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Ready pods / Desired replicas' },
359
383
  { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
360
384
  { key: 'available', label: 'Available', width: 'w-28', hideOnMobile: true, tooltip: 'Number of pods available (ready for minReadySeconds)' },
@@ -364,6 +388,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
364
388
  daemonsets: [
365
389
  { key: 'name', label: 'Name' },
366
390
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
391
+ { key: 'status', label: 'Status', width: 'w-40' },
367
392
  { key: 'desired', label: 'Desired', width: 'w-20', tooltip: 'Number of nodes that should run the daemon pod (based on node selector)' },
368
393
  { key: 'ready', label: 'Ready', width: 'w-20', tooltip: 'Number of pods that are ready (passing readiness probes)' },
369
394
  { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template spec' },
@@ -374,6 +399,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
374
399
  statefulsets: [
375
400
  { key: 'name', label: 'Name' },
376
401
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
402
+ { key: 'status', label: 'Status', width: 'w-40' },
377
403
  { key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Ready pods / Desired replicas' },
378
404
  { key: 'upToDate', label: 'Up-to-date', width: 'w-32', hideOnMobile: true, tooltip: 'Number of pods running the current pod template' },
379
405
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
@@ -492,7 +518,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
492
518
  rollouts: [
493
519
  { key: 'name', label: 'Name' },
494
520
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
495
- { key: 'status', label: 'Phase', width: 'w-28' },
521
+ { key: 'status', label: 'Status', width: 'w-32' },
496
522
  { key: 'ready', label: 'Ready', width: 'w-24', tooltip: 'Available / Desired replicas' },
497
523
  { key: 'strategy', label: 'Strategy', width: 'w-24' },
498
524
  { key: 'step', label: 'Step', width: 'w-20', hideOnMobile: true, tooltip: 'Current canary step / Total steps' },
@@ -610,6 +636,14 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
610
636
  { key: 'rules', label: 'Rules', width: 'w-16', hideOnMobile: true },
611
637
  { key: 'age', label: 'Age', width: 'w-24' },
612
638
  ],
639
+ istiogateways: [
640
+ { key: 'name', label: 'Name' },
641
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
642
+ { key: 'status', label: 'Status', width: 'w-28' },
643
+ { key: 'servers', label: 'Servers', width: 'w-24' },
644
+ { key: 'selector', label: 'Selector', width: 'w-56' },
645
+ { key: 'age', label: 'Age', width: 'w-24' },
646
+ ],
613
647
  gatewayclasses: [
614
648
  { key: 'name', label: 'Name' },
615
649
  { key: 'controller', label: 'Controller', width: 'w-64', tooltip: 'Gateway controller implementation (spec.controllerName)' },
@@ -695,6 +729,314 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
695
729
  { key: 'version', label: 'Version', width: 'w-32' },
696
730
  { key: 'age', label: 'Age', width: 'w-24' },
697
731
  ],
732
+ clusterqueues: [
733
+ { key: 'name', label: 'Name' },
734
+ { key: 'status', label: 'Status', width: 'w-24' },
735
+ { key: 'cohort', label: 'Cohort', width: 'w-32' },
736
+ { key: 'pendingWorkloads', label: 'Pending', width: 'w-24', tooltip: 'Pending workloads' },
737
+ { key: 'admittedWorkloads', label: 'Admitted', width: 'w-24', tooltip: 'Admitted workloads' },
738
+ { key: 'flavors', label: 'Flavors', width: 'w-40' },
739
+ { key: 'age', label: 'Age', width: 'w-24' },
740
+ ],
741
+ localqueues: [
742
+ { key: 'name', label: 'Name' },
743
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
744
+ { key: 'status', label: 'Status', width: 'w-24' },
745
+ { key: 'clusterQueue', label: 'Cluster Queue', width: 'w-40' },
746
+ { key: 'pendingWorkloads', label: 'Pending', width: 'w-24' },
747
+ { key: 'admittedWorkloads', label: 'Admitted', width: 'w-24' },
748
+ { key: 'age', label: 'Age', width: 'w-24' },
749
+ ],
750
+ workloads: [
751
+ { key: 'name', label: 'Name' },
752
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
753
+ { key: 'status', label: 'Status', width: 'w-28' },
754
+ { key: 'queueName', label: 'Queue', width: 'w-36' },
755
+ { key: 'admittedBy', label: 'Admitted By', width: 'w-36', tooltip: 'Admitting ClusterQueue' },
756
+ { key: 'priority', label: 'Priority', width: 'w-24' },
757
+ { key: 'age', label: 'Age', width: 'w-24' },
758
+ ],
759
+ resourceflavors: [
760
+ { key: 'name', label: 'Name' },
761
+ { key: 'status', label: 'Status', width: 'w-24' },
762
+ { key: 'nodeLabels', label: 'Node Labels', width: 'w-28', tooltip: 'Node label selector count' },
763
+ { key: 'taints', label: 'Taints', width: 'w-24' },
764
+ { key: 'age', label: 'Age', width: 'w-24' },
765
+ ],
766
+ admissionchecks: [
767
+ { key: 'name', label: 'Name' },
768
+ { key: 'status', label: 'Status', width: 'w-24' },
769
+ { key: 'controllerName', label: 'Controller', width: 'w-64' },
770
+ { key: 'age', label: 'Age', width: 'w-24' },
771
+ ],
772
+ provisioningrequests: [
773
+ { key: 'name', label: 'Name' },
774
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
775
+ { key: 'status', label: 'Status', width: 'w-28' },
776
+ { key: 'provisioningClassName', label: 'Class', width: 'w-56' },
777
+ { key: 'podSets', label: 'Pod Sets', width: 'w-24' },
778
+ { key: 'age', label: 'Age', width: 'w-24' },
779
+ ],
780
+ rayclusters: [
781
+ { key: 'name', label: 'Name' },
782
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
783
+ { key: 'status', label: 'Status', width: 'w-28' },
784
+ { key: 'rayVersion', label: 'Ray Version', width: 'w-28' },
785
+ { key: 'workers', label: 'Workers', width: 'w-24', tooltip: 'Available/desired worker replicas' },
786
+ { key: 'headService', label: 'Head Service', width: 'w-48' },
787
+ { key: 'age', label: 'Age', width: 'w-24' },
788
+ ],
789
+ rayjobs: [
790
+ { key: 'name', label: 'Name' },
791
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
792
+ { key: 'status', label: 'Status', width: 'w-28' },
793
+ { key: 'jobStatus', label: 'Job Status', width: 'w-28' },
794
+ { key: 'deploymentStatus', label: 'Deployment', width: 'w-32' },
795
+ { key: 'cluster', label: 'Cluster', width: 'w-48' },
796
+ { key: 'age', label: 'Age', width: 'w-24' },
797
+ ],
798
+ rayservices: [
799
+ { key: 'name', label: 'Name' },
800
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
801
+ { key: 'status', label: 'Status', width: 'w-28' },
802
+ { key: 'serviceStatus', label: 'Service Status', width: 'w-28' },
803
+ { key: 'clusters', label: 'Clusters', width: 'w-56', tooltip: 'Active and pending RayCluster names' },
804
+ { key: 'age', label: 'Age', width: 'w-24' },
805
+ ],
806
+ raycronjobs: [
807
+ { key: 'name', label: 'Name' },
808
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
809
+ { key: 'status', label: 'Status', width: 'w-24' },
810
+ { key: 'schedule', label: 'Schedule', width: 'w-32' },
811
+ { key: 'suspend', label: 'Suspend', width: 'w-20' },
812
+ { key: 'lastSchedule', label: 'Last Schedule', width: 'w-28' },
813
+ { key: 'age', label: 'Age', width: 'w-24' },
814
+ ],
815
+ leaderworkersets: [
816
+ { key: 'name', label: 'Name' },
817
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
818
+ { key: 'status', label: 'Status', width: 'w-28' },
819
+ { key: 'replicas', label: 'Groups', width: 'w-20', tooltip: 'Desired leader-worker groups' },
820
+ { key: 'size', label: 'Size', width: 'w-20', tooltip: 'Pods per group' },
821
+ { key: 'ready', label: 'Ready', width: 'w-20', tooltip: 'Ready groups / desired groups' },
822
+ { key: 'updated', label: 'Updated', width: 'w-20' },
823
+ { key: 'age', label: 'Age', width: 'w-24' },
824
+ ],
825
+ jobsets: [
826
+ { key: 'name', label: 'Name' },
827
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
828
+ { key: 'status', label: 'Status', width: 'w-28' },
829
+ { key: 'replicatedJobs', label: 'Repl. Jobs', width: 'w-24', tooltip: 'Number of replicated job templates' },
830
+ { key: 'ready', label: 'Ready', width: 'w-20' },
831
+ { key: 'succeeded', label: 'Succeeded', width: 'w-24' },
832
+ { key: 'failed', label: 'Failed', width: 'w-20' },
833
+ { key: 'restarts', label: 'Restarts', width: 'w-24' },
834
+ { key: 'age', label: 'Age', width: 'w-24' },
835
+ ],
836
+ inferenceservices: [
837
+ { key: 'name', label: 'Name' },
838
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
839
+ { key: 'status', label: 'Status', width: 'w-36', tooltip: 'Ready condition + model load state' },
840
+ { key: 'url', label: 'URL', width: 'w-64' },
841
+ { key: 'modelFormat', label: 'Format', width: 'w-28' },
842
+ { key: 'runtime', label: 'Runtime', width: 'w-40' },
843
+ { key: 'deploymentMode', label: 'Mode', width: 'w-28', tooltip: 'Serverless, RawDeployment, or ModelMesh' },
844
+ { key: 'age', label: 'Age', width: 'w-24' },
845
+ ],
846
+ servingruntimes: [
847
+ { key: 'name', label: 'Name' },
848
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
849
+ { key: 'status', label: 'Status', width: 'w-24' },
850
+ { key: 'modelFormats', label: 'Model Formats', width: 'w-44', tooltip: 'Supported model formats' },
851
+ { key: 'image', label: 'Image', width: 'w-64' },
852
+ { key: 'age', label: 'Age', width: 'w-24' },
853
+ ],
854
+ clusterservingruntimes: [
855
+ { key: 'name', label: 'Name' },
856
+ { key: 'status', label: 'Status', width: 'w-24' },
857
+ { key: 'modelFormats', label: 'Model Formats', width: 'w-44', tooltip: 'Supported model formats' },
858
+ { key: 'image', label: 'Image', width: 'w-64' },
859
+ { key: 'age', label: 'Age', width: 'w-24' },
860
+ ],
861
+ inferencegraphs: [
862
+ { key: 'name', label: 'Name' },
863
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
864
+ { key: 'status', label: 'Status', width: 'w-24' },
865
+ { key: 'nodes', label: 'Nodes', width: 'w-20', tooltip: 'Router nodes in the graph' },
866
+ { key: 'age', label: 'Age', width: 'w-24' },
867
+ ],
868
+ trainedmodels: [
869
+ { key: 'name', label: 'Name' },
870
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
871
+ { key: 'status', label: 'Status', width: 'w-24' },
872
+ { key: 'framework', label: 'Framework', width: 'w-28' },
873
+ { key: 'storageUri', label: 'Storage URI', width: 'w-56' },
874
+ { key: 'inferenceService', label: 'Inference Service', width: 'w-40', tooltip: 'Parent InferenceService' },
875
+ { key: 'age', label: 'Age', width: 'w-24' },
876
+ ],
877
+ llminferenceservices: [
878
+ { key: 'name', label: 'Name' },
879
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
880
+ { key: 'status', label: 'Status', width: 'w-28' },
881
+ { key: 'model', label: 'Model', width: 'w-56', tooltip: 'Model name or URI' },
882
+ { key: 'replicas', label: 'Replicas', width: 'w-24' },
883
+ { key: 'age', label: 'Age', width: 'w-24' },
884
+ ],
885
+ inferencepools: [
886
+ { key: 'name', label: 'Name' },
887
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
888
+ { key: 'status', label: 'Status', width: 'w-36', tooltip: 'Gateway acceptance and EPP resolution' },
889
+ { key: 'selector', label: 'Selector', width: 'w-48' },
890
+ { key: 'targetPorts', label: 'Target Ports', width: 'w-28' },
891
+ { key: 'extensionRef', label: 'Endpoint Picker', width: 'w-40', tooltip: 'EPP extension service' },
892
+ { key: 'age', label: 'Age', width: 'w-24' },
893
+ ],
894
+ inferenceobjectives: [
895
+ { key: 'name', label: 'Name' },
896
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
897
+ { key: 'status', label: 'Status', width: 'w-24' },
898
+ { key: 'poolRef', label: 'Pool', width: 'w-40', tooltip: 'Target InferencePool' },
899
+ { key: 'priority', label: 'Priority', width: 'w-24' },
900
+ { key: 'age', label: 'Age', width: 'w-24' },
901
+ ],
902
+ volcanojobs: [
903
+ { key: 'name', label: 'Name' },
904
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
905
+ { key: 'status', label: 'Status', width: 'w-28' },
906
+ { key: 'queue', label: 'Queue', width: 'w-32' },
907
+ { key: 'minAvailable', label: 'Min Available', width: 'w-28' },
908
+ { key: 'pods', label: 'Pods', width: 'w-44', tooltip: 'Running / succeeded / failed pod counts' },
909
+ { key: 'age', label: 'Age', width: 'w-24' },
910
+ ],
911
+ volcanoqueues: [
912
+ { key: 'name', label: 'Name' },
913
+ { key: 'status', label: 'Status', width: 'w-24' },
914
+ { key: 'weight', label: 'Weight', width: 'w-20' },
915
+ { key: 'capability', label: 'Capability', width: 'w-48' },
916
+ { key: 'allocated', label: 'Allocated', width: 'w-48' },
917
+ { key: 'podGroups', label: 'PodGroups', width: 'w-44', tooltip: 'PodGroup counts by state' },
918
+ { key: 'age', label: 'Age', width: 'w-24' },
919
+ ],
920
+ volcanopodgroups: [
921
+ { key: 'name', label: 'Name' },
922
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
923
+ { key: 'status', label: 'Status', width: 'w-32' },
924
+ { key: 'queue', label: 'Queue', width: 'w-32' },
925
+ { key: 'minMember', label: 'Min Member', width: 'w-28' },
926
+ { key: 'age', label: 'Age', width: 'w-24' },
927
+ ],
928
+ jobflows: [
929
+ { key: 'name', label: 'Name' },
930
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
931
+ { key: 'status', label: 'Status', width: 'w-28' },
932
+ { key: 'flows', label: 'Flows', width: 'w-20' },
933
+ { key: 'age', label: 'Age', width: 'w-24' },
934
+ ],
935
+ jobtemplates: [
936
+ { key: 'name', label: 'Name' },
937
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
938
+ { key: 'status', label: 'Status', width: 'w-24' },
939
+ { key: 'tasks', label: 'Tasks', width: 'w-20' },
940
+ { key: 'age', label: 'Age', width: 'w-24' },
941
+ ],
942
+ kaiqueues: [
943
+ { key: 'name', label: 'Name' },
944
+ { key: 'status', label: 'Status', width: 'w-24' },
945
+ { key: 'parentQueue', label: 'Parent', width: 'w-32' },
946
+ { key: 'priority', label: 'Priority', width: 'w-20' },
947
+ { key: 'quota', label: 'Quota', width: 'w-52', tooltip: 'GPU fractions, CPU millicpus, memory MB; -1 = unlimited' },
948
+ { key: 'allocated', label: 'Allocated', width: 'w-48' },
949
+ { key: 'age', label: 'Age', width: 'w-24' },
950
+ ],
951
+ kaipodgroups: [
952
+ { key: 'name', label: 'Name' },
953
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
954
+ { key: 'status', label: 'Status', width: 'w-32' },
955
+ { key: 'queue', label: 'Queue', width: 'w-32' },
956
+ { key: 'minMember', label: 'Min Member', width: 'w-28' },
957
+ { key: 'age', label: 'Age', width: 'w-24' },
958
+ ],
959
+ kaitoworkspaces: [
960
+ { key: 'name', label: 'Name' },
961
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
962
+ { key: 'status', label: 'Status', width: 'w-28' },
963
+ { key: 'instanceType', label: 'Instance Type', width: 'w-40', tooltip: 'GPU node SKU provisioned for the workspace' },
964
+ { key: 'preset', label: 'Model', width: 'w-44', tooltip: 'Preset model (inference or tuning)' },
965
+ { key: 'nodes', label: 'Nodes', width: 'w-20', tooltip: 'Worker nodes ready / target' },
966
+ { key: 'age', label: 'Age', width: 'w-24' },
967
+ ],
968
+ ragengines: [
969
+ { key: 'name', label: 'Name' },
970
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
971
+ { key: 'status', label: 'Status', width: 'w-28' },
972
+ { key: 'embedding', label: 'Embedding Model', width: 'w-56', tooltip: 'Local model ID/image or remote endpoint' },
973
+ { key: 'instanceType', label: 'Instance Type', width: 'w-40' },
974
+ { key: 'age', label: 'Age', width: 'w-24' },
975
+ ],
976
+ nimservices: [
977
+ { key: 'name', label: 'Name' },
978
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
979
+ { key: 'status', label: 'Status', width: 'w-24' },
980
+ { key: 'model', label: 'Model / Image', width: 'w-64', tooltip: 'Served model name (falls back to NIM image)' },
981
+ { key: 'replicas', label: 'Replicas', width: 'w-28', tooltip: 'Available / desired (HPA range when autoscaling)' },
982
+ { key: 'age', label: 'Age', width: 'w-24' },
983
+ ],
984
+ nimcaches: [
985
+ { key: 'name', label: 'Name' },
986
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
987
+ { key: 'status', label: 'Status', width: 'w-28' },
988
+ { key: 'source', label: 'Model Source', width: 'w-64', tooltip: 'NGC model puller image, or DataStore/HF model name' },
989
+ { key: 'storage', label: 'Storage', width: 'w-24', tooltip: 'Requested PVC size' },
990
+ { key: 'age', label: 'Age', width: 'w-24' },
991
+ ],
992
+ nimpipelines: [
993
+ { key: 'name', label: 'Name' },
994
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
995
+ { key: 'status', label: 'Status', width: 'w-24' },
996
+ { key: 'services', label: 'Services', width: 'w-28', tooltip: 'NIM services in pipeline' },
997
+ { key: 'age', label: 'Age', width: 'w-24' },
998
+ ],
999
+ deviceconfigs: [
1000
+ { key: 'name', label: 'Name' },
1001
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1002
+ { key: 'status', label: 'Status', width: 'w-24' },
1003
+ { key: 'driver', label: 'Driver', width: 'w-40', tooltip: 'Out-of-tree driver install enabled (version)' },
1004
+ { key: 'devicePluginImage', label: 'Device Plugin', width: 'w-64' },
1005
+ { key: 'nodes', label: 'Nodes', width: 'w-20', tooltip: 'Device plugin DaemonSet available / desired' },
1006
+ { key: 'age', label: 'Age', width: 'w-24' },
1007
+ ],
1008
+ pytorchjobs: [
1009
+ { key: 'name', label: 'Name' },
1010
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1011
+ { key: 'status', label: 'Status', width: 'w-28' },
1012
+ { key: 'replicas', label: 'Progress', width: 'w-48', tooltip: 'Active + succeeded / desired per replica type; failures shown separately' },
1013
+ { key: 'elapsed', label: 'Elapsed', width: 'w-24', tooltip: 'Start to completion (or now)' },
1014
+ { key: 'age', label: 'Age', width: 'w-24' },
1015
+ ],
1016
+ tfjobs: [
1017
+ { key: 'name', label: 'Name' },
1018
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1019
+ { key: 'status', label: 'Status', width: 'w-28' },
1020
+ { key: 'replicas', label: 'Progress', width: 'w-56', tooltip: 'Active + succeeded / desired per replica type; failures shown separately' },
1021
+ { key: 'elapsed', label: 'Elapsed', width: 'w-24', tooltip: 'Start to completion (or now)' },
1022
+ { key: 'age', label: 'Age', width: 'w-24' },
1023
+ ],
1024
+ mpijobs: [
1025
+ { key: 'name', label: 'Name' },
1026
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1027
+ { key: 'status', label: 'Status', width: 'w-28' },
1028
+ { key: 'replicas', label: 'Progress', width: 'w-48', tooltip: 'Active + succeeded / desired per replica type; failures shown separately' },
1029
+ { key: 'elapsed', label: 'Elapsed', width: 'w-24', tooltip: 'Start to completion (or now)' },
1030
+ { key: 'age', label: 'Age', width: 'w-24' },
1031
+ ],
1032
+ trainjobs: [
1033
+ { key: 'name', label: 'Name' },
1034
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1035
+ { key: 'status', label: 'Status', width: 'w-28' },
1036
+ { key: 'runtime', label: 'Runtime', width: 'w-44', tooltip: 'Training runtime reference' },
1037
+ { key: 'suspended', label: 'Suspended', width: 'w-24' },
1038
+ { key: 'age', label: 'Age', width: 'w-24' },
1039
+ ],
698
1040
  scaledjobs: [
699
1041
  { key: 'name', label: 'Name' },
700
1042
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
@@ -2098,6 +2440,21 @@ const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
2098
2440
  // completely different shape (cluster + method, no storage location/expiry).
2099
2441
  backups: { 'postgresql.cnpg.io': 'cnpgbackups' },
2100
2442
  clusterpolicies: { 'nvidia.com': 'nvidiaclusterpolicies' },
2443
+ // Kyverno's namespaced Policy. `policies` is a plural several projects use,
2444
+ // so it is only Kyverno's when the group says so; the curated set and its
2445
+ // cells already existed but nothing routed to them.
2446
+ policies: { 'kyverno.io': 'kyvernopolicies' },
2447
+ // Istio Gateway is not a Gateway API Gateway: it has servers and a workload
2448
+ // selector, and none of Class/Listeners/Routes/Addresses.
2449
+ gateways: { 'networking.istio.io': 'istiogateways' },
2450
+ job: { batch: 'jobs', 'batch.volcano.sh': 'volcanojobs' },
2451
+ jobs: { batch: 'jobs', 'batch.volcano.sh': 'volcanojobs' },
2452
+ queue: { 'scheduling.volcano.sh': 'volcanoqueues', 'scheduling.run.ai': 'kaiqueues' },
2453
+ queues: { 'scheduling.volcano.sh': 'volcanoqueues', 'scheduling.run.ai': 'kaiqueues' },
2454
+ podgroup: { 'scheduling.volcano.sh': 'volcanopodgroups', 'scheduling.run.ai': 'kaipodgroups' },
2455
+ podgroups: { 'scheduling.volcano.sh': 'volcanopodgroups', 'scheduling.run.ai': 'kaipodgroups' },
2456
+ workspace: { 'kaito.sh': 'kaitoworkspaces' },
2457
+ workspaces: { 'kaito.sh': 'kaitoworkspaces' },
2101
2458
  services: { 'serving.knative.dev': 'knativeservices' },
2102
2459
  configurations: { 'serving.knative.dev': 'knativeconfigurations' },
2103
2460
  revisions: { 'serving.knative.dev': 'knativerevisions' },
@@ -2166,6 +2523,7 @@ const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
2166
2523
 
2167
2524
  const GROUP_QUALIFIED_ONLY_COLUMN_KEYS = new Set([
2168
2525
  'networkpolicy', 'networkpolicies',
2526
+ 'job', 'jobs',
2169
2527
  // Other projects serve their own IPPool CRD, so only a Calico group earns the
2170
2528
  // Calico columns.
2171
2529
  'ippool', 'ippools',
@@ -2174,6 +2532,7 @@ const GROUP_QUALIFIED_ONLY_COLUMN_KEYS = new Set([
2174
2532
  'stagedglobalnetworkpolicy', 'stagedglobalnetworkpolicies',
2175
2533
  'stagedkubernetesnetworkpolicy', 'stagedkubernetesnetworkpolicies',
2176
2534
  ])
2535
+
2177
2536
  const CALICO_ONLY_COLUMN_KEYS = new Set([
2178
2537
  'globalnetworkpolicy', 'globalnetworkpolicies',
2179
2538
  'stagednetworkpolicy', 'stagednetworkpolicies',
@@ -2184,7 +2543,7 @@ const CALICO_ONLY_COLUMN_KEYS = new Set([
2184
2543
  // Normalize a kind name to its plural API form used in KNOWN_COLUMNS keys.
2185
2544
  // Handles CRD singular names from URLs: 'ScaledObject' → 'scaledobjects', 'NodePool' → 'nodepools'
2186
2545
  // When group is provided, resolves collisions (e.g., 'services' + 'serving.knative.dev' → 'knativeservices')
2187
- function normalizeKindToPlural(kind: string, group?: string): string {
2546
+ export function normalizeKindToPlural(kind: string, group?: string): string {
2188
2547
  const lower = kind.toLowerCase()
2189
2548
  // Check group-qualified mapping first for collision resolution
2190
2549
  if (group && GROUP_QUALIFIED_COLUMN_KEYS[lower]?.[group]) {
@@ -2229,20 +2588,396 @@ function isLikelyCrossplaneMRGroup(kind: string, group: string): boolean {
2229
2588
  return false
2230
2589
  }
2231
2590
 
2591
+ /**
2592
+ * API groups the Kubernetes API server reserves. A CRD cannot be created in
2593
+ * one, so a curated set keyed on a core kind can never be claimed by a foreign
2594
+ * resource and needs no ownership entry. Note the deliberate omissions:
2595
+ * gateway.networking.k8s.io, snapshot.storage.k8s.io and cluster.x-k8s.io end
2596
+ * in k8s.io but ARE CRD groups, so this is an explicit list, not a suffix test.
2597
+ */
2598
+ const CORE_API_GROUPS = new Set([
2599
+ '', 'apps', 'batch', 'autoscaling', 'policy', 'networking.k8s.io',
2600
+ 'rbac.authorization.k8s.io', 'storage.k8s.io', 'apiextensions.k8s.io',
2601
+ 'coordination.k8s.io', 'discovery.k8s.io', 'scheduling.k8s.io',
2602
+ 'admissionregistration.k8s.io', 'certificates.k8s.io', 'node.k8s.io',
2603
+ 'flowcontrol.apiserver.k8s.io', 'authentication.k8s.io', 'authorization.k8s.io',
2604
+ 'apiregistration.k8s.io', 'events.k8s.io', 'resource.k8s.io',
2605
+ 'internal.apiserver.k8s.io', 'storagemigration.k8s.io',
2606
+ ])
2607
+
2608
+ // ProviderConfig groups are unbounded — one per provider — so they are matched
2609
+ // by suffix. Managed resources take the isLikelyCrossplaneMRGroup branch above.
2610
+ function isCuratedUnboundedGroup(key: string, group: string): boolean {
2611
+ if (key !== 'providerconfigs') return false
2612
+ return group.endsWith('.crossplane.io') || group.endsWith('.upbound.io')
2613
+ }
2614
+
2615
+ /**
2616
+ * Which API group(s) each curated column set was written for.
2617
+ *
2618
+ * A CRD is not the kind Radar curated just because it reuses its plural:
2619
+ * chaos-mesh ships workflows.chaos-mesh.org, Istio ships gateways, and Flux
2620
+ * ships providers. Rendering Argo's or Gateway API's columns for those reads
2621
+ * every field off a schema the object does not have. Anything not claimed here
2622
+ * falls through to the generic set (and, for a CRD, to its own printer
2623
+ * columns), which is the honest answer for a kind Radar has never seen.
2624
+ *
2625
+ * Core kinds need no entry: the API server reserves their groups, so a CRD
2626
+ * cannot occupy one. See CORE_API_GROUPS.
2627
+ */
2628
+ const CURATED_COLUMN_GROUPS: Record<string, readonly string[]> = {
2629
+ challenges: ['acme.cert-manager.io'],
2630
+ orders: ['acme.cert-manager.io'],
2631
+ compositeresourcedefinitions: ['apiextensions.crossplane.io'],
2632
+ compositions: ['apiextensions.crossplane.io'],
2633
+ clustercompliancereports: ['aquasecurity.github.io'],
2634
+ clusterinfraassessmentreports: ['aquasecurity.github.io'],
2635
+ clusterrbacassessmentreports: ['aquasecurity.github.io'],
2636
+ clustersbomreports: ['aquasecurity.github.io'],
2637
+ configauditreports: ['aquasecurity.github.io'],
2638
+ exposedsecretreports: ['aquasecurity.github.io'],
2639
+ infraassessmentreports: ['aquasecurity.github.io'],
2640
+ rbacassessmentreports: ['aquasecurity.github.io'],
2641
+ sbomreports: ['aquasecurity.github.io'],
2642
+ vulnerabilityreports: ['aquasecurity.github.io'],
2643
+ analysisruns: ['argoproj.io'],
2644
+ applications: ['argoproj.io'],
2645
+ applicationsets: ['argoproj.io'],
2646
+ appprojects: ['argoproj.io'],
2647
+ clusterworkflowtemplates: ['argoproj.io'],
2648
+ cronworkflows: ['argoproj.io'],
2649
+ rollouts: ['argoproj.io'],
2650
+ workflows: ['argoproj.io'],
2651
+ workflowtemplates: ['argoproj.io'],
2652
+ barmanobjectstores: ['barmancloud.cnpg.io'],
2653
+ sealedsecrets: ['bitnami.com'],
2654
+ certificaterequests: ['cert-manager.io'],
2655
+ certificates: ['cert-manager.io'],
2656
+ clusterissuers: ['cert-manager.io'],
2657
+ issuers: ['cert-manager.io'],
2658
+ capiclusters: ['cluster.x-k8s.io'],
2659
+ clusterclasses: ['cluster.x-k8s.io'],
2660
+ machinedeployments: ['cluster.x-k8s.io'],
2661
+ machinehealthchecks: ['cluster.x-k8s.io'],
2662
+ machinepools: ['cluster.x-k8s.io'],
2663
+ machines: ['cluster.x-k8s.io'],
2664
+ machinesets: ['cluster.x-k8s.io'],
2665
+ kubeadmcontrolplanes: ['controlplane.cluster.x-k8s.io'],
2666
+ brokers: ['eventing.knative.dev'],
2667
+ eventtypes: ['eventing.knative.dev'],
2668
+ triggers: ['eventing.knative.dev'],
2669
+ clusterexternalsecrets: ['external-secrets.io'],
2670
+ clustersecretstores: ['external-secrets.io'],
2671
+ externalsecrets: ['external-secrets.io'],
2672
+ secretstores: ['external-secrets.io'],
2673
+ parallels: ['flows.knative.dev'],
2674
+ sequences: ['flows.knative.dev'],
2675
+ gatewayclasses: ['gateway.networking.k8s.io'],
2676
+ gateways: ['gateway.networking.k8s.io'],
2677
+ istiogateways: ['networking.istio.io'],
2678
+ grpcroutes: ['gateway.networking.k8s.io'],
2679
+ httproutes: ['gateway.networking.k8s.io'],
2680
+ tcproutes: ['gateway.networking.k8s.io'],
2681
+ tlsroutes: ['gateway.networking.k8s.io'],
2682
+ helmreleases: ['helm.toolkit.fluxcd.io'],
2683
+ awsmachines: ['infrastructure.cluster.x-k8s.io'],
2684
+ awsmachinetemplates: ['infrastructure.cluster.x-k8s.io'],
2685
+ awsmanagedclusters: ['infrastructure.cluster.x-k8s.io'],
2686
+ awsmanagedcontrolplanes: ['controlplane.cluster.x-k8s.io'],
2687
+ awsmanagedmachinepools: ['infrastructure.cluster.x-k8s.io'],
2688
+ azuremachines: ['infrastructure.cluster.x-k8s.io'],
2689
+ azuremachinetemplates: ['infrastructure.cluster.x-k8s.io'],
2690
+ azuremanagedclusters: ['infrastructure.cluster.x-k8s.io'],
2691
+ azuremanagedcontrolplanes: ['infrastructure.cluster.x-k8s.io'],
2692
+ azuremanagedmachinepools: ['infrastructure.cluster.x-k8s.io'],
2693
+ gcpmachines: ['infrastructure.cluster.x-k8s.io'],
2694
+ gcpmachinetemplates: ['infrastructure.cluster.x-k8s.io'],
2695
+ gcpmanagedclusters: ['infrastructure.cluster.x-k8s.io'],
2696
+ gcpmanagedcontrolplanes: ['infrastructure.cluster.x-k8s.io'],
2697
+ gcpmanagedmachinepools: ['infrastructure.cluster.x-k8s.io'],
2698
+ ec2nodeclasses: ['karpenter.k8s.aws'],
2699
+ nodeclaims: ['karpenter.sh'],
2700
+ nodepools: ['karpenter.sh'],
2701
+ clustertriggerauthentications: ['keda.sh'],
2702
+ scaledjobs: ['keda.sh'],
2703
+ scaledobjects: ['keda.sh'],
2704
+ triggerauthentications: ['keda.sh'],
2705
+ kustomizations: ['kustomize.toolkit.fluxcd.io'],
2706
+ cleanuppolicies: ['kyverno.io'],
2707
+ clustercleanuppolicies: ['kyverno.io'],
2708
+ clusterpolicies: ['kyverno.io'],
2709
+ kyvernopolicies: ['kyverno.io'],
2710
+ updaterequests: ['kyverno.io'],
2711
+ policyexceptions: ['kyverno.io', 'policies.kyverno.io'],
2712
+ channels: ['messaging.knative.dev'],
2713
+ inmemorychannels: ['messaging.knative.dev'],
2714
+ knativesubscriptions: ['messaging.knative.dev'],
2715
+ podmonitors: ['monitoring.coreos.com'],
2716
+ prometheusrules: ['monitoring.coreos.com'],
2717
+ servicemonitors: ['monitoring.coreos.com'],
2718
+ knativecertificates: ['networking.internal.knative.dev'],
2719
+ knativeingresses: ['networking.internal.knative.dev'],
2720
+ serverlessservices: ['networking.internal.knative.dev'],
2721
+ destinationrules: ['networking.istio.io'],
2722
+ serviceentries: ['networking.istio.io'],
2723
+ virtualservices: ['networking.istio.io'],
2724
+ alerts: ['notification.toolkit.fluxcd.io'],
2725
+ nvidiaclusterpolicies: ['nvidia.com'],
2726
+ nvidiadrivers: ['nvidia.com'],
2727
+ providers: ['pkg.crossplane.io'],
2728
+ deletingpolicies: ['policies.kyverno.io'],
2729
+ generatingpolicies: ['policies.kyverno.io'],
2730
+ imagevalidatingpolicies: ['policies.kyverno.io'],
2731
+ mutatingpolicies: ['policies.kyverno.io'],
2732
+ namespaceddeletingpolicies: ['policies.kyverno.io'],
2733
+ namespacedgeneratingpolicies: ['policies.kyverno.io'],
2734
+ namespacedimagevalidatingpolicies: ['policies.kyverno.io'],
2735
+ namespacedmutatingpolicies: ['policies.kyverno.io'],
2736
+ namespacedvalidatingpolicies: ['policies.kyverno.io'],
2737
+ validatingpolicies: ['policies.kyverno.io'],
2738
+ cnpgbackups: ['postgresql.cnpg.io'],
2739
+ cnpgclusterimagecatalogs: ['postgresql.cnpg.io'],
2740
+ cnpgclusters: ['postgresql.cnpg.io'],
2741
+ cnpgdatabases: ['postgresql.cnpg.io'],
2742
+ cnpgimagecatalogs: ['postgresql.cnpg.io'],
2743
+ cnpgpublications: ['postgresql.cnpg.io'],
2744
+ cnpgsubscriptions: ['postgresql.cnpg.io'],
2745
+ poolers: ['postgresql.cnpg.io'],
2746
+ scheduledbackups: ['postgresql.cnpg.io'],
2747
+ calicoglobalnetworkpolicies: ['projectcalico.org', 'crd.projectcalico.org'],
2748
+ calicohostendpoints: ['projectcalico.org', 'crd.projectcalico.org'],
2749
+ calicoippools: ['projectcalico.org', 'crd.projectcalico.org'],
2750
+ caliconetworkpolicies: ['projectcalico.org', 'crd.projectcalico.org'],
2751
+ calicostagedglobalnetworkpolicies: ['projectcalico.org', 'crd.projectcalico.org'],
2752
+ calicostagedkubernetesnetworkpolicies: ['projectcalico.org', 'crd.projectcalico.org'],
2753
+ calicostagednetworkpolicies: ['projectcalico.org', 'crd.projectcalico.org'],
2754
+ calicotiers: ['projectcalico.org', 'crd.projectcalico.org'],
2755
+ httpproxies: ['projectcontour.io'],
2756
+ clusterephemeralreports: ['reports.kyverno.io'],
2757
+ ephemeralreports: ['reports.kyverno.io'],
2758
+ authorizationpolicies: ['security.istio.io'],
2759
+ peerauthentications: ['security.istio.io'],
2760
+ domainmappings: ['serving.knative.dev'],
2761
+ knativeconfigurations: ['serving.knative.dev'],
2762
+ knativerevisions: ['serving.knative.dev'],
2763
+ knativeroutes: ['serving.knative.dev'],
2764
+ knativeservices: ['serving.knative.dev'],
2765
+ gitrepositories: ['source.toolkit.fluxcd.io'],
2766
+ helmrepositories: ['source.toolkit.fluxcd.io'],
2767
+ ocirepositories: ['source.toolkit.fluxcd.io'],
2768
+ apiserversources: ['sources.knative.dev'],
2769
+ containersources: ['sources.knative.dev'],
2770
+ pingsources: ['sources.knative.dev'],
2771
+ sinkbindings: ['sources.knative.dev'],
2772
+ ingressroutes: ['traefik.io', 'traefik.containo.us'],
2773
+ ingressroutetcps: ['traefik.io', 'traefik.containo.us'],
2774
+ ingressrouteudps: ['traefik.io', 'traefik.containo.us'],
2775
+ middlewares: ['traefik.io', 'traefik.containo.us'],
2776
+ middlewaretcps: ['traefik.io', 'traefik.containo.us'],
2777
+ serverstransports: ['traefik.io', 'traefik.containo.us'],
2778
+ serverstransporttcps: ['traefik.io', 'traefik.containo.us'],
2779
+ tlsoptions: ['traefik.io', 'traefik.containo.us'],
2780
+ tlsstores: ['traefik.io', 'traefik.containo.us'],
2781
+ traefikservices: ['traefik.io', 'traefik.containo.us'],
2782
+ backuprepositories: ['velero.io'],
2783
+ backups: ['velero.io'],
2784
+ backupstoragelocations: ['velero.io'],
2785
+ velerorestores: ['velero.io'],
2786
+ veleroschedules: ['velero.io'],
2787
+ volumesnapshotlocations: ['velero.io'],
2788
+ clusterpolicyreports: ['wgpolicyk8s.io'],
2789
+ policyreports: ['wgpolicyk8s.io'],
2790
+ deviceconfigs: ['amd.com'],
2791
+ nimcaches: ['apps.nvidia.com'],
2792
+ nimpipelines: ['apps.nvidia.com'],
2793
+ nimservices: ['apps.nvidia.com'],
2794
+ provisioningrequests: ['autoscaling.x-k8s.io'],
2795
+ volcanojobs: ['batch.volcano.sh'],
2796
+ jobflows: ['flow.volcano.sh'],
2797
+ jobtemplates: ['flow.volcano.sh'],
2798
+ inferencepools: ['inference.networking.k8s.io', 'inference.networking.x-k8s.io'],
2799
+ inferenceobjectives: ['inference.networking.x-k8s.io', 'llm-d.ai'],
2800
+ jobsets: ['jobset.x-k8s.io'],
2801
+ kaitoworkspaces: ['kaito.sh'],
2802
+ ragengines: ['kaito.sh'],
2803
+ admissionchecks: ['kueue.x-k8s.io'],
2804
+ clusterqueues: ['kueue.x-k8s.io'],
2805
+ localqueues: ['kueue.x-k8s.io'],
2806
+ resourceflavors: ['kueue.x-k8s.io'],
2807
+ workloads: ['kueue.x-k8s.io'],
2808
+ mpijobs: ['kubeflow.org'],
2809
+ pytorchjobs: ['kubeflow.org'],
2810
+ tfjobs: ['kubeflow.org'],
2811
+ leaderworkersets: ['leaderworkerset.x-k8s.io'],
2812
+ rayclusters: ['ray.io'],
2813
+ raycronjobs: ['ray.io'],
2814
+ rayjobs: ['ray.io'],
2815
+ rayservices: ['ray.io'],
2816
+ kaipodgroups: ['scheduling.run.ai'],
2817
+ kaiqueues: ['scheduling.run.ai'],
2818
+ volcanopodgroups: ['scheduling.volcano.sh'],
2819
+ volcanoqueues: ['scheduling.volcano.sh'],
2820
+ clusterservingruntimes: ['serving.kserve.io'],
2821
+ inferencegraphs: ['serving.kserve.io'],
2822
+ inferenceservices: ['serving.kserve.io'],
2823
+ llminferenceservices: ['serving.kserve.io'],
2824
+ servingruntimes: ['serving.kserve.io'],
2825
+ trainedmodels: ['serving.kserve.io'],
2826
+ trainjobs: ['trainer.kubeflow.org'],
2827
+ }
2828
+
2232
2829
  function getColumnsForKind(kind: string, group?: string): Column[] {
2233
- const key = normalizeKindToPlural(kind, group)
2234
- if (KNOWN_COLUMNS[key]) return KNOWN_COLUMNS[key]
2830
+ // Ahead of the curated lookup: a provider ships one CRD per service, so a
2831
+ // managed resource routinely collides with a curated plural (acm's
2832
+ // Certificate, s3's Service). Resolving the collision the other way would
2833
+ // return DEFAULT_COLUMNS and never reach this branch at all.
2235
2834
  if (group && isLikelyCrossplaneMRGroup(kind, group)) {
2236
2835
  return KNOWN_COLUMNS.crossplanemanagedresources
2237
2836
  }
2837
+ const key = normalizeKindToPlural(kind, group)
2838
+ const curated = KNOWN_COLUMNS[key]
2839
+ if (curated) {
2840
+ const owners = CURATED_COLUMN_GROUPS[key]
2841
+ if (group && owners) return owners.includes(group) ? curated : DEFAULT_COLUMNS
2842
+ // A core kind cannot be shadowed by a CRD, so it keeps its columns without
2843
+ // an ownership entry. Anything else has to be claimed for this exact group.
2844
+ if (!group || CORE_API_GROUPS.has(group)) return curated
2845
+ if (isCuratedUnboundedGroup(key, group)) return curated
2846
+ return DEFAULT_COLUMNS
2847
+ }
2238
2848
  return DEFAULT_COLUMNS
2239
2849
  }
2240
2850
 
2851
+ /**
2852
+ * Whether Radar hand-curates this kind's columns. The single definition of
2853
+ * "curated": the printer-column XOR, the storage-key qualifier, and the host's
2854
+ * decision whether to request table mode at all must all agree, or the server
2855
+ * does work the client throws away — or worse, they disagree about which set a
2856
+ * kind is on.
2857
+ */
2858
+ export function hasCuratedColumns(kind: string, group?: string): boolean {
2859
+ return getColumnsForKind(kind, group) !== DEFAULT_COLUMNS
2860
+ }
2861
+
2862
+ export function getCellFilterKind(kind: string, group?: string): string {
2863
+ const normalized = normalizeKindToPlural(kind, group)
2864
+ if (!group || hasCuratedColumns(kind, group) || normalized.startsWith('__generic_')) return normalized
2865
+ return `__generic_${normalized}`
2866
+ }
2867
+
2241
2868
  // Get the default visible columns for a kind
2242
2869
  function getDefaultVisibleColumns(columns: Column[]): Set<string> {
2243
2870
  return new Set(columns.filter(c => c.defaultVisible !== false).map(c => c.key))
2244
2871
  }
2245
2872
 
2873
+ // Materializes one vendor-declared printer column into a self-contained
2874
+ // ExtraColumn, so it rides the same render/sort/filter rails as a user's custom
2875
+ // column. Values were evaluated server-side and arrive keyed by uid.
2876
+ function buildPrinterColumn(def: PrinterColumnDef, index: number, table: PrinterTable): ExtraColumn {
2877
+ const read = (resource: any) => readPrinterCell(table, resource?.metadata?.uid, index)
2878
+ return {
2879
+ key: printerColumnKey(def),
2880
+ label: def.name,
2881
+ width: printerColumnWidth(def),
2882
+ // Columns in kubectl's `-o wide` tier are declared but not shown by
2883
+ // default; the column picker still offers them.
2884
+ defaultVisible: (def.priority ?? 0) === 0,
2885
+ tooltip: def.description || undefined,
2886
+ render: (resource: any) => {
2887
+ const value = read(resource)
2888
+ // `date` cells arrive already humanized ("5m") — the API server's table
2889
+ // converter does that, so re-formatting here would parse "5m" as a date.
2890
+ const text = formatPrinterCell(value)
2891
+ if (!text) return <span className="text-sm text-theme-text-tertiary">-</span>
2892
+ // A column the vendor named Ready/Healthy/Established carries the only
2893
+ // scannable signal these kinds have — they have no curated Status column.
2894
+ // Rendering it as grey text would leave a screen of uniform "True".
2895
+ const tone = printerCellTone(def, value)
2896
+ if (tone) {
2897
+ return (
2898
+ // The value, not the column's description — the header tooltip
2899
+ // carries that. A cell tooltip exists to reveal a truncated value.
2900
+ <Tooltip content={text}>
2901
+ <span className={clsx('badge', healthColors[tone])}>{text}</span>
2902
+ </Tooltip>
2903
+ )
2904
+ }
2905
+ return (
2906
+ <Tooltip content={text}>
2907
+ <span className="text-sm text-theme-text-secondary truncate block">{text}</span>
2908
+ </Tooltip>
2909
+ )
2910
+ },
2911
+ getSortValue: (resource: any) => printerCellSortValue(read(resource), def),
2912
+ getFilterValue: (resource: any) => formatPrinterCell(read(resource)),
2913
+ }
2914
+ }
2915
+
2916
+ // Printer columns fill in only where Radar has no curated set for the kind —
2917
+ // the two are exclusive, never merged. A curated set is hand-tuned and often
2918
+ // encodes why a vendor-obvious field is the wrong one to show, so it wins.
2919
+ function buildPrinterColumns(
2920
+ kind: string,
2921
+ group: string | undefined,
2922
+ table: PrinterTable | null | undefined,
2923
+ ): ExtraColumn[] {
2924
+ if (!table?.columns?.length) return []
2925
+ if (!printerTableMatchesKind(table, kind, group)) return []
2926
+ if (hasCuratedColumns(kind, group)) return []
2927
+ return table.columns.map((def, i) => buildPrinterColumn(def, i, table))
2928
+ }
2929
+
2930
+ // The kind's effective column set. Printer columns replace the generic
2931
+ // Name/Namespace/Status/Age set rather than joining it: the vendor's columns
2932
+ // are the answer to the same question the generic Status column was guessing
2933
+ // at, and showing both would ask the reader which one to believe. Name,
2934
+ // Namespace and Age stay because Radar renders those itself (the server drops
2935
+ // the CRD's own duplicates of them before sending).
2936
+ function columnsForKindWithPrinter(
2937
+ kind: string,
2938
+ group: string | undefined,
2939
+ printerColumns: ExtraColumn[],
2940
+ ): Column[] {
2941
+ const curated = getColumnsForKind(kind, group)
2942
+ if (!printerColumns.length || hasCuratedColumns(kind, group)) return curated
2943
+ return [
2944
+ { key: 'name', label: 'Name' },
2945
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
2946
+ ...printerColumns,
2947
+ { key: 'age', label: 'Age', width: 'w-24' },
2948
+ ]
2949
+ }
2950
+
2951
+ /**
2952
+ * The visible-column set for a kind the user has saved settings for.
2953
+ *
2954
+ * Saved choices win, with two seeded exceptions for columns the blob predates —
2955
+ * a user cannot have decided to hide something they were never shown:
2956
+ * - host extras, which are injected by the embedding app;
2957
+ * - printer columns, but only when the blob names none at all. Once it names
2958
+ * one, the user has seen the set and an absent key is a deliberate hide.
2959
+ */
2960
+ export function mergeSavedVisibleColumns(
2961
+ savedVisible: string[],
2962
+ extraKeys: string[],
2963
+ printerColumns: ExtraColumn[],
2964
+ ): Set<string> {
2965
+ const merged = new Set(savedVisible)
2966
+ for (const k of extraKeys) merged.add(k)
2967
+ if (!savedVisible.some(k => k.startsWith(PRINTER_COLUMN_PREFIX))) {
2968
+ for (const c of printerColumns) {
2969
+ if (c.defaultVisible !== false) merged.add(c.key)
2970
+ }
2971
+ }
2972
+ return merged
2973
+ }
2974
+
2975
+ /** Storage/reset identity for a selection. Two CRDs can share a plural across
2976
+ * API groups, and their columns are unrelated. */
2977
+ export function selectedKindIdentityOf(kind: { name: string; group?: string }): string {
2978
+ return `${kind.name} ${kind.group ?? ''}`
2979
+ }
2980
+
2246
2981
  // Host-injected leading columns minus any whose key collides with a built-in.
2247
2982
  // Rendering two columns under one key duplicates React keys and corrupts the
2248
2983
  // visibleColumns / columnWidths maps, so a colliding extra is dropped (dev-warn).
@@ -2269,6 +3004,22 @@ function filterHostExtras(
2269
3004
  // localStorage helpers for column settings
2270
3005
  const COLUMN_SETTINGS_PREFIX = 'radar-columns-'
2271
3006
 
3007
+ // Storage identity for a kind's column settings. normalizeKindToPlural falls
3008
+ // through to the bare plural for anything outside the curated collision
3009
+ // catalog, so two uncurated CRDs sharing a plural in different API groups —
3010
+ // Crossplane ships two `Usage` kinds — would share one blob and leak column
3011
+ // visibility, widths and custom columns between them. Harmless while both got
3012
+ // identical generic columns; not harmless once each has its own printer
3013
+ // columns. Only the fallthrough kinds are qualified, so curated and core kinds
3014
+ // keep the key their saved preferences are already under.
3015
+ export function columnSettingsKey(kind: string, group?: string): string {
3016
+ const normalized = normalizeKindToPlural(kind, group)
3017
+ if (group && !hasCuratedColumns(kind, group)) {
3018
+ return `${normalized}.${group}`
3019
+ }
3020
+ return normalized
3021
+ }
3022
+
2272
3023
  interface ColumnSettings {
2273
3024
  visible: string[]
2274
3025
  widths: Record<string, number>
@@ -2277,7 +3028,7 @@ interface ColumnSettings {
2277
3028
 
2278
3029
  function loadColumnSettings(kind: string, group?: string): ColumnSettings | null {
2279
3030
  try {
2280
- const key = COLUMN_SETTINGS_PREFIX + normalizeKindToPlural(kind, group)
3031
+ const key = COLUMN_SETTINGS_PREFIX + columnSettingsKey(kind, group)
2281
3032
  const raw = localStorage.getItem(key)
2282
3033
  if (raw) return JSON.parse(raw)
2283
3034
  } catch { /* ignore */ }
@@ -2286,14 +3037,14 @@ function loadColumnSettings(kind: string, group?: string): ColumnSettings | null
2286
3037
 
2287
3038
  function saveColumnSettings(kind: string, group: string | undefined, settings: ColumnSettings) {
2288
3039
  try {
2289
- const key = COLUMN_SETTINGS_PREFIX + normalizeKindToPlural(kind, group)
3040
+ const key = COLUMN_SETTINGS_PREFIX + columnSettingsKey(kind, group)
2290
3041
  localStorage.setItem(key, JSON.stringify(settings))
2291
3042
  } catch { /* ignore */ }
2292
3043
  }
2293
3044
 
2294
3045
  function clearColumnSettings(kind: string, group?: string) {
2295
3046
  try {
2296
- const key = COLUMN_SETTINGS_PREFIX + normalizeKindToPlural(kind, group)
3047
+ const key = COLUMN_SETTINGS_PREFIX + columnSettingsKey(kind, group)
2297
3048
  localStorage.removeItem(key)
2298
3049
  } catch { /* ignore */ }
2299
3050
  }
@@ -2401,6 +3152,11 @@ interface ResourcesViewProps {
2401
3152
  * doesn't need to extend KNOWN_COLUMNS or per-kind cell renderers.
2402
3153
  * When undefined, behavior is byte-identical to single-cluster mode. */
2403
3154
  extraLeadingColumns?: ExtraColumn[]
3155
+ /** Vendor-declared columns from the kind's CRD (additionalPrinterColumns),
3156
+ * evaluated server-side. Used ONLY for kinds Radar has no curated column
3157
+ * set for — the two are exclusive. Hosts that don't fetch them get the
3158
+ * generic columns and nothing changes. */
3159
+ printerTable?: PrinterTable | null
2404
3160
  /** Escape hatch for full-page-nav row selection: receive the FULL
2405
3161
  * resource object on row click / Enter / `d` / search-Enter, instead
2406
3162
  * of the stripped {kind, namespace, name, group} shape onResourceClick
@@ -2630,6 +3386,7 @@ export function ResourcesView({
2630
3386
  onCreateResource,
2631
3387
  defaultKind = DEFAULT_KIND_INFO,
2632
3388
  extraLeadingColumns,
3389
+ printerTable,
2633
3390
  onRowSelect,
2634
3391
  rowHrefFor,
2635
3392
  onCompareSubmit,
@@ -2827,22 +3584,32 @@ export function ResourcesView({
2827
3584
  // collision: a colliding extra is filtered out (with a dev-mode warn)
2828
3585
  // so we don't render two columns sharing one key — that would yield
2829
3586
  // duplicate React keys and corrupt visibleColumns / columnWidths state.
3587
+ const builtPrinterColumns = useMemo(
3588
+ () => buildPrinterColumns(selectedKind.name, selectedKind.group, printerTable),
3589
+ [selectedKind.name, selectedKind.group, printerTable],
3590
+ )
3591
+ const printerColumnsKey = useMemo(
3592
+ () => printerTableKey(builtPrinterColumns.length ? printerTable : null),
3593
+ [printerTable, builtPrinterColumns],
3594
+ )
3595
+
2830
3596
  const allColumns = useMemo(() => {
2831
- const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
3597
+ const kindColumns = columnsForKindWithPrinter(selectedKind.name, selectedKind.group, builtPrinterColumns)
2832
3598
  if (!extraLeadingColumns?.length && !builtCustomColumns.length) return kindColumns
2833
3599
  const builtinKeys = new Set(kindColumns.map(c => c.key))
2834
3600
  const filteredExtras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
2835
3601
  return [...filteredExtras, ...kindColumns, ...builtCustomColumns]
2836
- }, [selectedKind.name, selectedKind.group, extraLeadingColumns, builtCustomColumns])
3602
+ }, [selectedKind.name, selectedKind.group, extraLeadingColumns, builtCustomColumns, builtPrinterColumns])
2837
3603
 
2838
3604
  // Map of extra column keys for fast O(1) lookup on each render path
2839
3605
  // (cell render, sort, column-filter unique-values).
2840
3606
  const extraColumnsByKey = useMemo(() => {
2841
3607
  const m = new Map<string, ExtraColumn>()
2842
3608
  extraLeadingColumns?.forEach(c => m.set(c.key, c))
3609
+ builtPrinterColumns.forEach(c => m.set(c.key, c))
2843
3610
  builtCustomColumns.forEach(c => m.set(c.key, c))
2844
3611
  return m
2845
- }, [extraLeadingColumns, builtCustomColumns])
3612
+ }, [extraLeadingColumns, builtCustomColumns, builtPrinterColumns])
2846
3613
 
2847
3614
  // Guards the save effect from persisting on the initial load of each kind
2848
3615
  // (set false by the load effect, flipped true on its first skipped save).
@@ -2872,7 +3639,7 @@ export function ResourcesView({
2872
3639
  // (non-array, blank path, bad source) must not crash the later .map or add dead columns.
2873
3640
  const savedCustom = sanitizeCustomColumnDefs(saved?.custom)
2874
3641
  setCustomColumns(savedCustom)
2875
- const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
3642
+ const kindColumns = columnsForKindWithPrinter(selectedKind.name, selectedKind.group, builtPrinterColumns)
2876
3643
  const builtinKeys = new Set(kindColumns.map(c => c.key))
2877
3644
  const extras = filterHostExtras(extraLeadingColumns, builtinKeys, selectedKind.name)
2878
3645
  const effective = [...extras, ...kindColumns, ...savedCustom.map(buildCustomColumn)]
@@ -2899,16 +3666,18 @@ export function ResourcesView({
2899
3666
  setVisibleColumns(getDefaultVisibleColumns(effective))
2900
3667
  setColumnWidths({})
2901
3668
  } else {
2902
- const merged = new Set(saved.visible)
2903
- for (const k of extraKeys) merged.add(k)
2904
- setVisibleColumns(merged)
3669
+ setVisibleColumns(mergeSavedVisibleColumns(saved.visible, extraKeys, builtPrinterColumns))
2905
3670
  setColumnWidths(saved.widths || {})
2906
3671
  }
2907
3672
  } else {
2908
3673
  setVisibleColumns(getDefaultVisibleColumns(effective))
2909
3674
  setColumnWidths({})
2910
3675
  }
2911
- }, [selectedKind.name, selectedKind.group, extraLeadingColumns])
3676
+ // printerColumnsKey, not builtPrinterColumns: the built array is a new
3677
+ // identity on every refetch, and re-running this effect would reset the
3678
+ // user's in-session column choices each time the list polls. The key only
3679
+ // changes when the kind changes or an operator upgrade changes the CRD.
3680
+ }, [selectedKind.name, selectedKind.group, extraLeadingColumns, printerColumnsKey])
2912
3681
 
2913
3682
  // Save column settings when they change (skip the initial load of each kind)
2914
3683
  useEffect(() => {
@@ -3303,11 +4072,14 @@ export function ResourcesView({
3303
4072
  if (containers.length > 0) {
3304
4073
  openLogs?.({ namespace: ns, podName: name, containers })
3305
4074
  }
3306
- } else if (['deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kindLower)) {
4075
+ } else if (['deployments', 'statefulsets', 'daemonsets', 'replicasets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(kindLower) || isCoreBatchJob(kindLower, selectedKind.group)) {
3307
4076
  openWorkloadLogs?.({ namespace: ns, workloadKind: selectedKind.kind, workloadName: name })
3308
4077
  }
3309
4078
  },
3310
- enabled: highlightedIndex >= 0 && !compareMode && ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'jobs', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(selectedKind.name.toLowerCase()),
4079
+ enabled: highlightedIndex >= 0 && !compareMode && (
4080
+ ['pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets', 'workflows', 'cronjobs', 'cronworkflows', 'workflowtemplates', 'clusterworkflowtemplates', 'scaledjobs'].includes(selectedKind.name.toLowerCase()) ||
4081
+ isCoreBatchJob(selectedKind.name, selectedKind.group)
4082
+ ),
3311
4083
  },
3312
4084
  {
3313
4085
  id: 'resources-sort-name',
@@ -3962,12 +4734,17 @@ export function ResourcesView({
3962
4734
 
3963
4735
  // Reset sort and filters when kind changes (but not when syncing from URL navigation)
3964
4736
  // Track previous kind to skip on mount (where the effect fires but kind hasn't actually changed)
3965
- const prevKindRef = useRef(selectedKind.name)
4737
+ // Keyed on group as well as plural: two CRDs can share a plural across API
4738
+ // groups, and their printer columns are unrelated. Resetting on the plural
4739
+ // alone would carry a `printer:*` sort or filter onto a table with no such
4740
+ // column, where every row fails the filter and the list looks empty.
4741
+ const selectedKindIdentity = selectedKindIdentityOf(selectedKind)
4742
+ const prevKindRef = useRef(selectedKindIdentity)
3966
4743
  useEffect(() => {
3967
- if (prevKindRef.current === selectedKind.name) {
4744
+ if (prevKindRef.current === selectedKindIdentity) {
3968
4745
  return
3969
4746
  }
3970
- prevKindRef.current = selectedKind.name
4747
+ prevKindRef.current = selectedKindIdentity
3971
4748
  setSortColumn(null)
3972
4749
  setSortDirection(null)
3973
4750
  setOpenColumnFilter(null)
@@ -3976,7 +4753,7 @@ export function ResourcesView({
3976
4753
  setColumnFilterExcludes({})
3977
4754
  }
3978
4755
  setProblemFilters([])
3979
- }, [selectedKind.name])
4756
+ }, [selectedKindIdentity])
3980
4757
 
3981
4758
  // Toggle sort for a column
3982
4759
  const handleSort = useCallback((column: string) => {
@@ -4010,7 +4787,28 @@ export function ResourcesView({
4010
4787
  case 'age':
4011
4788
  return meta.creationTimestamp ? new Date(meta.creationTimestamp).getTime() : 0
4012
4789
  case 'status':
4013
- return status.phase || ''
4790
+ // Phase first for a curated kind: it keeps the most-used lists — Pods,
4791
+ // Deployments, Nodes — ordering as they always have.
4792
+ //
4793
+ // Everything else routes through getCellFilterValue, the same reader
4794
+ // the cell and the filter dropdown use, so all three agree on one
4795
+ // string. Deriving it here instead ordered rows on a status the row
4796
+ // never displayed: a Gateway shows `Programmed` while the generic
4797
+ // ladder, which knows neither Programmed nor Accepted, called it
4798
+ // `Accepted` — so a healthy Gateway and a pending one sorted equal.
4799
+ // getCellFilterValue ends in that same generic derivation, so an
4800
+ // uncurated kind still sorts on the text its badge shows.
4801
+ if (hasCuratedColumns(selectedKind.name, selectedKind.group) && status.phase) {
4802
+ return status.phase
4803
+ }
4804
+ return getCellFilterValue(resource, 'status', kindLower)
4805
+ case 'servers':
4806
+ // Numeric so 10 does not sort before 9.
4807
+ if (kindLower === 'istiogateways') return getIstioGatewayServerCount(resource)
4808
+ return ''
4809
+ case 'selector':
4810
+ if (kindLower === 'istiogateways') return getIstioGatewaySelectorString(resource)
4811
+ return ''
4014
4812
  case 'containers':
4015
4813
  // Pod containers column — sort by readiness ratio
4016
4814
  if (status.containerStatuses) {
@@ -4020,6 +4818,7 @@ export function ResourcesView({
4020
4818
  }
4021
4819
  return 0
4022
4820
  case 'ready':
4821
+ if (kindLower === 'jobsets') return getJobSetReadyJobs(resource)
4023
4822
  // For DaemonSets, use numberReady/desiredNumberScheduled
4024
4823
  if (kindLower === 'daemonsets') {
4025
4824
  const desired = status.desiredNumberScheduled ?? 0
@@ -4040,6 +4839,7 @@ export function ResourcesView({
4040
4839
  // DaemonSet: updatedNumberScheduled, others: updatedReplicas
4041
4840
  return status.updatedNumberScheduled ?? status.updatedReplicas ?? 0
4042
4841
  case 'restarts':
4842
+ if (kindLower === 'jobsets') return getJobSetRestarts(resource)
4043
4843
  return getPodRestarts(resource)
4044
4844
  case 'lastSeen': {
4045
4845
  const lastTs = resource.lastTimestamp || meta.creationTimestamp
@@ -4089,7 +4889,7 @@ export function ResourcesView({
4089
4889
  default:
4090
4890
  return ''
4091
4891
  }
4092
- }, [metricsLookup])
4892
+ }, [metricsLookup, selectedKind.name, selectedKind.group])
4093
4893
 
4094
4894
  // Helper to check if a pod matches problem filters
4095
4895
  const podMatchesProblemFilter = useCallback((pod: any, filters: string[]): boolean => {
@@ -4142,11 +4942,11 @@ export function ResourcesView({
4142
4942
  // when the parent supplied a custom getFilterValue.
4143
4943
  const activeColFilters = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
4144
4944
  if (activeColFilters.length > 0) {
4145
- const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
4945
+ const cellFilterKind = getCellFilterKind(selectedKind.name, selectedKind.group)
4146
4946
  result = result.filter((r: any) =>
4147
4947
  activeColFilters.every(([col, vals]) => {
4148
4948
  const extra = extraColumnsByKey.get(col)
4149
- const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col, kindLower)
4949
+ const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col, cellFilterKind)
4150
4950
  const match = vals.includes(cellVal)
4151
4951
  return columnFilterExcludes[col] ? !match : match
4152
4952
  })
@@ -4208,17 +5008,26 @@ export function ResourcesView({
4208
5008
  // the built-in getSortValue for their key.
4209
5009
  if (sortColumn && sortDirection) {
4210
5010
  const extra = extraColumnsByKey.get(sortColumn)
4211
- result = [...result].sort((a: any, b: any) => {
4212
- const aVal = extra?.getSortValue ? extra.getSortValue(a) : getSortValue(a, sortColumn, selectedKind.name)
4213
- const bVal = extra?.getSortValue ? extra.getSortValue(b) : getSortValue(b, sortColumn, selectedKind.name)
5011
+ // Normalized, matching the filter call sites: a group-qualified kind
5012
+ // (istiogateways, cnpgclusters) otherwise arrives here as its bare plural
5013
+ // and misses its own sort readers. Hoisted out of the comparator.
5014
+ // The ownership-aware wrapper also keeps a foreign CRD that reuses a
5015
+ // curated plural on the generic reader used by its cells and filters.
5016
+ const sortKind = getCellFilterKind(selectedKind.name, selectedKind.group)
5017
+ const keyOf = extra?.getSortValue ?? ((r: any) => getSortValue(r, sortColumn, sortKind))
5018
+ // Derived once per row, not once per comparison: a sort visits O(n log n)
5019
+ // pairs, and the status key now runs a per-kind status derivation.
5020
+ const decorated = result.map((r: any) => ({ r, k: keyOf(r) }))
5021
+ decorated.sort((a, b) => {
4214
5022
  let comparison = 0
4215
- if (typeof aVal === 'number' && typeof bVal === 'number') {
4216
- comparison = aVal - bVal
5023
+ if (typeof a.k === 'number' && typeof b.k === 'number') {
5024
+ comparison = a.k - b.k
4217
5025
  } else {
4218
- comparison = String(aVal).localeCompare(String(bVal))
5026
+ comparison = String(a.k).localeCompare(String(b.k))
4219
5027
  }
4220
5028
  return sortDirection === 'desc' ? -comparison : comparison
4221
5029
  })
5030
+ result = decorated.map(d => d.r)
4222
5031
  } else {
4223
5032
  // Default sort by kind
4224
5033
  const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
@@ -4490,6 +5299,7 @@ export function ResourcesView({
4490
5299
  if (!resources || resources.length === 0) return null
4491
5300
 
4492
5301
  const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
5302
+ const cellFilterKind = getCellFilterKind(selectedKind.name, selectedKind.group)
4493
5303
  // Iterate over allColumns (built-ins + injected extras) so an
4494
5304
  // ExtraColumn with getFilterValue gets a column-filter dropdown like
4495
5305
  // any built-in does. The previous formulation iterated KNOWN_COLUMNS
@@ -4516,7 +5326,7 @@ export function ResourcesView({
4516
5326
  // Count distinct values for this column
4517
5327
  const valueCounts: Record<string, number> = {}
4518
5328
  for (const r of resources) {
4519
- const val = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col.key, kindLower)
5329
+ const val = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col.key, cellFilterKind)
4520
5330
  if (val) {
4521
5331
  valueCounts[val] = (valueCounts[val] || 0) + 1
4522
5332
  }
@@ -5838,10 +6648,11 @@ function CopyNameButton({ name }: { name: string }) {
5838
6648
  <button
5839
6649
  onClick={(e) => {
5840
6650
  e.stopPropagation()
5841
- navigator.clipboard.writeText(name).then(() => {
6651
+ void copyText(name).then((didCopy) => {
6652
+ if (!didCopy) return
5842
6653
  setCopied(true)
5843
6654
  setTimeout(() => setCopied(false), 1500)
5844
- }).catch(() => {})
6655
+ })
5845
6656
  }}
5846
6657
  className="shrink-0 p-0.5 text-theme-text-tertiary hover:text-theme-text-primary opacity-0 group-hover/row:opacity-100 transition-opacity"
5847
6658
  title="Copy name"
@@ -5940,6 +6751,9 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5940
6751
 
5941
6752
  // Kind-specific columns (normalize CRD singular names like 'ScaledObject' → 'scaledobjects')
5942
6753
  const kindLower = normalizeKindToPlural(kind, group)
6754
+ if (group && !hasCuratedColumns(kind, group)) {
6755
+ return <GenericCell resource={resource} column={column} />
6756
+ }
5943
6757
 
5944
6758
  // Kyverno cells are group-gated ahead of the switch so a non-matching CR
5945
6759
  // falls through to the switch's default (GenericCell) instead of being
@@ -5995,6 +6809,9 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5995
6809
  case 'persistentvolumeclaims':
5996
6810
  return <PVCCell resource={resource} column={column} />
5997
6811
  case 'rollouts':
6812
+ if (!resource.apiVersion?.startsWith('argoproj.io/')) {
6813
+ return <GenericCell resource={resource} column={column} />
6814
+ }
5998
6815
  return <RolloutCell resource={resource} column={column} />
5999
6816
  case 'analysisruns':
6000
6817
  return <AnalysisRunCell resource={resource} column={column} />
@@ -6018,8 +6835,12 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
6018
6835
  return <OrderCell resource={resource} column={column} />
6019
6836
  case 'challenges':
6020
6837
  return <ChallengeCell resource={resource} column={column} />
6838
+ case 'istiogateways':
6839
+ return <IstioGatewayCell resource={resource} column={column} />
6021
6840
  case 'gateways':
6022
- // Disambiguate Gateway API vs Istio Gateway by apiVersion
6841
+ // Disambiguate Gateway API vs Istio Gateway by apiVersion. Retained
6842
+ // alongside the case above because not every call site normalizes the
6843
+ // kind through GROUP_QUALIFIED_COLUMN_KEYS before dispatching.
6023
6844
  if (resource.apiVersion?.includes('networking.istio.io')) {
6024
6845
  return <IstioGatewayCell resource={resource} column={column} />
6025
6846
  }
@@ -6125,6 +6946,90 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
6125
6946
  return <NvidiaClusterPolicyCell resource={resource} column={column} />
6126
6947
  case 'nvidiadrivers':
6127
6948
  return <NvidiaDriverCell resource={resource} column={column} />
6949
+ // Kueue + Cluster Autoscaler
6950
+ case 'clusterqueues':
6951
+ return <ClusterQueueCell resource={resource} column={column} />
6952
+ case 'localqueues':
6953
+ return <LocalQueueCell resource={resource} column={column} />
6954
+ case 'workloads':
6955
+ return <KueueWorkloadCell resource={resource} column={column} />
6956
+ case 'resourceflavors':
6957
+ return <ResourceFlavorCell resource={resource} column={column} />
6958
+ case 'admissionchecks':
6959
+ return <AdmissionCheckCell resource={resource} column={column} />
6960
+ case 'provisioningrequests':
6961
+ return <ProvisioningRequestCell resource={resource} column={column} />
6962
+ // KubeRay
6963
+ case 'rayclusters':
6964
+ return <RayClusterCell resource={resource} column={column} />
6965
+ case 'rayjobs':
6966
+ return <RayJobCell resource={resource} column={column} />
6967
+ case 'rayservices':
6968
+ return <RayServiceCell resource={resource} column={column} />
6969
+ case 'raycronjobs':
6970
+ return <RayCronJobCell resource={resource} column={column} />
6971
+ // LeaderWorkerSet + JobSet
6972
+ case 'leaderworkersets':
6973
+ return <LeaderWorkerSetCell resource={resource} column={column} />
6974
+ case 'jobsets':
6975
+ return <JobSetCell resource={resource} column={column} />
6976
+ // KServe
6977
+ case 'inferenceservices':
6978
+ return <InferenceServiceCell resource={resource} column={column} />
6979
+ case 'servingruntimes':
6980
+ case 'clusterservingruntimes':
6981
+ return <ServingRuntimeCell resource={resource} column={column} />
6982
+ case 'inferencegraphs':
6983
+ return <InferenceGraphCell resource={resource} column={column} />
6984
+ case 'trainedmodels':
6985
+ return <TrainedModelCell resource={resource} column={column} />
6986
+ case 'llminferenceservices':
6987
+ return <LLMInferenceServiceCell resource={resource} column={column} />
6988
+ // Gateway API Inference Extension
6989
+ case 'inferencepools':
6990
+ return <InferencePoolCell resource={resource} column={column} />
6991
+ case 'inferenceobjectives':
6992
+ return <InferenceObjectiveCell resource={resource} column={column} />
6993
+ // Volcano (group-qualified keys disambiguate from batch Jobs and KAI)
6994
+ case 'volcanojobs':
6995
+ return <VolcanoJobCell resource={resource} column={column} />
6996
+ case 'volcanoqueues':
6997
+ return <VolcanoQueueCell resource={resource} column={column} />
6998
+ case 'volcanopodgroups':
6999
+ return <VolcanoPodGroupCell resource={resource} column={column} />
7000
+ case 'jobflows':
7001
+ return <JobFlowCell resource={resource} column={column} />
7002
+ case 'jobtemplates':
7003
+ return <JobTemplateCell resource={resource} column={column} />
7004
+ // KAI Scheduler
7005
+ case 'kaiqueues':
7006
+ return <KaiQueueCell resource={resource} column={column} />
7007
+ case 'kaipodgroups':
7008
+ return <KaiPodGroupCell resource={resource} column={column} />
7009
+ // KAITO
7010
+ case 'kaitoworkspaces':
7011
+ return <KaitoWorkspaceCell resource={resource} column={column} />
7012
+ case 'ragengines':
7013
+ return <RAGEngineCell resource={resource} column={column} />
7014
+ // NVIDIA NIM Operator
7015
+ case 'nimservices':
7016
+ return <NIMServiceCell resource={resource} column={column} />
7017
+ case 'nimcaches':
7018
+ return <NIMCacheCell resource={resource} column={column} />
7019
+ case 'nimpipelines':
7020
+ return <NIMPipelineCell resource={resource} column={column} />
7021
+ // AMD GPU Operator
7022
+ case 'deviceconfigs':
7023
+ return <AMDDeviceConfigCell resource={resource} column={column} />
7024
+ // Kubeflow training
7025
+ case 'pytorchjobs':
7026
+ return <PyTorchJobCell resource={resource} column={column} />
7027
+ case 'tfjobs':
7028
+ return <TFJobCell resource={resource} column={column} />
7029
+ case 'mpijobs':
7030
+ return <MPIJobCell resource={resource} column={column} />
7031
+ case 'trainjobs':
7032
+ return <TrainJobCell resource={resource} column={column} />
6128
7033
  // ArgoCD GitOps resources
6129
7034
  case 'applications':
6130
7035
  return <ArgoApplicationCell resource={resource} column={column} />
@@ -6425,53 +7330,13 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
6425
7330
  function GenericCell({ resource, column }: { resource: any; column: string }) {
6426
7331
  switch (column) {
6427
7332
  case 'status': {
6428
- // Try to extract status from common patterns
6429
- const status = resource.status
6430
- if (!status) return <span className="text-sm text-theme-text-tertiary">-</span>
6431
-
6432
- // Check for phase (common in many CRDs)
6433
- if (status.phase) {
6434
- const phase = status.phase as string
6435
- const isHealthy = ['Running', 'Active', 'Succeeded', 'Ready', 'Healthy', 'Available'].includes(phase)
6436
- const isWarning = ['Pending', 'Progressing', 'Unknown'].includes(phase)
6437
- return (
6438
- <span className={clsx(
6439
- 'badge',
6440
- isHealthy ? 'status-healthy' :
6441
- isWarning ? 'status-degraded' :
6442
- 'status-unhealthy'
6443
- )}>
6444
- {phase}
6445
- </span>
6446
- )
6447
- }
6448
-
6449
- // Check for conditions (common pattern)
6450
- if (status.conditions && Array.isArray(status.conditions)) {
6451
- const readyCondition = status.conditions.find((c: any) => c.type === 'Ready' || c.type === 'Available')
6452
- if (readyCondition) {
6453
- const isReady = readyCondition.status === 'True'
6454
- return (
6455
- <span className={clsx(
6456
- 'badge',
6457
- isReady ? 'status-healthy' : 'status-degraded'
6458
- )}>
6459
- {isReady ? 'Ready' : 'Not Ready'}
6460
- </span>
6461
- )
6462
- }
6463
- }
6464
-
6465
- // Check for state field
6466
- if (status.state) {
6467
- return (
6468
- <span className="text-sm text-theme-text-secondary truncate">
6469
- {String(status.state)}
6470
- </span>
6471
- )
6472
- }
6473
-
6474
- return <span className="text-sm text-theme-text-tertiary">-</span>
7333
+ const derived = getGenericResourceStatus(resource)
7334
+ if (!derived) return <span className="text-sm text-theme-text-tertiary">-</span>
7335
+ return (
7336
+ <Tooltip content={derived.reason ?? derived.text}>
7337
+ <span className={clsx('badge', healthColors[derived.tone])}>{derived.text}</span>
7338
+ </Tooltip>
7339
+ )
6475
7340
  }
6476
7341
  default:
6477
7342
  return <span className="text-sm text-theme-text-tertiary">-</span>
@@ -6760,15 +7625,25 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
6760
7625
  }
6761
7626
  }
6762
7627
 
7628
+ function WorkloadStatusCell({ resource, kind }: { resource: any; kind: string }) {
7629
+ const { activity, status } = getWorkloadDisplayStatus(resource, kind)
7630
+ const rolloutVisible = isRolloutActivityVisible(activity)
7631
+ const label = rolloutVisible ? status.text : workloadStatusLabel(status)
7632
+ const detail = rolloutVisible && activity.detail ? `${activity.detail} · ${status.text}` : status.text
7633
+ return <Tooltip content={detail}><span className={clsx('badge', status.color)}>{label}</span></Tooltip>
7634
+ }
7635
+
6763
7636
  function WorkloadCell({ resource, column, kind }: { resource: any; kind: string; column: string }) {
6764
7637
  const status = resource.status || {}
6765
7638
  const spec = resource.spec || {}
6766
7639
 
6767
7640
  switch (column) {
7641
+ case 'status':
7642
+ return <WorkloadStatusCell resource={resource} kind={kind} />
6768
7643
  case 'ready': {
6769
- const desired = spec.replicas ?? 0
7644
+ const desired = spec.replicas ?? 1
6770
7645
  const ready = status.readyReplicas || 0
6771
- const allReady = ready === desired && desired > 0
7646
+ const allReady = ready >= desired && desired > 0
6772
7647
  const problems = getWorkloadProblems(resource, kind)
6773
7648
  return (
6774
7649
  <div className="flex items-center gap-1.5">
@@ -6776,7 +7651,7 @@ function WorkloadCell({ resource, column, kind }: { resource: any; kind: string;
6776
7651
  'text-sm font-medium',
6777
7652
  desired === 0 ? 'text-theme-text-secondary' : allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
6778
7653
  )}>
6779
- {ready}/{desired}
7654
+ {Math.min(ready, desired)}/{desired}
6780
7655
  </span>
6781
7656
  {problems.length > 0 && (
6782
7657
  <Tooltip content={
@@ -6839,12 +7714,14 @@ function DaemonSetCell({ resource, column }: { resource: any; column: string })
6839
7714
  const status = resource.status || {}
6840
7715
 
6841
7716
  switch (column) {
7717
+ case 'status':
7718
+ return <WorkloadStatusCell resource={resource} kind="daemonsets" />
6842
7719
  case 'desired':
6843
7720
  return <span className="text-sm text-theme-text-secondary">{status.desiredNumberScheduled || 0}</span>
6844
7721
  case 'ready': {
6845
7722
  const desired = status.desiredNumberScheduled || 0
6846
7723
  const ready = status.numberReady || 0
6847
- const allReady = ready === desired && desired > 0
7724
+ const allReady = ready >= desired && desired > 0
6848
7725
  const problems = getWorkloadProblems(resource, 'daemonsets')
6849
7726
  return (
6850
7727
  <div className="flex items-center gap-1.5">
@@ -6852,7 +7729,7 @@ function DaemonSetCell({ resource, column }: { resource: any; column: string })
6852
7729
  'text-sm font-medium',
6853
7730
  allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
6854
7731
  )}>
6855
- {ready}
7732
+ {Math.min(ready, desired)}
6856
7733
  </span>
6857
7734
  {problems.length > 0 && (
6858
7735
  <Tooltip content={
@@ -6900,7 +7777,7 @@ function ReplicaSetCell({ resource, column }: { resource: any; column: string })
6900
7777
 
6901
7778
  switch (column) {
6902
7779
  case 'ready': {
6903
- const desired = spec.replicas ?? 0
7780
+ const desired = spec.replicas ?? 1
6904
7781
  const ready = status.readyReplicas || 0
6905
7782
  const allReady = ready === desired && desired > 0
6906
7783
  return (
@@ -7518,8 +8395,10 @@ function NodeCell({ resource, column, majorityNodeMinorVersion }: { resource: an
7518
8395
  const allocatable = resource.status?.allocatable?.pods
7519
8396
  const podCount = m?.podCount ?? 0
7520
8397
  if (!allocatable) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
7521
- const max = parseInt(allocatable, 10)
7522
- if (isNaN(max) || max <= 0) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
8398
+ // Allocatable pods is a Quantity, so a 1000-pod node reports "1k" —
8399
+ // a raw-integer read would see 1 and peg the bar at 21600%.
8400
+ const max = parseQuantityToNumber(allocatable)
8401
+ if (!Number.isFinite(max) || max <= 0) return <span className="text-sm text-theme-text-tertiary font-mono">{podCount || '-'}</span>
7523
8402
  const pct = (podCount / max) * 100
7524
8403
  return <ResourceBar used={String(podCount)} total={String(max)} percent={pct} colorScheme="count" />
7525
8404
  }
@@ -7566,14 +8445,8 @@ function PVCCell({ resource, column }: { resource: any; column: string }) {
7566
8445
 
7567
8446
  function RolloutCell({ resource, column }: { resource: any; column: string }) {
7568
8447
  switch (column) {
7569
- case 'status': {
7570
- const status = getRolloutStatus(resource)
7571
- return (
7572
- <span className={clsx('badge', status.color)}>
7573
- {status.text}
7574
- </span>
7575
- )
7576
- }
8448
+ case 'status':
8449
+ return <WorkloadStatusCell resource={resource} kind="rollouts" />
7577
8450
  case 'ready': {
7578
8451
  const ready = getRolloutReady(resource)
7579
8452
  const parts = ready.split('/')