@skyhook-io/k8s-ui 1.18.2 → 1.20.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 (152) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/charts/AreaChart.test.tsx +39 -1
  4. package/src/components/charts/AreaChart.tsx +32 -13
  5. package/src/components/charts/axis.test.ts +17 -1
  6. package/src/components/charts/axis.ts +13 -0
  7. package/src/components/charts/format.test.ts +159 -2
  8. package/src/components/charts/format.ts +118 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  11. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  12. package/src/components/compare/ResourceCompareView.tsx +35 -12
  13. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  14. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  15. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  17. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  18. package/src/components/gitops/RollbackDialog.tsx +25 -6
  19. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  21. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  22. package/src/components/gitops/applicationset-row.test.ts +12 -0
  23. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  24. package/src/components/gitops/detail-helpers.test.ts +8 -2
  25. package/src/components/gitops/detail-helpers.ts +9 -11
  26. package/src/components/gitops/index.ts +1 -1
  27. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  28. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  29. package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
  30. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  31. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  32. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  33. package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
  34. package/src/components/gitops/insights/insights-helpers.ts +17 -3
  35. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  36. package/src/components/issues/IssueGuidance.tsx +48 -0
  37. package/src/components/issues/IssuesView.tsx +15 -3
  38. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  39. package/src/components/issues/diagnostic.ts +14 -1
  40. package/src/components/issues/index.ts +1 -0
  41. package/src/components/issues/severity.ts +15 -0
  42. package/src/components/issues/types.ts +10 -0
  43. package/src/components/kueue/AdmissionPath.tsx +223 -0
  44. package/src/components/kueue/QueueEvidence.tsx +148 -0
  45. package/src/components/kueue/constants.ts +1 -0
  46. package/src/components/kueue/eligibility.ts +43 -0
  47. package/src/components/kueue/flavors-tried.ts +79 -0
  48. package/src/components/kueue/helpers.test.ts +105 -0
  49. package/src/components/kueue/policy.ts +100 -0
  50. package/src/components/kueue/queue-detail.test.tsx +317 -0
  51. package/src/components/kueue/queue-status.ts +42 -0
  52. package/src/components/kueue/related-read.ts +48 -0
  53. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  54. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  55. package/src/components/logs/StructuredLogLine.tsx +8 -3
  56. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  57. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  58. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  59. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  60. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  61. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  62. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  63. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  64. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  65. package/src/components/resources/ResourcesView.tsx +50 -3
  66. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  67. package/src/components/resources/generic-status.ts +7 -0
  68. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  69. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  71. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  72. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  73. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  74. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  75. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  76. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  77. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  78. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  79. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  80. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  81. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  82. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  83. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  84. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  85. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  86. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  87. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  88. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  89. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  90. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  91. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  92. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  93. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  94. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  95. package/src/components/resources/renderers/index.ts +6 -0
  96. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  97. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  98. package/src/components/resources/resource-utils-argo.ts +84 -5
  99. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  100. package/src/components/resources/resource-utils-crd.ts +26 -0
  101. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  102. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  103. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  104. package/src/components/resources/resource-utils-kueue.ts +137 -4
  105. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  106. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  107. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  108. package/src/components/resources/resource-utils-ray.ts +38 -0
  109. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  110. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  111. package/src/components/resources/resource-utils.ts +45 -1
  112. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  113. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  114. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  115. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  116. package/src/components/topology/K8sResourceNode.tsx +18 -1
  117. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  118. package/src/components/topology/TopologyGraph.tsx +96 -94
  119. package/src/components/topology/layout.ts +3 -1
  120. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  121. package/src/components/ui/Badge.tsx +1 -0
  122. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  123. package/src/components/ui/drawer-components.tsx +1 -1
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/workload/PodList.tsx +35 -9
  126. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  127. package/src/components/workload/WorkloadView.tsx +61 -8
  128. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  129. package/src/types/core.ts +41 -0
  130. package/src/types/gitops-insights.ts +19 -2
  131. package/src/types/gitops-tree.ts +2 -2
  132. package/src/types/kueue-queues.ts +84 -0
  133. package/src/types/read-coverage.ts +2 -1
  134. package/src/types/scheduling.ts +72 -0
  135. package/src/utils/animation.test.ts +38 -0
  136. package/src/utils/animation.ts +34 -0
  137. package/src/utils/api-resources-crd.test.ts +11 -0
  138. package/src/utils/api-resources.ts +5 -1
  139. package/src/utils/applications.test.ts +15 -1
  140. package/src/utils/applications.ts +26 -1
  141. package/src/utils/crd-schema.test.ts +107 -0
  142. package/src/utils/crd-schema.ts +201 -0
  143. package/src/utils/format.test.ts +37 -1
  144. package/src/utils/format.ts +28 -80
  145. package/src/utils/gitops-owner.test.ts +28 -2
  146. package/src/utils/gitops-owner.ts +19 -0
  147. package/src/utils/kueue-queue-workloads.ts +19 -0
  148. package/src/utils/resource-icons.ts +2 -0
  149. package/src/utils/topology-neighborhood.test.ts +90 -1
  150. package/src/utils/topology-neighborhood.ts +55 -3
  151. package/src/utils/workload-history-notes.test.ts +76 -0
  152. package/src/utils/workload-history-notes.ts +93 -0
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { kueueAdmissionStateHint, kueueAdmissionStateLabel, kueueAdmissionStatePresentation } from './resource-utils-kueue'
5
+
6
+ const vector = JSON.parse(readFileSync(fileURLToPath(new URL('../../../../../pkg/schedulinginsight/testdata/kueue_admission_codes.json', import.meta.url)), 'utf8'))
7
+
8
+ describe('Kueue admission-state presentation', () => {
9
+ it('has a label for every code the server can send', () => {
10
+ for (const code of vector.codes as string[]) expect(kueueAdmissionStateLabel(code), code).not.toBe(code)
11
+ })
12
+
13
+ it('has a tone for every attention class and stays neutral for stale or unknown classes', () => {
14
+ for (const attention of vector.attention as string[]) expect(kueueAdmissionStatePresentation({ state: 'insufficient_quota', attention }).severity).toBeTruthy()
15
+ expect(kueueAdmissionStatePresentation({ state: 'local_queue_missing', attention: 'action_needed' }).severity).toBe('alert')
16
+ expect(kueueAdmissionStatePresentation({ state: 'local_queue_missing', attention: 'action_needed', stale: true }).severity).toBe('neutral')
17
+ expect(kueueAdmissionStatePresentation({ state: 'a_future_code', attention: 'a_future_class' })).toEqual({ label: 'a_future_code', severity: 'neutral' })
18
+ })
19
+
20
+ it('bounds flavor claims to the attempted flavors and keeps required resource requests intact', () => {
21
+ expect(kueueAdmissionStateLabel('no_matching_flavor')).toContain('flavors tried')
22
+ const hint = kueueAdmissionStateHint({ state: 'resource_unavailable', attention: 'action_needed' })
23
+ expect(hint).toContain('If the request is required, use a queue that offers it')
24
+ expect(hint).toContain('Remove it only if unintended')
25
+ })
26
+ })
@@ -0,0 +1,15 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { isKueueWorkloadEligibleForPendingPosition } from './resource-utils-kueue'
5
+
6
+ const vectors = JSON.parse(readFileSync(fileURLToPath(new URL('../../../../../pkg/schedulinginsight/testdata/kueue_awaiting_admission.json', import.meta.url)), 'utf8'))
7
+
8
+ describe('isKueueWorkloadEligibleForPendingPosition', () => {
9
+ it('has cases', () => expect(vectors.cases.length).toBeGreaterThan(0))
10
+ for (const testCase of vectors.cases) {
11
+ it(`matches Go: ${testCase.name}`, () => {
12
+ expect(isKueueWorkloadEligibleForPendingPosition(testCase.workload)).toBe(testCase.awaiting)
13
+ })
14
+ }
15
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { BadgeSeverity } from '../ui/Badge'
4
4
  import type { StatusBadge } from './resource-utils'
5
+ import type { KueueAdmissionState } from '../../types/scheduling'
5
6
  import { healthColors } from './resource-utils'
6
7
 
7
8
  const failedFinishedReasons = new Set(['Failed', 'FailedToStart', 'OutOfSync', 'OwnerNotFound'])
@@ -41,12 +42,34 @@ export function getKueueQueueCondition(resource: any): any {
41
42
  return findCondition(resource, 'Active')
42
43
  }
43
44
 
44
- function queueConditionStatus(resource: any): StatusBadge {
45
+ /**
46
+ * The queue reports an intentional stop and nothing else. Kueue names only the
47
+ * first of a ClusterQueue's inactive causes as the reason, and Stopped always
48
+ * comes first, so a stopped ClusterQueue that is also missing a flavor or a
49
+ * check still says Stopped; its message lists every cause. A LocalQueue's
50
+ * Stopped has the one cause.
51
+ */
52
+ export function isKueueStopOnly(condition: any, clusterQueue: boolean): boolean {
53
+ if (condition?.status !== 'False' || condition.reason !== 'Stopped') return false
54
+ // Only Kueue's multi-cause wording ("is stopped, <more>") counts as more; unfamiliar wording keeps the stop.
55
+ return !clusterQueue || !/^Can't admit new workloads: is stopped, /.test(condition.message ?? '')
56
+ }
57
+
58
+ /**
59
+ * The ClusterQueue's message is Kueue's single-cause stop wording. Stricter
60
+ * than isKueueStopOnly: lending a stop to another object's tone needs the
61
+ * positive form, not just the absence of more causes.
62
+ */
63
+ export function isKueueClusterQueueStopOnlyMessage(condition: any): boolean {
64
+ return isKueueStopOnly(condition, true) && /^Can't admit new workloads: is stopped\.?$/.test(condition.message ?? '')
65
+ }
66
+
67
+ function queueConditionStatus(resource: any, clusterQueue: boolean): StatusBadge {
45
68
  const active = getKueueQueueCondition(resource)
46
69
  if (isKueueConditionStale(active, resource?.metadata?.generation)) {
47
70
  return { text: `${active.status === 'True' ? 'Active' : active.reason || 'Unknown'} (stale)`, color: healthColors.unknown, level: 'unknown' }
48
71
  }
49
- if (active?.status === 'False' && active.reason === 'Stopped') {
72
+ if (isKueueStopOnly(active, clusterQueue)) {
50
73
  return { text: 'Stopped', color: healthColors.neutral, level: 'neutral' }
51
74
  }
52
75
  return activeConditionStatus(resource)
@@ -124,7 +147,7 @@ function formatWorkloadCount(value: any): string {
124
147
  // ============================================================================
125
148
 
126
149
  export function getClusterQueueStatus(resource: any): StatusBadge {
127
- return queueConditionStatus(resource)
150
+ return queueConditionStatus(resource, true)
128
151
  }
129
152
 
130
153
  export function getClusterQueueCohort(resource: any): string {
@@ -136,6 +159,11 @@ export function getClusterQueuePendingWorkloads(resource: any): string {
136
159
  return formatWorkloadCount(resource?.status?.pendingWorkloads)
137
160
  }
138
161
 
162
+ /** Reserving includes admitted Workloads; the two counts are not additive. */
163
+ export function getKueueQueueReservingWorkloads(resource: any): string {
164
+ return formatWorkloadCount(resource?.status?.reservingWorkloads)
165
+ }
166
+
139
167
  export function getClusterQueueAdmittedWorkloads(resource: any): string {
140
168
  return formatWorkloadCount(resource?.status?.admittedWorkloads)
141
169
  }
@@ -155,7 +183,7 @@ export function getClusterQueueFlavors(resource: any): string {
155
183
  // ============================================================================
156
184
 
157
185
  export function getLocalQueueStatus(resource: any): StatusBadge {
158
- return queueConditionStatus(resource)
186
+ return queueConditionStatus(resource, false)
159
187
  }
160
188
 
161
189
  export function getLocalQueueClusterQueue(resource: any): string {
@@ -174,6 +202,10 @@ export function getLocalQueueAdmittedWorkloads(resource: any): string {
174
202
  // KUEUE WORKLOAD UTILITIES
175
203
  // ============================================================================
176
204
 
205
+ // The Workload's most notable native condition, for its status badge. This is
206
+ // not the admission-state classification, which only the server decides
207
+ // (kueue.admission); the two can differ, e.g. an Evicted Workload now blocked
208
+ // by a missing LocalQueue.
177
209
  export function getKueueWorkloadStatusCondition(resource: any): any {
178
210
  for (const type of ['Finished', 'Evicted', 'Preempted', 'Admitted', 'QuotaReserved']) {
179
211
  const condition = findCondition(resource, type)
@@ -200,6 +232,93 @@ export function getKueueWorkloadStatus(resource: any): StatusBadge {
200
232
  }
201
233
  }
202
234
 
235
+ export interface KueueAdmissionStatePresentation {
236
+ label: string
237
+ severity: BadgeSeverity
238
+ }
239
+
240
+ // Presentation only: the server decides every code and its attention class
241
+ // (pkg/schedulinginsight). A code this UI has no label for renders as sent;
242
+ // an attention class it does not know renders in a neutral tone. Stale
243
+ // evidence is always neutral.
244
+ const kueueAdmissionLabels: Record<string, string> = {
245
+ finished: 'Finished',
246
+ admitted: 'Admitted',
247
+ quota_reserved: 'Quota reserved',
248
+ deactivated: 'Deactivated',
249
+ deactivated_by_kueue: 'Deactivated by Kueue',
250
+ deactivation_pending: 'Deactivating',
251
+ on_hold: 'On hold',
252
+ no_decision: 'Awaiting evaluation',
253
+ insufficient_quota: 'Waiting for quota',
254
+ exceeds_quota: 'Too large for the flavors tried',
255
+ no_matching_flavor: 'No fit among the flavors tried',
256
+ resource_unavailable: 'Resource not in the queue',
257
+ topology_placement_failed: 'Topology placement failed',
258
+ waiting_for_preemption: 'Waiting for preempted workloads',
259
+ preemption_gated: 'Preemption gated',
260
+ admission_gated: 'Admission gated',
261
+ namespace_not_eligible: 'Namespace not eligible',
262
+ local_queue_missing: 'LocalQueue missing',
263
+ local_queue_inactive: 'LocalQueue inactive',
264
+ cluster_queue_missing: 'ClusterQueue missing',
265
+ cluster_queue_inactive: 'ClusterQueue inactive',
266
+ admission_check_rejected: 'Admission check rejected',
267
+ admission_checks_pending: 'Waiting for admission checks',
268
+ topology_assignment_pending: 'Waiting for topology assignment',
269
+ waiting_for_pods_ready: 'Waiting for admitted workloads to start',
270
+ requeue_backoff: 'Waiting to requeue',
271
+ dra_resources_unresolved: 'DRA resources unresolved',
272
+ invalid_workload: 'Invalid workload',
273
+ unrecognized: 'Unclassified reason',
274
+ preempted: 'Preempted',
275
+ pods_ready_timeout: 'Pods not ready in time',
276
+ evicted: 'Evicted',
277
+ }
278
+
279
+ const kueueAttentionSeverity: Record<string, BadgeSeverity> = {
280
+ none: 'neutral',
281
+ waiting: 'neutral',
282
+ paused: 'neutral',
283
+ action_needed: 'alert',
284
+ unknown: 'info',
285
+ }
286
+
287
+ // What the reported state usually asks of someone, worded as a suggestion:
288
+ // the classification reads only the Workload, never its queue.
289
+ const kueueAdmissionHints: Record<string, string> = {
290
+ exceeds_quota: "In its last attempt, Kueue found the requests too large for every flavor it tried. If the queue has no larger flavor, reduce them or ask an admin to raise its quota.",
291
+ no_matching_flavor: "In its last attempt, no flavor Kueue tried fits the Workload's node selectors, tolerations or topology, or the flavor an earlier slice was admitted to.",
292
+ resource_unavailable: 'The ClusterQueue has no quota for a resource the job requests. If the request is required, use a queue that offers it or ask an admin to add quota. Remove it only if unintended.',
293
+ namespace_not_eligible: "The ClusterQueue does not accept this namespace. Use another LocalQueue, or ask an admin.",
294
+ local_queue_missing: "Check the job's queue name, or ask an admin to create the LocalQueue.",
295
+ cluster_queue_missing: "The LocalQueue points at a ClusterQueue that doesn't exist or is being deleted. An admin needs to restore it or repoint the LocalQueue.",
296
+ local_queue_inactive: 'The LocalQueue may be stopped on purpose or misconfigured. Its status says which.',
297
+ cluster_queue_inactive: 'The ClusterQueue may be stopped on purpose or misconfigured. Its status says which.',
298
+ admission_check_rejected: 'An admission check rejected the Workload. Kueue usually deactivates it next.',
299
+ deactivation_pending: 'Kueue is deactivating the Workload. It will not be admitted until reactivated.',
300
+ deactivated_by_kueue: 'Kueue deactivated this Workload. Fix the cause it reports, then set it active again to retry.',
301
+ invalid_workload: "Kueue rejects the job's resource requests. Fix them and resubmit.",
302
+ }
303
+
304
+ export function kueueAdmissionStateHint(admission: Pick<KueueAdmissionState, 'state' | 'attention'>): string | undefined {
305
+ if (admission.state === 'deactivated' && admission.attention === 'unknown') {
306
+ return "The Workload doesn't say whether Kueue or a person deactivated it. The earlier eviction it still reports may or may not be why."
307
+ }
308
+ return kueueAdmissionHints[admission.state]
309
+ }
310
+
311
+ export function kueueAdmissionStateLabel(code: string): string {
312
+ return kueueAdmissionLabels[code] ?? code
313
+ }
314
+
315
+ export function kueueAdmissionStatePresentation(admission: Pick<KueueAdmissionState, 'state' | 'attention' | 'stale'>): KueueAdmissionStatePresentation {
316
+ return {
317
+ label: kueueAdmissionStateLabel(admission.state),
318
+ severity: admission.stale ? 'neutral' : kueueAttentionSeverity[admission.attention] ?? 'neutral',
319
+ }
320
+ }
321
+
203
322
  export function getKueueWorkloadQueueName(resource: any): string {
204
323
  return resource?.spec?.queueName || '-'
205
324
  }
@@ -208,6 +327,20 @@ export function getKueueWorkloadAdmittedBy(resource: any): string {
208
327
  return resource?.status?.admission?.clusterQueue || '-'
209
328
  }
210
329
 
330
+ /**
331
+ * Whether this Workload snapshot permits a pending-position lookup.
332
+ * True does not establish that Kueue has observed or queued it. Mirrors Go's
333
+ * schedulinginsight.KueueWorkloadEligibleForPendingPosition; both read
334
+ * pkg/schedulinginsight/testdata/kueue_awaiting_admission.json.
335
+ */
336
+ export function isKueueWorkloadEligibleForPendingPosition(resource: any): boolean {
337
+ if (resource?.spec?.active === false) return false
338
+ const conditions: any[] = Array.isArray(resource?.status?.conditions) ? resource.status.conditions : []
339
+ const byType = new Map<string, any>()
340
+ for (const condition of conditions) if (condition?.type) byType.set(condition.type, condition)
341
+ return !['Finished', 'Admitted', 'QuotaReserved'].some(type => byType.get(type)?.status === 'True')
342
+ }
343
+
211
344
  export function getKueueWorkloadPriority(resource: any): string {
212
345
  const priority = resource?.spec?.priority
213
346
  if (typeof priority === 'number') return String(priority)
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { readFileSync } from 'node:fs'
3
- import { renderToString } from 'react-dom/server'
3
+ import { renderToStaticMarkup, renderToString } from 'react-dom/server'
4
4
  import { getCollectorPipelines, getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from './resource-utils-opentelemetry'
5
5
  import { getGenericResourceStatus } from './generic-status'
6
6
  import { getCellFilterValue } from './resource-utils'
@@ -72,3 +72,11 @@ describe('OpenTelemetry drawer identity and meaning', () => {
72
72
  expect(render('other.example/v1', 'Instrumentation', { custom: 'generic-visible' })).toContain('generic-visible')
73
73
  })
74
74
  })
75
+
76
+ it('bounds recorded upgrade language rows and labels', () => {
77
+ const blocked = Object.fromEntries(Array.from({length:30},(_,i)=>[`language-${String(i).padStart(2,'0')}${'x'.repeat(200)}`, 'recorded reason']))
78
+ const html=renderToStaticMarkup(<ResourceRendererDispatch resource={{kind:'instrumentations',namespace:'test',name:'test'}} data={{apiVersion:'opentelemetry.io/v1alpha1',kind:'Instrumentation',metadata:{name:'test',namespace:'test'},status:{upgradeBlockedVersions:blocked}}} onCopy={()=>{}} copied={null} showCommonSections={false} />)
79
+ expect(html).toContain('20 more recorded languages in YAML')
80
+ expect(html).not.toContain('language-10')
81
+ expect(html).not.toContain('x'.repeat(161))
82
+ })
@@ -20,7 +20,7 @@ export function getOpenTelemetryCollectorStatus(data: any): StatusBadge {
20
20
  export function getOpenTelemetryInstrumentationStatus(data: any): StatusBadge {
21
21
  const blocked = Object.keys(data.status?.upgradeBlockedVersions ?? {}).length > 0
22
22
  const level = 'neutral'
23
- return { text: blocked ? 'Upgrade blocked' : 'Configured', level, color: healthColors[level] }
23
+ return { text: blocked ? 'Upgrade block recorded' : 'Configured', level, color: healthColors[level] }
24
24
  }
25
25
 
26
26
  export interface CollectorPipeline {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { getRayCronJobTimeZone, getRayCronJobStatus, getRayServiceStatus } from './resource-utils-ray'
2
+ import { getRayCronJobTimeZone, getRayCronJobStatus, getRayServiceStatus, logsContainer, podLogsUnavailableReason } from './resource-utils-ray'
3
3
  import { getCellFilterValue } from './resource-utils'
4
4
 
5
5
  describe('getRayCronJobTimeZone', () => {
@@ -91,3 +91,44 @@ describe('RayCluster readiness', () => {
91
91
  expect(getRayClusterStatus(root({ conditions: [{ type: 'RayClusterProvisioned', status: 'True' }] })).level).toBe('neutral')
92
92
  })
93
93
  })
94
+
95
+ describe('logsContainer', () => {
96
+ const status = (name: string, extra: Record<string, unknown> = {}) => ({ name, ready: false, restartCount: 0, ...extra })
97
+ it('opens a Pending worker in the regular init container it waits on, past a started native sidecar', () => {
98
+ expect(logsContainer({ name: 'p', ready: false, phase: 'Pending', containers: ['proxy', 'wait-gcs-ready', 'ray-worker'], containerStatuses: [status('proxy', { init: true, sidecar: true }), status('wait-gcs-ready', { init: true }), status('ray-worker')] }, true)).toBe('wait-gcs-ready')
99
+ })
100
+ it('opens the regular init container that failed in a Failed Pod', () => {
101
+ expect(logsContainer({ name: 'p', ready: false, phase: 'Failed', containers: ['setup', 'ray-worker'], containerStatuses: [status('setup', { init: true }), status('ray-worker')] }, true)).toBe('setup')
102
+ })
103
+ it('opens the application of a running Pod, never an unready native sidecar', () => {
104
+ expect(logsContainer({ name: 'p', ready: false, phase: 'Running', containers: ['proxy', 'ray-worker'], containerStatuses: [status('proxy', { init: true, sidecar: true }), status('ray-worker')] }, true)).toBe('ray-worker')
105
+ })
106
+ it('opens the application of a Failed Pod whose init containers finished', () => {
107
+ expect(logsContainer({ name: 'p', ready: false, phase: 'Failed', containers: ['proxy', 'setup', 'ray-worker'], containerStatuses: [status('proxy', { init: true, sidecar: true }), status('setup', { init: true, ready: true }), status('ray-worker')] }, true)).toBe('ray-worker')
108
+ })
109
+ it('leaves the choice to the viewer when init statuses are missing but init containers may exist', () => {
110
+ expect(logsContainer({ name: 'p', ready: false, phase: 'Running', containers: ['wait-gcs-ready', 'ray-worker'], containerStatuses: [status('ray-worker')] }, true)).toBeUndefined()
111
+ expect(logsContainer({ name: 'p', ready: true, phase: 'Running', containers: ['ray-head'], containerStatuses: [status('ray-head')] }, true)).toBe('ray-head')
112
+ })
113
+ it('leaves the choice to the viewer when no status is reported', () => {
114
+ expect(logsContainer({ name: 'p', ready: false, containers: ['wait-gcs-ready', 'ray-worker'] }, true)).toBeUndefined()
115
+ })
116
+ it('on a Radar that does not mark sidecars, opens the first application container', () => {
117
+ const onlyWait = { name: 'p', ready: false, phase: 'Pending', containers: ['wait-gcs-ready', 'ray-worker'], containerStatuses: [status('wait-gcs-ready', { init: true }), status('ray-worker')] }
118
+ expect(logsContainer(onlyWait, false)).toBe('ray-worker')
119
+ const loneSidecar = { name: 'p', ready: false, phase: 'Pending', containers: ['setup', 'proxy', 'ray-worker'], containerStatuses: [status('setup', { init: true, ready: true }), status('proxy', { init: true }), status('ray-worker')] }
120
+ expect(logsContainer(loneSidecar, false)).toBe('ray-worker')
121
+ const failedWithStoppedSidecar = { name: 'p', ready: false, phase: 'Failed', containers: ['proxy', 'setup', 'ray-worker'], containerStatuses: [status('proxy', { init: true }), status('setup', { init: true, ready: true }), status('ray-worker')] }
122
+ expect(logsContainer(failedWithStoppedSidecar, false)).toBe('ray-worker')
123
+ })
124
+ })
125
+
126
+ describe('podLogsUnavailableReason', () => {
127
+ it('names why a Pod has no output to read yet', () => {
128
+ expect(podLogsUnavailableReason({ name: 'p', ready: false, containers: ['c'] })).toBe('Not scheduled')
129
+ expect(podLogsUnavailableReason({ name: 'p', ready: false, nodeName: 'n', containers: ['c'] })).toBe('No status yet')
130
+ expect(podLogsUnavailableReason({ name: 'p', ready: false, phase: 'Failed', containers: ['c'] })).toBe('Ended before starting')
131
+ expect(podLogsUnavailableReason({ name: 'p', ready: false, nodeName: 'n', containers: ['proxy', 'c'], containerStatuses: [{ name: 'proxy', init: true, sidecar: true, ready: true, restartCount: 0 }] })).toBe('Waiting to start')
132
+ expect(podLogsUnavailableReason({ name: 'p', ready: true, containers: ['c'], containerStatuses: [{ name: 'c', ready: true, restartCount: 0 }] })).toBeUndefined()
133
+ })
134
+ })
@@ -1,5 +1,6 @@
1
1
  // KubeRay CRD utility functions (ray.io/v1)
2
2
 
3
+ import type { WorkloadPodInfo } from '../../types'
3
4
  import type { StatusBadge } from './resource-utils'
4
5
  import { healthColors, formatAge } from './resource-utils'
5
6
 
@@ -194,3 +195,40 @@ export function rayJobIsLocal(resource: any): boolean {
194
195
  export function rayJobSubmissionMode(resource: any): string {
195
196
  return resource.spec?.submissionMode || 'K8sJobMode'
196
197
  }
198
+
199
+ /** The KubeRay RayCluster Radar reads Pods for: `ray.io/v1`, or group `ray.io` before the object loads. */
200
+ export function isKubeRayClusterIdentity(group: string | undefined, apiVersion: string | undefined): boolean {
201
+ return apiVersion ? apiVersion === 'ray.io/v1' : group === 'ray.io'
202
+ }
203
+
204
+ /** Why a Pod has no container output to read yet, or undefined when it may. */
205
+ export function podLogsUnavailableReason(pod: WorkloadPodInfo): string | undefined {
206
+ const statuses = pod.containerStatuses ?? []
207
+ // A native sidecar's status alone doesn't mean the Pod's own containers have one.
208
+ if (statuses.some(status => !status.sidecar)) return undefined
209
+ if (pod.phase === 'Failed' || pod.phase === 'Succeeded') return 'Ended before starting'
210
+ if (statuses.length) return 'Waiting to start'
211
+ return pod.nodeName ? 'No status yet' : 'Not scheduled'
212
+ }
213
+
214
+ // The container a Pod's logs open on. Container lists put init containers
215
+ // first and only reported statuses say which those are, so with no statuses
216
+ // the viewer keeps its own default. A regular init container that isn't done
217
+ // while the Pod is Pending is what it waits on (a worker waiting for the head
218
+ // sits in KubeRay's wait-gcs-ready), though it may not have started yet; in a
219
+ // Failed Pod it is what failed, or what never ran if the Pod was evicted
220
+ // first. Native sidecars never finish, so they are never that container.
221
+ // Without sidecar marks (an older Radar) an unready init container can't be
222
+ // told from a sidecar, so the first application container opens instead.
223
+ export function logsContainer(pod: WorkloadPodInfo, sidecarsMarked: boolean): string | undefined {
224
+ const statuses = pod.containerStatuses ?? []
225
+ if (!statuses.length) return undefined
226
+ const init = new Set(statuses.filter(status => status.init).map(status => status.name))
227
+ // Init statuses missing while there are more containers than reported
228
+ // application statuses: which names are init containers isn't known.
229
+ if (!init.size && pod.containers.length > statuses.length) return undefined
230
+ const blocking = sidecarsMarked && (pod.phase === 'Pending' || pod.phase === 'Failed')
231
+ ? pod.containers.find(name => statuses.some(status => status.init && !status.sidecar && status.name === name && !status.ready))
232
+ : undefined
233
+ return blocking ?? pod.containers.find(name => !init.has(name))
234
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import cases from '../../../../../testdata/strimzi/connector-status.json'
4
+ import { getGenericResourceStatus } from './generic-status'
5
+ import { OperationalIssuesShownContext } from '../ui/drawer-components'
6
+ import { getStrimziConnectorStatus, getStrimziConnectorColumnValue } from './resource-utils-strimzi'
7
+ import { StrimziConnectorRenderer } from './renderers/StrimziConnectorRenderer'
8
+
9
+ describe('Strimzi snapshot parity with canonical Issues', () => {
10
+ for (const tc of cases) it(tc.name, () => {
11
+ const status = getStrimziConnectorStatus(tc.resource)
12
+ expect(status.text).toBe(tc.text)
13
+ expect(() => renderToStaticMarkup(<StrimziConnectorRenderer data={tc.resource} />)).not.toThrow()
14
+ expect(getGenericResourceStatus(tc.resource)?.text).toBe(tc.text)
15
+ expect(status.level === 'degraded').toBe(tc.issue)
16
+ expect(status.level === 'healthy').toBe(tc.recovered)
17
+ })
18
+ it('shows desired and reported facts without config or traces', () => {
19
+ const data = structuredClone(cases[1].resource) as any
20
+ data.spec.config = { password: 'private-configuration' }
21
+ data.status.conditions[0].message = 'condition-trace-sentinel'
22
+ data.metadata.labels = { 'strimzi.io/cluster': 'connect-workers' }
23
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
24
+ for (const text of ['Tasks failed', 'Desired state', 'running (default)', 'Maximum tasks', 'RUNNING', 'FAILED', 'worker:8083', 'connect-workers', 'YAML']) expect(html).toContain(text)
25
+ expect(html).not.toContain('private-configuration')
26
+ expect(html).not.toContain('private-trace')
27
+ expect(html).not.toContain('condition-trace-sentinel')
28
+ expect(html).toContain('Strimzi condition message withheld')
29
+ expect(html).toContain('status.conditions in YAML')
30
+ })
31
+ it('bounds large previews and makes omitted tasks explicit', () => {
32
+ const data = structuredClone(cases[0].resource) as any
33
+ data.status.connectorStatus.tasks = Array.from({length: 150}, (_, id) => ({id,state:'RUNNING',worker_id:`worker-${id}`}))
34
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
35
+ expect(html).toContain('Reported tasks (150)')
36
+ expect(html).toContain('Show 100 tasks')
37
+ expect(html).toContain('limited to 100 of 150')
38
+ expect(html).not.toContain('worker-149')
39
+ })
40
+ })
41
+
42
+ it('respects v1beta2 pause and native automatic restart defaults', () => {
43
+ const data = structuredClone(cases[0].resource) as any
44
+ data.apiVersion = 'kafka.strimzi.io/v1beta2'
45
+ data.spec.pause = true
46
+ data.spec.autoRestart = {}
47
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
48
+ expect(html).toContain('paused (spec.pause)')
49
+ expect(html).toContain('Yes (default)')
50
+ expect(html).toContain('Unlimited (default)')
51
+ })
52
+ it('qualifies historical evidence independently from badge wording', () => {
53
+ const data = structuredClone(cases[1].resource) as any
54
+ data.status.observedGeneration = 1
55
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
56
+ expect(html).toContain('Historical or unassessed status')
57
+ expect(getStrimziConnectorStatus(data).snapshotCurrent).toBe(false)
58
+ })
59
+
60
+ it('does not render structured runtime values as React children', () => {
61
+ const data = structuredClone(cases[0].resource) as any
62
+ data.status.connectorStatus = {connector:{state:{bad:true},worker_id:[]},tasks:[{id:{bad:true},state:[],worker_id:{bad:true}}]}
63
+ data.status.conditions = {bad:true}
64
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
65
+ expect(html).toContain('Not reported')
66
+ expect(html).not.toContain('[object Object]')
67
+ })
68
+
69
+
70
+ it('shows failure guidance only when canonical Issues are not displayed', () => {
71
+ const data = structuredClone(cases[1].resource)
72
+ const render = (shown: boolean) => renderToStaticMarkup(<OperationalIssuesShownContext.Provider value={shown}><StrimziConnectorRenderer data={data} /></OperationalIssuesShownContext.Provider>)
73
+ expect(render(false)).toContain('Strimzi reports a failure in this operator snapshot')
74
+ expect(render(true)).not.toContain('Strimzi reports a failure in this operator snapshot')
75
+ data.status.observedGeneration = 0
76
+ expect(render(false)).not.toContain('Strimzi reports a failure in this operator snapshot')
77
+ })
78
+
79
+ it('omits withheld-message guidance when no condition message is present', () => {
80
+ const data = structuredClone(cases[0].resource)
81
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
82
+ expect(html).not.toContain('Strimzi condition message withheld')
83
+ })
84
+
85
+ it('does not suggest a restart allowance when automatic restart is disabled', () => {
86
+ const data = structuredClone(cases[0].resource) as any
87
+ data.spec.autoRestart = { enabled: false, maxRestarts: 5 }
88
+ data.status.autoRestart = { count: 2 }
89
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
90
+ expect(html).toContain('Automatic Restart')
91
+ expect(html).toContain('Reported restarts')
92
+ expect(html).not.toContain('Maximum restarts')
93
+ expect(html).not.toContain('Unlimited')
94
+ })
95
+
96
+ it('uses the same unset maximum-task text in list and detail', () => {
97
+ const data = structuredClone(cases[0].resource) as any
98
+ delete data.spec.tasksMax
99
+ expect(getStrimziConnectorColumnValue(data, 'connectorTasks')).toBe('1 / Not specified')
100
+ expect(renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)).toContain('Not specified')
101
+ })
102
+
103
+ it('prioritizes failed and other non-running tasks in the bounded preview', () => {
104
+ const data = structuredClone(cases[0].resource) as any
105
+ data.status.connectorStatus.tasks = [
106
+ ...Array.from({ length: 7 }, (_, id) => ({ id, state: 'RUNNING', worker_id: `running-${id}` })),
107
+ { id: 7, state: 'RESTARTING', worker_id: 'restarting-worker' },
108
+ { id: 8, state: 'UNASSIGNED', worker_id: 'unassigned-worker' },
109
+ { id: 9, state: 'FAILED', worker_id: 'failed-worker' },
110
+ ]
111
+ const html = renderToStaticMarkup(<StrimziConnectorRenderer data={data} />)
112
+ expect(html.indexOf('failed-worker')).toBeLessThan(html.indexOf('restarting-worker'))
113
+ expect(html.indexOf('restarting-worker')).toBeLessThan(html.indexOf('unassigned-worker'))
114
+ expect(html.indexOf('unassigned-worker')).toBeLessThan(html.indexOf('running-0'))
115
+ expect(html).not.toContain('running-2')
116
+ expect(html).toContain('Show 10 tasks')
117
+ expect(data.status.connectorStatus.tasks[0].worker_id).toBe('running-0')
118
+ })
@@ -0,0 +1,35 @@
1
+ import { isApiGroup } from './resource-utils-cnpg'
2
+ import { healthColors, type StatusBadge } from './resource-utils'
3
+
4
+ // Generation agreement permits assessment; it does not establish live runtime freshness.
5
+ export function getStrimziConnectorStatus(data: any): StatusBadge & { snapshotCurrent: boolean } {
6
+ const badge = (text: string, level: StatusBadge['level'], snapshotCurrent = false) => ({ text, level, color: healthColors[level], snapshotCurrent })
7
+ if (data.metadata?.deletionTimestamp) return badge('Terminating', 'neutral')
8
+ if (data.metadata?.annotations?.['strimzi.io/pause-reconciliation']?.toLowerCase() === 'true') return badge('Reconciliation paused', 'neutral')
9
+ const observed = data.status?.observedGeneration
10
+ if (!Number.isInteger(observed) || observed <= 0) return badge('Status unknown', 'unknown')
11
+ if (observed !== data.metadata?.generation) return badge('Status stale', 'unknown')
12
+ const conditions = Array.isArray(data.status?.conditions) ? data.status.conditions : []
13
+ if (conditions.some((c: any) => c?.type === 'ReconciliationPaused' && c.status === 'True')) return badge('Reconciliation paused', 'neutral')
14
+ const state = data.status?.connectorStatus?.connector?.state
15
+ const tasks = Array.isArray(data.status?.connectorStatus?.tasks) ? data.status.connectorStatus.tasks : []
16
+ if (state === 'FAILED') return badge('Connector failed', 'degraded', true)
17
+ if (tasks.some((t: any) => t?.state === 'FAILED')) return badge('Tasks failed', 'degraded', true)
18
+ if (conditions.some((c: any) => c?.type === 'NotReady' && c.status === 'True')) return badge('Not ready', 'degraded', true)
19
+ if (conditions.every((c: any) => c && typeof c === 'object' && !Array.isArray(c)) && conditions.some((c: any) => c.type === 'Ready' && c.status === 'True') && state === 'RUNNING' && tasks.length > 0 && tasks.every((t: any) => t?.state === 'RUNNING')) return badge('Running', 'healthy', true)
20
+ if (state === 'PAUSED' || state === 'STOPPED') return badge(state === 'PAUSED' ? 'Paused' : 'Stopped', 'neutral', true)
21
+ if (state === 'RESTARTING' || state === 'UNASSIGNED') return badge(state === 'RESTARTING' ? 'Restarting' : 'Unassigned', 'unknown', true)
22
+ if (tasks.some((t: any) => t?.state === 'RESTARTING')) return badge('Tasks restarting', 'unknown', true)
23
+ return badge('Runtime unknown', 'unknown', true)
24
+ }
25
+
26
+ export function getStrimziConnectorColumnValue(data: any, column: string): string | undefined {
27
+ if (data.kind !== 'KafkaConnector' || !isApiGroup(data.apiVersion, 'kafka.strimzi.io')) return undefined
28
+ switch (column) {
29
+ case 'status': return getStrimziConnectorStatus(data).text
30
+ case 'connectCluster': return data.metadata?.labels?.['strimzi.io/cluster'] ?? ''
31
+ case 'connectorClass': return data.spec?.class ?? ''
32
+ case 'connectorTasks': return `${Array.isArray(data.status?.connectorStatus?.tasks) ? data.status.connectorStatus.tasks.length : 'Unknown'} / ${data.spec?.tasksMax ?? 'Not specified'}`
33
+ default: return undefined
34
+ }
35
+ }
@@ -1,3 +1,5 @@
1
+ import { getCRDColumnValue } from './resource-utils-crd'
2
+ import { getStrimziConnectorColumnValue } from './resource-utils-strimzi'
1
3
  import { getOpenTelemetryColumnValue } from './resource-utils-opentelemetry'
2
4
  import { getFlaggerColumnValue } from './resource-utils-flagger'
3
5
  // Utility functions for resource display in tables
@@ -207,6 +209,22 @@ function podUnschedulable(pod: any): boolean {
207
209
  )
208
210
  }
209
211
 
212
+ // A non-empty spec.schedulingGates means a controller is deliberately holding the
213
+ // pod in Pending (Kueue admission, topology assignment, a custom gate). Mirrors the
214
+ // backend health.PodHasSchedulingGates — keep the two in lockstep (the cross-language
215
+ // golden fixture pins it). The spec field is authoritative and is kept in the summary
216
+ // list payload, so this reads it directly rather than racing the scheduler's
217
+ // PodScheduled=SchedulingGated condition (which lags gate changes in both directions).
218
+ function podSchedulingGated(pod: any): boolean {
219
+ return (pod?.spec?.schedulingGates || []).length > 0
220
+ }
221
+
222
+ function schedulingGateNames(pod: any): string[] {
223
+ return (pod?.spec?.schedulingGates || [])
224
+ .map((g: any) => g?.name)
225
+ .filter((n: any): n is string => typeof n === 'string' && n.length > 0)
226
+ }
227
+
210
228
  // A container that has terminated successfully (exit 0) is done, not "not ready".
211
229
  // A completing Job pod (Running phase, container Completed, Ready=false) must not
212
230
  // read as degraded just because ready<total — this mirrors ClassifyPodHealth, so
@@ -427,6 +445,12 @@ export function getPodStatus(pod: any): StatusBadge {
427
445
  case 'Succeeded':
428
446
  return { text: 'Completed', color: healthColors.neutral, level: 'neutral' }
429
447
  case 'Pending':
448
+ // A scheduling gate deliberately holds the pod in Pending; it's an
449
+ // intentional state, not an age-based problem, so it reads neutral and never
450
+ // ages into a warning. Checked before the unschedulable/age branches.
451
+ if (podSchedulingGated(pod)) {
452
+ return { text: 'Scheduling gated', color: healthColors.neutral, level: 'neutral' }
453
+ }
430
454
  // Unschedulable = the scheduler tried and failed to place this pod; the
431
455
  // backend flags it immediately (severity ramps with duration), so the badge
432
456
  // does too. Plain Pending (not yet placed) keeps the young-grace window.
@@ -577,7 +601,22 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
577
601
  }
578
602
  case 'Succeeded':
579
603
  return { phase, text: 'Completed', level: 'neutral' }
580
- case 'Pending':
604
+ case 'Pending': {
605
+ // A scheduling gate deliberately holds the pod in Pending; surface it as a
606
+ // calm, explained state (with the gate name) rather than a bare Pending or an
607
+ // age-based warning. Checked before the unschedulable/age branches.
608
+ if (podSchedulingGated(pod)) {
609
+ const gates = schedulingGateNames(pod)
610
+ return {
611
+ phase,
612
+ text: `${phase} — Scheduling gated`,
613
+ level: 'neutral',
614
+ hint:
615
+ gates.length > 0
616
+ ? `Held by scheduling gate(s): ${gates.join(', ')}. A controller is intentionally blocking scheduling until a condition is met.`
617
+ : 'A controller is intentionally blocking scheduling until a condition is met.',
618
+ }
619
+ }
581
620
  // Unschedulable = the scheduler tried and failed; surface it immediately
582
621
  // (the backend does, with severity ramping by duration). Plain Pending
583
622
  // (not yet placed) keeps the young-grace window.
@@ -598,6 +637,7 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
598
637
  }
599
638
  }
600
639
  return { phase, text: 'Pending', level: 'neutral' }
640
+ }
601
641
  case 'Failed':
602
642
  return { phase, text: 'Failed', level: 'unhealthy' }
603
643
  default:
@@ -2464,10 +2504,14 @@ function parseColumnFilterPair(
2464
2504
 
2465
2505
  export function getCellFilterValue(resource: any, column: string, kind: string): string {
2466
2506
  try {
2507
+ const connectorValue = getStrimziConnectorColumnValue(resource, column)
2508
+ if (connectorValue !== undefined) return connectorValue
2467
2509
  const otelValue = getOpenTelemetryColumnValue(resource, column)
2468
2510
  if (otelValue !== undefined) return otelValue
2469
2511
  const flaggerValue = getFlaggerColumnValue(resource, column)
2470
2512
  if (flaggerValue !== undefined) return flaggerValue
2513
+ const crdValue = getCRDColumnValue(resource, column)
2514
+ if (crdValue !== undefined) return crdValue
2471
2515
  const kindLower = kind.toLowerCase()
2472
2516
 
2473
2517
  switch (column) {