@skyhook-io/k8s-ui 1.19.0 → 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 (139) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  5. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  6. package/src/components/compare/ResourceCompareView.tsx +35 -12
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  9. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  10. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  11. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  12. package/src/components/gitops/RollbackDialog.tsx +25 -6
  13. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  14. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  15. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  16. package/src/components/gitops/applicationset-row.test.ts +12 -0
  17. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  18. package/src/components/gitops/detail-helpers.test.ts +8 -2
  19. package/src/components/gitops/detail-helpers.ts +9 -11
  20. package/src/components/gitops/index.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  22. package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
  23. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  24. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
  26. package/src/components/gitops/insights/insights-helpers.ts +12 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  28. package/src/components/issues/diagnostic.ts +14 -1
  29. package/src/components/issues/severity.ts +14 -0
  30. package/src/components/kueue/AdmissionPath.tsx +223 -0
  31. package/src/components/kueue/QueueEvidence.tsx +148 -0
  32. package/src/components/kueue/constants.ts +1 -0
  33. package/src/components/kueue/eligibility.ts +43 -0
  34. package/src/components/kueue/flavors-tried.ts +79 -0
  35. package/src/components/kueue/helpers.test.ts +105 -0
  36. package/src/components/kueue/policy.ts +100 -0
  37. package/src/components/kueue/queue-detail.test.tsx +317 -0
  38. package/src/components/kueue/queue-status.ts +42 -0
  39. package/src/components/kueue/related-read.ts +48 -0
  40. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  41. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  42. package/src/components/logs/StructuredLogLine.tsx +8 -3
  43. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  44. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  45. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  46. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  47. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  48. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  49. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  50. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  51. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  52. package/src/components/resources/ResourcesView.tsx +50 -3
  53. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  54. package/src/components/resources/generic-status.ts +7 -0
  55. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  56. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  57. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  58. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  59. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  60. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  61. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  62. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  63. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  64. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  65. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  66. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  67. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  68. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  69. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  70. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  71. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  72. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  73. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  74. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  75. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  76. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  77. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  78. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  79. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  80. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  81. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  82. package/src/components/resources/renderers/index.ts +6 -0
  83. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  84. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  85. package/src/components/resources/resource-utils-argo.ts +84 -5
  86. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  87. package/src/components/resources/resource-utils-crd.ts +26 -0
  88. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  89. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  90. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  91. package/src/components/resources/resource-utils-kueue.ts +137 -4
  92. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  93. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  94. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  95. package/src/components/resources/resource-utils-ray.ts +38 -0
  96. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  97. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  98. package/src/components/resources/resource-utils.ts +45 -1
  99. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  100. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  101. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  102. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  103. package/src/components/topology/K8sResourceNode.tsx +18 -1
  104. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  105. package/src/components/topology/TopologyGraph.tsx +96 -94
  106. package/src/components/topology/layout.ts +3 -1
  107. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  108. package/src/components/ui/Badge.tsx +1 -0
  109. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  110. package/src/components/ui/drawer-components.tsx +1 -1
  111. package/src/components/ui/index.ts +1 -0
  112. package/src/components/workload/PodList.tsx +35 -9
  113. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  114. package/src/components/workload/WorkloadView.tsx +61 -8
  115. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  116. package/src/types/core.ts +40 -0
  117. package/src/types/gitops-insights.ts +11 -2
  118. package/src/types/gitops-tree.ts +2 -2
  119. package/src/types/kueue-queues.ts +84 -0
  120. package/src/types/read-coverage.ts +2 -1
  121. package/src/types/scheduling.ts +72 -0
  122. package/src/utils/animation.test.ts +38 -0
  123. package/src/utils/animation.ts +34 -0
  124. package/src/utils/api-resources-crd.test.ts +11 -0
  125. package/src/utils/api-resources.ts +5 -1
  126. package/src/utils/applications.test.ts +15 -1
  127. package/src/utils/applications.ts +26 -1
  128. package/src/utils/crd-schema.test.ts +107 -0
  129. package/src/utils/crd-schema.ts +201 -0
  130. package/src/utils/format.test.ts +37 -1
  131. package/src/utils/format.ts +28 -80
  132. package/src/utils/gitops-owner.test.ts +28 -2
  133. package/src/utils/gitops-owner.ts +19 -0
  134. package/src/utils/kueue-queue-workloads.ts +19 -0
  135. package/src/utils/resource-icons.ts +2 -0
  136. package/src/utils/topology-neighborhood.test.ts +90 -1
  137. package/src/utils/topology-neighborhood.ts +55 -3
  138. package/src/utils/workload-history-notes.test.ts +76 -0
  139. package/src/utils/workload-history-notes.ts +93 -0
@@ -0,0 +1,73 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { GitOpsInsight } from '../../../types'
4
+ import { GitOpsChangesView, GitOpsIssuesBand } from './GitOpsInsightViews'
5
+
6
+ function insight(rolledBack: boolean): GitOpsInsight {
7
+ return {
8
+ summary: { tool: 'argocd', kind: 'Application', namespace: 'argocd', name: 'demo', autoSyncMode: 'Manual', operationPhase: 'Succeeded', rollbackRevision: rolledBack ? 'a'.repeat(40) : undefined },
9
+ issues: rolledBack ? [{
10
+ reason: 'RolledBack', scope: 'operation', severity: 'info',
11
+ message: `Last rollback deployed ${'a'.repeat(40)} by alice.`,
12
+ cause: "Auto-sync is off, so Argo won't deploy a new revision on its own. Changes made since the rollback can also show as drift. Syncing or enabling auto-sync deploys the current revision and can undo the rollback.",
13
+ }] : [],
14
+ changes: [{
15
+ ref: { group: 'apps', kind: 'Deployment', namespace: 'default', name: 'demo' },
16
+ sync: 'OutOfSync', category: 'OutOfSync', hasDesired: true, hasLive: true, partial: false,
17
+ }],
18
+ }
19
+ }
20
+
21
+ describe('rollback insights', () => {
22
+ it('puts the consequence on a separate line from the full revision', () => {
23
+ const html = renderToString(<GitOpsIssuesBand issues={insight(true).issues} />)
24
+ expect(html).toContain('a'.repeat(40))
25
+ expect(html).toMatch(/<span class="[^"]*whitespace-normal break-words">Last rollback deployed/)
26
+ expect(html).toMatch(/<p class="[^"]*">Auto-sync is off/)
27
+ expect(html).toContain('Changes made since the rollback can also show as drift.')
28
+ })
29
+
30
+ it('shows the mismatch revisions, compatibility explanation, and recovery advice in full', () => {
31
+ const message = `Last rollback deployed different sources or revisions than requested: ${'b'.repeat(40)} instead of ${'a'.repeat(40)}.`
32
+ const cause = "On Argo CD before 2.12, rolling back across a single-source/multi-source change or to a multi-source entry can deploy the wrong revisions or render nothing; with prune, it can delete resources."
33
+ const action = 'Check the Argo CD controller version, then sync the intended revision manually.'
34
+ const html = renderToString(<GitOpsIssuesBand issues={[{
35
+ reason: 'RollbackRevisionMismatch', scope: 'operation', severity: 'warning', message, cause, action,
36
+ }]} />)
37
+ expect(html).toMatch(/<span class="[^"]*whitespace-normal break-words">Last rollback deployed/)
38
+ expect(html).toContain(message)
39
+ expect(html).toMatch(/<p class="[^"]*">On Argo CD before 2.12/)
40
+ expect(html).toContain(action)
41
+ })
42
+
43
+ it('explains that resource Sync can undo the rollback', () => {
44
+ const html = renderToString(<GitOpsChangesView insight={insight(true)} />)
45
+ expect(html).toContain('Live state differs from Git after a rollback. Sync applies the desired state and can undo the rollback.')
46
+ expect(html).not.toContain('Click Sync to apply the desired state.')
47
+ })
48
+
49
+ it('keeps ordinary manual-drift resource copy unchanged', () => {
50
+ expect(renderToString(<GitOpsChangesView insight={insight(false)} />)).toContain('Live state differs from Git. Click Sync to apply the desired state.')
51
+ })
52
+ })
53
+
54
+ it('keeps Missing rows rollback-aware when sync is Unknown and no RolledBack issue exists', () => {
55
+ const data = insight(true)
56
+ data.summary.sync = 'Unknown'
57
+ data.issues = []
58
+ data.changes![0].sync = 'Unknown'
59
+ data.changes![0].missing = true
60
+ data.changes![0].hasLive = false
61
+ const html = renderToString(<GitOpsChangesView insight={data} />)
62
+ expect(html).toContain('Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.')
63
+ expect(html).not.toContain('Sync the application to create it.')
64
+ })
65
+
66
+ it('keeps Missing health explanations rollback-aware without a confirmed missing lookup', () => {
67
+ const data = insight(true)
68
+ data.changes![0].sync = 'Unknown'
69
+ data.changes![0].health = 'Missing'
70
+ const html = renderToString(<GitOpsChangesView insight={data} />)
71
+ expect(html).toContain('Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.')
72
+ expect(html).not.toContain('Declared in Git but not present in the cluster. Sync to create it.')
73
+ })
@@ -0,0 +1,29 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { GitOpsIssue } from '../../../types/gitops-insights'
4
+ import { GitOpsIssuesBand } from './GitOpsInsightViews'
5
+
6
+ const windowIssues: GitOpsIssue[] = [
7
+ {
8
+ reason: 'SyncWindowBlocked', scope: 'operation', severity: 'warning',
9
+ message: "A sync window is holding this sync. Argo CD continues it when the AppProject's sync windows allow syncing.",
10
+ action: 'Wait for the window, or terminate the operation to drop it. A terminated operation stays Terminating until a window allows syncing; it applies nothing more, and changes already applied stay.',
11
+ },
12
+ {
13
+ reason: 'SyncWindowBlocked', scope: 'operation', severity: 'warning',
14
+ message: 'This operation was terminated while a sync window held it. Argo CD completes the termination when a window allows syncing. It applies nothing more; changes already applied stay.',
15
+ action: "Wait for the window to open, or change the AppProject's sync windows.",
16
+ },
17
+ ]
18
+
19
+ describe('sync-window insights', () => {
20
+ it.each(windowIssues)('shows the full message and action for $message', (issue) => {
21
+ for (const issues of [[issue], [issue, { ...issue, reason: 'OtherIssue' }]]) {
22
+ const html = renderToString(<GitOpsIssuesBand issues={issues} />)
23
+ expect(html).toMatch(/<span class="[^"]*whitespace-normal break-words">/)
24
+ expect(html).not.toMatch(/<span class="[^"]*truncate">/)
25
+ expect(html).toContain(issue.message.replaceAll("'", '&#x27;'))
26
+ expect(html).toContain(`>${issue.action!.replaceAll("'", '&#x27;')}</p>`)
27
+ }
28
+ })
29
+ })
@@ -9,6 +9,7 @@ import {
9
9
  entryTone,
10
10
  gitopsToSeverity,
11
11
  healthStatusRank,
12
+ historyRollbackBlockedReason,
12
13
  isArgoResourceSyncEligible,
13
14
  messageToPhase,
14
15
  missingResourceMessage,
@@ -30,6 +31,21 @@ describe('confirmed missing resources', () => {
30
31
  expect(isArgoResourceSyncEligible(item, true)).toBe(true)
31
32
  })
32
33
 
34
+ it.each(['OutOfSync', 'Unknown', undefined])('defers to the hazard before rollback advice for %s', (sync) => {
35
+ expect(missingResourceMessage(sync, { hazard: 'Sync can delete resources.', rolledBack: true })).toBe('Not present in the cluster. See the sync warning above before syncing this Application.')
36
+ expect(missingResourceMessage(sync, { hazard: 'Sync can delete resources.' })).toBe('Not present in the cluster. See the sync warning above before syncing this Application.')
37
+ })
38
+
39
+ it('keeps refresh advice for Synced even with hazard and rollback context', () => {
40
+ expect(missingResourceMessage('Synced', { hazard: 'Sync can delete resources.', rolledBack: true })).toBe(missingResourceMessage('Synced'))
41
+ })
42
+
43
+ it('uses named rollback context and treats an empty hazard as absent', () => {
44
+ expect(missingResourceMessage('OutOfSync', { rolledBack: true })).toBe('Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.')
45
+ expect(missingResourceMessage('OutOfSync', { hazard: '', rolledBack: true })).toBe(missingResourceMessage('OutOfSync', { rolledBack: true }))
46
+ expect(missingResourceMessage('OutOfSync', { rolledBack: false })).toBe(missingResourceMessage('OutOfSync'))
47
+ })
48
+
33
49
  it('defers creation advice to the sync hazard while preserving stale Synced refresh advice', () => {
34
50
  for (const sync of ['OutOfSync', 'Unknown', undefined]) {
35
51
  expect(missingResourceMessage(sync, { hazard: 'Resolve shared ownership before syncing.' })).toBe(
@@ -243,3 +259,24 @@ describe('syncStatusRank / healthStatusRank', () => {
243
259
  expect(healthStatusRank('Degraded')).toBeLessThan(healthStatusRank('Healthy'))
244
260
  })
245
261
  })
262
+
263
+ describe('historyRollbackBlockedReason', () => {
264
+ const autoSync = 'Auto-sync is enabled.'
265
+ it('allows an entry the backend marks rollbackable', () => {
266
+ expect(historyRollbackBlockedReason({ id: '0', rollbackable: true })).toBeUndefined()
267
+ })
268
+ it('blocks an entry that records no source', () => {
269
+ expect(historyRollbackBlockedReason({ id: '1', rollbackable: false })).toMatch(/records no source/)
270
+ })
271
+ // Turning auto-sync off would not make this entry rollbackable.
272
+ it('names the missing source over auto-sync', () => {
273
+ expect(historyRollbackBlockedReason({ id: '1', rollbackable: false }, autoSync)).toMatch(/records no source/)
274
+ })
275
+ it('passes the app-wide reason through for a rollbackable entry', () => {
276
+ expect(historyRollbackBlockedReason({ id: '0', rollbackable: true }, autoSync)).toBe(autoSync)
277
+ })
278
+ // A Radar that predates the field omits it; the rollback API decides.
279
+ it('leaves an entry without the field enabled', () => {
280
+ expect(historyRollbackBlockedReason({ id: '2' })).toBeUndefined()
281
+ })
282
+ })
@@ -160,6 +160,18 @@ export function entryTone(item: GitOpsHistoryItem): EntryTone {
160
160
  return { dot: SEVERITY_DOT.neutral, inferredFrom: 'no phase information' }
161
161
  }
162
162
 
163
+ // historyRollbackBlockedReason is why a history row's Rollback button is
164
+ // disabled, or undefined when it can roll back. An entry the backend marks
165
+ // not rollbackable is refused by the rollback API whatever else changes, so
166
+ // its reason wins over the app-wide one (auto-sync), which the operator can
167
+ // fix. A Radar that predates `rollbackable` omits it; the API decides there.
168
+ export function historyRollbackBlockedReason(item: GitOpsHistoryItem, appBlockedReason?: string): string | undefined {
169
+ if (item.rollbackable === false) {
170
+ return "This entry records no source, so Argo CD can't roll back to it. Sync to its revision instead."
171
+ }
172
+ return appBlockedReason
173
+ }
174
+
163
175
  // Compact a source string for inline display. Argo emits the full GitHub URL
164
176
  // followed by " · path/within/repo", which dominates the timeline row when
165
177
  // rendered raw. Strip the protocol+host (full string still shown on hover via
@@ -10,10 +10,12 @@ export function ResourceIssuesSection({
10
10
  subjectResource,
11
11
  compact,
12
12
  renderDetailSection,
13
+ heading = "Operational issues",
13
14
  }: {
14
15
  compact?: boolean
15
16
  renderDetailSection?: (ctx: IssueRowSlotContext) => ReactNode
16
17
  issues: Issue[] | undefined
18
+ heading?: string
17
19
  /** When provided, related resources in a causal link become clickable. */
18
20
  onResourceClick?: (ref: IssueResourceRef) => void
19
21
  /** The resource this section is embedded under. When an issue's subject IS
@@ -31,7 +33,7 @@ export function ResourceIssuesSection({
31
33
  <section className="space-y-2">
32
34
  <div className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
33
35
  <AlertTriangle className="h-4 w-4 text-theme-text-tertiary" aria-hidden />
34
- <span>Operational issues ({sorted.length})</span>
36
+ <span>{heading} ({sorted.length})</span>
35
37
  </div>
36
38
  <ol className="flex flex-col gap-1.5">
37
39
  {sorted.map((issue) => {
@@ -26,8 +26,10 @@ export function diagnosticFactLabel(type: string): string {
26
26
  return 'Missing reference';
27
27
  case 'node_startup_corroboration':
28
28
  return 'Same-node failures';
29
+ case 'evicted_from_node':
30
+ return 'Evicted by node';
29
31
  case 'owner_rollup':
30
- return 'Grouped from pods';
32
+ return 'Grouped resources';
31
33
  case 'selected_backend_issue':
32
34
  return 'Backend pods';
33
35
  case 'service_config_mismatch':
@@ -56,6 +58,16 @@ export function diagnosticFactLabel(type: string): string {
56
58
  return 'Affected resources';
57
59
  case 'tls_certificate_consumers':
58
60
  return 'Used for TLS by';
61
+ case 'kueue_admission':
62
+ return 'Kueue admission';
63
+ case 'kueue_queue':
64
+ return 'Queue';
65
+ case 'kueue_affected_runs':
66
+ return 'Blocked runs';
67
+ case 'kueue_admission_check':
68
+ return 'Admission check';
69
+ case 'kueue_provisioning_request':
70
+ return 'Provisioning request';
59
71
  default:
60
72
  return type.replace(/_/g, ' ');
61
73
  }
@@ -70,6 +82,7 @@ export function incidentParentLabel(factType?: string, confidence?: string): str
70
82
  case 'pvc_blast_radius':
71
83
  case 'secret_not_ready':
72
84
  case 'admission_webhook_backend':
85
+ case 'kueue_queue':
73
86
  return 'Caused by';
74
87
  case 'apiservice_hpa':
75
88
  return 'Likely cause';
@@ -57,6 +57,7 @@ const CATEGORY_LABEL: Record<string, string> = {
57
57
  unschedulable: "Can't be scheduled",
58
58
  quota_exceeded: 'Quota exceeded',
59
59
  admission_webhook_blocking: 'Admission blocked',
60
+ queue_admission_blocked: 'Queue admission blocked',
60
61
  image_pull_failed: 'Image pull failed',
61
62
  container_waiting: 'Container waiting',
62
63
  init_container_failed: 'Init container failed',
@@ -66,6 +67,7 @@ const CATEGORY_LABEL: Record<string, string> = {
66
67
  readiness_failed: 'Not ready for traffic',
67
68
  workload_degraded: 'Workload degraded',
68
69
  high_restart: 'High restart count',
70
+ pod_evicted: 'Pod evicted',
69
71
  missing_config_ref: 'Missing reference',
70
72
  pdb_blocks_evictions: 'Evictions blocked',
71
73
  secret_sync_failed: 'Secret sync failed',
@@ -89,10 +91,12 @@ const CATEGORY_LABEL: Record<string, string> = {
89
91
  backup_stalled: 'Backup not progressing',
90
92
  job_failed: 'Job failed',
91
93
  cronjob_failed: 'CronJob needs attention',
94
+ batch_run_stopped: 'Run stopped by Kueue',
92
95
  rollout_stalled: 'Rollout stalled',
93
96
  hpa_limited_or_failed: 'Autoscaling limited',
94
97
  rbac_forbidden: 'Permission denied',
95
98
  certificate_not_ready: 'Certificate not ready',
99
+ certificate_renewal_failed: 'Certificate renewal failed',
96
100
  pod_security_violation: 'Pod Security blocked',
97
101
  node_not_ready: 'Node not ready',
98
102
  node_provisioning_failed: 'Node provisioning failed',
@@ -154,6 +158,16 @@ const REASON_TITLE: Record<string, string> = {
154
158
  CNPGInstanceReadinessMismatch: "Instance Pods contradict Cluster readiness",
155
159
  CNPGPrimaryLabelMismatch: "Primary Pod label contradicts Cluster status",
156
160
  CNPGScheduledRunNoBackup: "No successful backup since a scheduled run",
161
+ KueueAdmissionCheckRejected: 'Admission check rejected the run',
162
+ KueueClusterQueueInactive: 'ClusterQueue admits nothing',
163
+ KueueClusterQueueMissing: 'ClusterQueue missing',
164
+ KueueLocalQueueMissing: 'LocalQueue missing',
165
+ KueueRequeuingLimitExceeded: 'Kueue stopped retrying the run',
166
+ KueueMaximumExecutionTimeExceeded: 'Run hit its maximum execution time',
167
+ KueueDeactivatedByKueue: 'Kueue withdrew the run',
168
+ KueueResourceUnavailable: 'Queue offers no quota for a requested resource',
169
+ KueueNamespaceNotEligible: "Queue doesn't admit this namespace",
170
+ KueueInvalidWorkload: "Kueue can't admit the run's spec",
157
171
  };
158
172
 
159
173
  /** The title a reason has wherever it is shown, when its category's label would name the wrong operation. */
@@ -0,0 +1,223 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Route } from 'lucide-react'
3
+ import { ResourceLink, Section, ownerNote } from '../ui/drawer-components'
4
+ import { gapReasonText } from '../../utils/read-actions'
5
+ import { FactSource, FactValue } from '../facts'
6
+ import type { Relationships } from '../../types'
7
+ import { kindToPluralWithGroup } from '../../utils/navigation'
8
+ import { isKueueConditionStale } from '../resources/resource-utils-kueue'
9
+ import { localQueueStatusWithParent } from './queue-status'
10
+ import { isFlavorAssignmentMessage, parseFlavorAttempts, type FlavorAttempt, type FlavorAttemptKind } from './flavors-tried'
11
+ import { ClusterQueueReadSummary, ReadState, StatusPill, namespaceEligibilityFact, type Navigate } from './QueueEvidence'
12
+ import { KUEUE_GROUP } from './constants'
13
+ import type { RelatedRead } from './related-read'
14
+
15
+ /** Objects on a Workload's admission path that a host read as the viewer. */
16
+ export interface KueueWorkloadReads {
17
+ localQueue?: RelatedRead
18
+ /** Keyed by ClusterQueue name: the LocalQueue's target and, when different, the one holding the reservation. */
19
+ clusterQueues?: Record<string, RelatedRead>
20
+ namespace?: RelatedRead
21
+ /** Keyed by ResourceFlavor name: the flavors the reservation assigned. */
22
+ flavors?: Record<string, RelatedRead>
23
+ }
24
+
25
+ function Step({ label, children }: { label: string; children: ReactNode }) {
26
+ return (
27
+ <li className="relative grid grid-cols-[7.5rem_minmax(0,1fr)] gap-3 py-2.5 text-sm first:pt-0 last:pb-0">
28
+ <span className="text-theme-text-tertiary">{label}</span>
29
+ <div className="min-w-0 space-y-1">{children}</div>
30
+ </li>
31
+ )
32
+ }
33
+
34
+ const muted = 'text-xs text-theme-text-secondary'
35
+
36
+ function OwnerStep({ data, relationships, onNavigate }: { data: any; relationships?: Relationships; onNavigate?: Navigate }) {
37
+ const coverage = relationships?.ownership
38
+ // Without the ownership answer (a Radar that predates it), `owner` may come
39
+ // from display edges that check neither group nor UID, so it isn't linked;
40
+ // the Workload's own controller reference is named instead.
41
+ const controllerRef = (data.metadata?.ownerReferences ?? []).find((ref: any) => ref?.controller)
42
+ const owner = coverage ? relationships?.owner : undefined
43
+ const clusterName: string | undefined = data.status?.clusterName
44
+ let body: ReactNode
45
+ if (owner) {
46
+ body = <div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
47
+ <span className="text-xs text-theme-text-tertiary">{owner.kind}</span>
48
+ <ResourceLink name={owner.name} namespace={owner.namespace} kind={kindToPluralWithGroup(owner.kind, owner.group ?? '')} group={owner.group} onNavigate={onNavigate} />
49
+ </div>
50
+ } else if (!relationships) {
51
+ body = <p className={muted}>Owner not read here.</p>
52
+ } else if (!coverage && controllerRef) {
53
+ body = <p className={muted}>Controller reference: {controllerRef.kind} {controllerRef.name} (this Radar doesn't resolve owners).</p>
54
+ } else if (coverage?.primaryOwnerState === 'several') {
55
+ body = <p className={muted}>Several owner references; none is the controller.</p>
56
+ } else {
57
+ const reason = coverage?.unavailableReason
58
+ const unanswered = !reason ? undefined
59
+ : reason === 'user_forbidden' ? "The owner isn't shown: your access lets you open this Workload, but not list its kind here."
60
+ : `The owner couldn't be read: ${gapReasonText(reason)}.`
61
+ const note = ownerNote(coverage)
62
+ body = <p className={muted}>{unanswered ?? note?.text ?? (data.metadata?.ownerReferences?.length ? 'Owner not resolved.' : 'No owner reference: this Workload was created directly.')}</p>
63
+ }
64
+ return <Step label="Owner">
65
+ {body}
66
+ {clusterName && <p className={muted}>Kueue reports cluster <code>{clusterName}</code> for this Workload (status.clusterName). Radar shows this cluster only and doesn't follow that name elsewhere.</p>}
67
+ </Step>
68
+ }
69
+
70
+ export function AdmissionPath({ data, reads, relationships, onNavigate }: { data: any; reads?: KueueWorkloadReads; relationships?: Relationships; onNavigate?: Navigate }) {
71
+ const namespace: string = data.metadata?.namespace ?? ''
72
+ const queueName: string | undefined = data.spec?.queueName
73
+ const localQueue = reads?.localQueue
74
+ const configured: string | undefined = localQueue?.state === 'found' ? localQueue.object?.spec?.clusterQueue : undefined
75
+ const reserved: string | undefined = data.status?.admission?.clusterQueue
76
+ const finished = (data.status?.conditions ?? []).some((condition: any) => condition?.type === 'Finished' && condition.status === 'True')
77
+ const admitted = (data.status?.conditions ?? []).some((condition: any) => condition?.type === 'Admitted' && condition.status === 'True')
78
+ const configuredRead = configured ? reads?.clusterQueues?.[configured] : undefined
79
+ const eligibility = configured && reads ? namespaceEligibilityFact({ clusterQueue: configured, parent: configuredRead, namespaceRead: reads.namespace }) : undefined
80
+ const assignments: any[] = Array.isArray(data.status?.admission?.podSetAssignments) ? data.status.admission.podSetAssignments : []
81
+ // Kueue's own word on why quota isn't reserved, verbatim. Flavor-assignment
82
+ // messages are left to "Reasons reported per flavor".
83
+ const quota = (data.status?.conditions ?? []).find((condition: any) => condition?.type === 'QuotaReserved')
84
+ const pendingMessage: string | undefined = quota?.status === 'False' && quota.message && !isFlavorAssignmentMessage(quota.message) ? quota.message : undefined
85
+ const pendingStale = isKueueConditionStale(quota, data.metadata?.generation)
86
+ return (
87
+ <Section variant="card" title="Admission Path" icon={Route}>
88
+ <ol className="divide-y divide-theme-border">
89
+ <OwnerStep data={data} relationships={relationships} onNavigate={onNavigate} />
90
+ <Step label="LocalQueue">
91
+ {!queueName ? <p className={muted}>No queue name: spec.queueName is empty, so the Workload names no LocalQueue.</p> : <>
92
+ <div className="flex flex-wrap items-center gap-2 break-all [&_button]:text-left">
93
+ <ResourceLink name={queueName} kind="localqueues" namespace={namespace} group={KUEUE_GROUP} onNavigate={onNavigate} />
94
+ <ReadState read={localQueue} found={localQueue?.state === 'found' ? <StatusPill status={localQueueStatusWithParent(localQueue.object, configuredRead)} /> : undefined} />
95
+ </div>
96
+ {localQueue?.state === 'not_found' && <p className={muted}>{reserved
97
+ ? `No LocalQueue ${queueName} exists in namespace ${namespace} now; the reservation below was made through it earlier.`
98
+ : finished || data.spec?.active === false
99
+ ? `No LocalQueue ${queueName} exists in namespace ${namespace}.`
100
+ : `No LocalQueue ${queueName} exists in namespace ${namespace}. Kueue can't admit this Workload until it does.`}</p>}
101
+ </>}
102
+ </Step>
103
+ {queueName && reads && (
104
+ <Step label="ClusterQueue">
105
+ {configured ? <>
106
+ <ClusterQueueReadSummary name={configured} read={configuredRead} onNavigate={onNavigate} />
107
+ <p className="text-xs text-theme-text-tertiary">Configured by LocalQueue {queueName}.</p>
108
+ {eligibility && !reserved && <div className="text-xs"><span className="text-theme-text-tertiary">Namespace {namespace}: </span><FactValue fact={eligibility} /><FactSource fact={eligibility} /></div>}
109
+ </> : <p className={muted}>
110
+ {localQueue?.state === 'found' ? 'The LocalQueue names no ClusterQueue.'
111
+ : localQueue?.state === 'not_found' ? `None: LocalQueue ${queueName} doesn't exist.`
112
+ : localQueue?.state === 'unconfirmed' ? `Unknown: this Radar can't confirm LocalQueue ${queueName} exists.`
113
+ : localQueue?.state === 'loading' ? 'Checking…' : 'Unknown until the LocalQueue is read.'}
114
+ </p>}
115
+ </Step>
116
+ )}
117
+ <Step label="Reservation">
118
+ {reserved ? <>
119
+ <p className="text-xs text-theme-text-tertiary">{finished ? 'Reservation recorded in (the Workload finished, so its quota was released)' : 'Quota reserved in'}</p>
120
+ {reads && reserved !== configured
121
+ ? <ClusterQueueReadSummary name={reserved} read={reads.clusterQueues?.[reserved]} onNavigate={onNavigate} />
122
+ : <div className="break-all [&_button]:text-left"><ResourceLink name={reserved} kind="clusterqueues" group={KUEUE_GROUP} onNavigate={onNavigate} /></div>}
123
+ {configured && configured !== reserved && (finished || admitted
124
+ ? <p className="text-xs text-theme-text-tertiary">Reserved in {reserved}; LocalQueue {queueName}, as it exists now, names {configured}.</p>
125
+ : <p className="text-xs text-warning-text">Reserved in {reserved}; LocalQueue {queueName}, as it exists now, names {configured}.</p>)}
126
+ </> : <>
127
+ <p className={muted}>No reservation reported.</p>
128
+ {pendingMessage && <p className="whitespace-pre-wrap break-words text-xs text-theme-text-secondary"><span className="text-theme-text-tertiary">{pendingStale ? `Controller-reported on QuotaReserved for generation ${quota.observedGeneration} (now ${data.metadata?.generation}): ` : 'Controller-reported on QuotaReserved: '}</span>{pendingMessage}</p>}
129
+ </>}
130
+ </Step>
131
+ {assignments.length > 0 && (
132
+ <Step label="Flavors">
133
+ <div className="space-y-2">
134
+ {assignments.map((assignment, index) => (
135
+ <div key={assignment?.name ?? index} className="space-y-0.5">
136
+ {assignments.length > 1 && <p className="text-xs font-medium text-theme-text-secondary">{assignment?.name ?? `PodSet ${index + 1}`}</p>}
137
+ {Object.entries(assignment?.flavors ?? {}).length === 0
138
+ ? <p className={muted}>No flavors reported.</p>
139
+ : Object.entries(assignment.flavors).map(([resource, flavor]) => (
140
+ <div key={resource} className="flex flex-wrap items-center gap-1.5 break-all text-xs [&_button]:text-left">
141
+ <span className="text-theme-text-secondary">{resource}</span>
142
+ <span className="text-theme-text-tertiary">→</span>
143
+ <ResourceLink name={String(flavor)} kind="resourceflavors" group={KUEUE_GROUP} onNavigate={onNavigate} />
144
+ {finished && reads?.flavors?.[String(flavor)]?.state === 'not_found'
145
+ ? <span className="text-xs text-theme-text-tertiary">No longer exists</span>
146
+ : reads?.flavors?.[String(flavor)]?.state !== 'found' && <ReadState read={reads?.flavors?.[String(flavor)]} />}
147
+ </div>
148
+ ))}
149
+ </div>
150
+ ))}
151
+ </div>
152
+ </Step>
153
+ )}
154
+ </ol>
155
+ </Section>
156
+ )
157
+ }
158
+
159
+ /** Reasons grouped under the flavor they name, in the order flavors first appear in the message. */
160
+ function groupByFlavor(attempts: FlavorAttempt[]): [string | undefined, FlavorAttempt[]][] {
161
+ const groups = new Map<string | undefined, FlavorAttempt[]>()
162
+ for (const attempt of attempts) groups.set(attempt.flavor, [...(groups.get(attempt.flavor) ?? []), attempt])
163
+ return [...groups]
164
+ }
165
+
166
+ const attemptLabel: Record<FlavorAttemptKind, string> = {
167
+ insufficient_quota: 'Over maximum capacity',
168
+ insufficient_unused_quota: 'Not enough unused quota',
169
+ untolerated_taint: 'Untolerated taint',
170
+ node_affinity: 'Node affinity mismatch',
171
+ flavor_not_found: 'Flavor not found',
172
+ resource_unavailable: 'Resource not in ClusterQueue',
173
+ skipped_by_annotation: 'Skipped by annotation',
174
+ pinned_by_original: 'Held by original Workload',
175
+ }
176
+
177
+ /**
178
+ * The reasons Kueue gave per flavor in the Workload's pending QuotaReserved
179
+ * message, parsed when every part is recognised and verbatim otherwise. It is
180
+ * one controller report, not an attempt history.
181
+ */
182
+ export function FlavorReasons({ data, onNavigate }: { data: any; onNavigate?: Navigate }) {
183
+ const conditions: any[] = data.status?.conditions ?? []
184
+ const quota = conditions.find(condition => condition?.type === 'QuotaReserved')
185
+ if (quota?.status !== 'False' || !isFlavorAssignmentMessage(quota.message)) return null
186
+ const stale = isKueueConditionStale(quota, data.metadata?.generation)
187
+ const parsed = parseFlavorAttempts(quota.message)
188
+ return (
189
+ <Section variant="card" title="Reasons Reported per Flavor">
190
+ {stale && <p className="mb-2 text-xs text-warning-text">Reported for generation {quota.observedGeneration} (now {data.metadata?.generation}); the Workload has changed since.</p>}
191
+ <p className="mb-3 text-xs text-theme-text-tertiary">From the controller-reported message on QuotaReserved. Kueue lists the reasons alphabetically, not in the order it tried flavors, and may not report every flavor.</p>
192
+ {parsed ? (
193
+ <div className="space-y-3">
194
+ {parsed.map(podSet => (
195
+ <div key={podSet.podSet} className="space-y-2">
196
+ <p className="text-xs font-medium text-theme-text-secondary">PodSet {podSet.podSet}</p>
197
+ <ul className="divide-y divide-theme-border">
198
+ {groupByFlavor(podSet.attempts).map(([flavor, attempts]) => (
199
+ <li key={flavor ?? ''} className="space-y-1 py-2 text-sm first:pt-0 last:pb-0">
200
+ <div className="break-all [&_button]:text-left">
201
+ {flavor ? <ResourceLink name={flavor} kind="resourceflavors" group={KUEUE_GROUP} onNavigate={onNavigate} /> : <span className="text-xs text-theme-text-tertiary">No flavor named</span>}
202
+ </div>
203
+ {attempts.map((attempt, index) => (
204
+ <div key={index} className="space-y-0.5 pl-3">
205
+ <div className="flex flex-wrap items-center gap-2 text-xs">
206
+ {attempt.resource && <code className="text-theme-text-secondary">{attempt.resource}</code>}
207
+ <span className="text-theme-text-tertiary">{attemptLabel[attempt.kind]}</span>
208
+ </div>
209
+ <p className="whitespace-pre-wrap break-words text-xs text-theme-text-secondary">{attempt.text}</p>
210
+ </div>
211
+ ))}
212
+ </li>
213
+ ))}
214
+ </ul>
215
+ </div>
216
+ ))}
217
+ </div>
218
+ ) : (
219
+ <p className="whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{quota.message}</p>
220
+ )}
221
+ </Section>
222
+ )
223
+ }