@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
@@ -47,9 +47,11 @@ export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
47
47
  return normalizeHealthStatus(change.health)
48
48
  }
49
49
 
50
- export function missingResourceMessage(sync?: string): string {
51
- return sync === 'Synced'
52
- ? 'Argo reports this resource as synced, but it is not present in the cluster. Refresh the application to check its status.'
50
+ export function missingResourceMessage(sync?: string, context?: { hazard?: string; rolledBack?: boolean }): string {
51
+ if (sync === 'Synced') return 'Argo reports this resource as synced, but it is not present in the cluster. Refresh the application to check its status.'
52
+ if (context?.hazard) return 'Not present in the cluster. See the sync warning above before syncing this Application.'
53
+ return context?.rolledBack
54
+ ? 'Declared in Git but not present in the cluster after a rollback. Sync can recreate it and undo the rollback.'
53
55
  : 'Not present in the cluster. Sync the application to create it.'
54
56
  }
55
57
 
@@ -158,6 +160,18 @@ export function entryTone(item: GitOpsHistoryItem): EntryTone {
158
160
  return { dot: SEVERITY_DOT.neutral, inferredFrom: 'no phase information' }
159
161
  }
160
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
+
161
175
  // Compact a source string for inline display. Argo emits the full GitHub URL
162
176
  // followed by " · path/within/repo", which dominates the timeline row when
163
177
  // rendered raw. Strip the protocol+host (full string still shown on hover via
@@ -0,0 +1,190 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { GitOpsIssuesBand } from '../gitops/insights/GitOpsInsightViews'
6
+ import { IssueRow } from './IssuesView'
7
+ import { ResourceIssuesSection } from './ResourceIssuesSection'
8
+ import type { GitOpsIssue } from '../../types/gitops-insights'
9
+ import type { Issue } from './types'
10
+
11
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
12
+
13
+ const primary = 'The render includes none of the resources this Application manages. A sync with Prune enabled could delete all of them. Prune=false blocks direct Argo pruning; Prune=confirm requires confirmation. Pruning a rendered Namespace deletes its contents, regardless of their Prune options. Check the source path, targetRevision, Helm values, and Kustomize or directory include/exclude settings first.'
14
+ const secondary = [
15
+ 'If this Application should go away: remove it from the ApplicationSet generator if generated; while the generator still emits it, the ApplicationSet recreates it. Check whether the generated Application survives; its ApplicationSet sync policy may keep it. If it survives, delete it explicitly with the intended cascade choice. For a plain Application, delete the Application. Cleanup follows the cascade/resources finalizer choice and Delete=false / Delete=confirm, not Prune options.',
16
+ 'If it should render empty: set syncPolicy.automated.allowEmpty: true in the Application or its ApplicationSet template. This turns off the empty-render guard for all future syncs.',
17
+ ]
18
+ const title = 'If removing all managed resources is intended'
19
+ const cause = 'Argo CD blocked auto-sync because the render includes none of the resources this Application manages.'
20
+ const guard: GitOpsIssue = {
21
+ severity: 'critical', scope: 'condition', reason: 'AutoSyncBlockedEmpty',
22
+ message: 'Auto-sync blocked: the render includes none of the managed resources', cause,
23
+ action: [primary, ...secondary].join(' '), actionPrimary: primary,
24
+ actionSecondaryTitle: title, actionSecondary: secondary,
25
+ }
26
+ const issue: Issue = {
27
+ id: 'guard', severity: 'critical', source: 'problem',
28
+ category: 'gitops_sync_failed', category_group: 'gitops', grouping_scope: 'unknown',
29
+ kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'empty',
30
+ reason: guard.reason, message: guard.message, cause,
31
+ action: guard.action, action_primary: primary,
32
+ action_secondary_title: title, action_secondary: secondary,
33
+ }
34
+
35
+ let root: Root
36
+ let element: HTMLDivElement
37
+ beforeEach(() => {
38
+ element = document.createElement('div')
39
+ document.body.appendChild(element)
40
+ root = createRoot(element)
41
+ })
42
+ afterEach(async () => {
43
+ await act(async () => root.unmount())
44
+ element.remove()
45
+ })
46
+
47
+ function disclosure() {
48
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(title))!
49
+ expect(button).toBeDefined()
50
+ return { button, panel: document.getElementById(button.getAttribute('aria-controls')!)! }
51
+ }
52
+
53
+ function checkPrimaryVisible() {
54
+ for (const text of [primary, cause]) {
55
+ const paragraph = [...element.querySelectorAll('p')].find((p) => p.textContent === text)!
56
+ expect(paragraph).toBeDefined()
57
+ expect(paragraph.closest('[inert]')).toBeNull()
58
+ }
59
+ expect(element.querySelector('button button')).toBeNull()
60
+ }
61
+
62
+ async function checkDisclosure() {
63
+ const { button, panel } = disclosure()
64
+ expect(button.getAttribute('aria-expanded')).toBe('false')
65
+ expect(panel.style.gridTemplateRows).toBe('0fr')
66
+ expect(panel.querySelector('[inert]')).not.toBeNull()
67
+ checkPrimaryVisible()
68
+ await act(async () => button.click())
69
+ expect(button.getAttribute('aria-expanded')).toBe('true')
70
+ expect(panel.style.gridTemplateRows).toBe('1fr')
71
+ expect(panel.querySelector('[inert]')).toBeNull()
72
+ for (const paragraph of secondary) expect(panel.textContent).toContain(paragraph)
73
+ await act(async () => button.click())
74
+ expect(button.getAttribute('aria-expanded')).toBe('false')
75
+ expect(panel.querySelector('[inert]')).not.toBeNull()
76
+ }
77
+
78
+ describe('empty-render guidance', () => {
79
+ it('shows primary guidance on the detail band and opens both intended-render paths', async () => {
80
+ await act(async () => root.render(<GitOpsIssuesBand issues={[guard]} />))
81
+ await checkDisclosure()
82
+ })
83
+
84
+ it('keeps the headline guidance independent of the collapsed detail stack', async () => {
85
+ const drift: GitOpsIssue = { severity: 'warning', scope: 'condition', reason: 'OtherWarning', message: 'Another condition' }
86
+ await act(async () => root.render(<GitOpsIssuesBand issues={[guard, drift]} />))
87
+ await checkDisclosure()
88
+ const stack = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes('AutoSyncBlockedEmpty'))!
89
+ expect(stack.getAttribute('aria-expanded')).toBe('false')
90
+ })
91
+
92
+ it('opens guidance on a lower detail row after expanding the stack', async () => {
93
+ const first: GitOpsIssue = { severity: 'critical', scope: 'condition', reason: 'ComparisonError', message: 'Cannot render' }
94
+ await act(async () => root.render(<GitOpsIssuesBand issues={[first, guard]} />))
95
+ const stack = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes('ComparisonError'))!
96
+ await act(async () => stack.click())
97
+ await checkDisclosure()
98
+ })
99
+
100
+ it.each([false, true])('shows split guidance on the cluster Issues row (open=%s)', async (open) => {
101
+ await act(async () => root.render(<IssueRow issue={issue} open={open} onToggle={() => {}} />))
102
+ await checkDisclosure()
103
+ })
104
+
105
+ it('shows primary guidance and disclosure in the resource drawer without expanding its issue', async () => {
106
+ await act(async () => root.render(<ResourceIssuesSection issues={[issue]} />))
107
+ await checkDisclosure()
108
+ })
109
+
110
+ it('shows complete action when presentation fields are absent', async () => {
111
+ const { actionPrimary: _primary, actionSecondaryTitle: _title, actionSecondary: _secondary, ...plain } = guard
112
+ await act(async () => root.render(<GitOpsIssuesBand issues={[plain]} />))
113
+ expect(element.textContent).toContain(guard.action)
114
+ expect(element.querySelectorAll('button[aria-controls]')).toHaveLength(0)
115
+ })
116
+ })
117
+
118
+ const moveSteps = [
119
+ "Check this Application's sync options for FailOnSharedResource=true. If set, the sync will refuse the takeover. Stop unless taking it over is intended and allowed.",
120
+ "Note the resource's live UID.",
121
+ 'Sync this Application.',
122
+ ]
123
+ const movePrimary = "To leave it with Application payments, remove it from this Application's source."
124
+ const moveTitle = 'To move it to this Application'
125
+ const move: GitOpsIssue = {
126
+ severity: 'warning', scope: 'condition', reason: 'SharedResourceWarning', message: 'Also tracked by payments',
127
+ action: `${movePrimary} ${moveTitle}: ${moveSteps.join(' ')}`, actionPrimary: movePrimary,
128
+ actionSecondaryTitle: moveTitle, actionSecondary: moveSteps,
129
+ }
130
+
131
+ describe('shared-resource move guidance', () => {
132
+ it.each(['detail', 'cluster'] as const)('renders numbered steps on the %s surface', async (surface) => {
133
+ const row: Issue = { ...issue, reason: move.reason, message: move.message, action: move.action,
134
+ action_primary: movePrimary, action_secondary_title: moveTitle, action_secondary: moveSteps }
135
+ await act(async () => root.render(surface === 'detail'
136
+ ? <GitOpsIssuesBand issues={[move]} />
137
+ : <IssueRow issue={row} open={true} onToggle={() => {}} />))
138
+ const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(moveTitle))!
139
+ expect(button).toBeDefined()
140
+ await act(async () => button.click())
141
+ const panel = document.getElementById(button.getAttribute('aria-controls')!)!
142
+ expect([...panel.querySelectorAll('ol > li')].map((li) => li.textContent)).toEqual(moveSteps)
143
+ expect(panel.querySelector('[inert]')).toBeNull()
144
+ expect(element.textContent).toContain(movePrimary)
145
+ })
146
+ })
147
+
148
+ describe('cluster sync hazards', () => {
149
+ const hazard = 'Resolve shared ownership before syncing: another Application can take tracking back.'
150
+ it.each([undefined, { nextStep: 'Host next step', cause: 'Host cause' }])('keeps a warning ahead of any next-step source (%j)', async (diagnosisSource) => {
151
+ await act(async () => root.render(<IssueRow
152
+ issue={{ ...issue, action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined, hazard }}
153
+ open={true} onToggle={() => {}} diagnosisSource={diagnosisSource} />))
154
+ const warning = [...element.querySelectorAll('p')].find((p) => p.textContent === hazard)!
155
+ expect(warning).toBeDefined()
156
+ expect(warning.closest('.text-warning-text')).not.toBeNull()
157
+ expect(warning.closest('[inert]')).toBeNull()
158
+ expect(element.textContent!.indexOf(hazard)).toBeLessThan(element.textContent!.indexOf(diagnosisSource?.nextStep ?? primary))
159
+ if (diagnosisSource) expect(element.textContent).toContain(diagnosisSource.nextStep)
160
+ })
161
+
162
+ it('shows a hazard on a collapsed row with no split guidance or action', async () => {
163
+ await act(async () => root.render(<IssueRow
164
+ issue={{ ...issue, action: undefined, action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined, hazard }}
165
+ open={false} onToggle={() => {}} diagnosisSource={{ nextStep: 'Host next step' }} />))
166
+ const warning = [...element.querySelectorAll('p')].find((p) => p.textContent === hazard)!
167
+ expect(warning).toBeDefined()
168
+ expect(warning.closest('[inert]')).toBeNull()
169
+ expect(warning.closest('.text-warning-text')).not.toBeNull()
170
+ })
171
+ })
172
+
173
+ describe('hazard text retained only once in presentation', () => {
174
+ it.each([false, true])('keeps empty-render guidance and one warning (open=%s)', async (open) => {
175
+ await act(async () => root.render(<IssueRow issue={{ ...issue, hazard: primary }} open={open} onToggle={() => {}} />))
176
+ expect([...element.querySelectorAll('p')].filter((p) => p.textContent === primary)).toHaveLength(1)
177
+ await checkDisclosure()
178
+ })
179
+
180
+ it.each(['hazard only', 'appended hazard'])('keeps one warning for %s actions', async (mode) => {
181
+ const hazard = 'Sync can take over shared tracking.'
182
+ const nextStep = "Open the application's sync operation details."
183
+ const action = mode === 'hazard only' ? hazard : `${nextStep} ${hazard}`
184
+ await act(async () => root.render(<IssueRow issue={{ ...issue, hazard, action,
185
+ action_primary: undefined, action_secondary: undefined, action_secondary_title: undefined }} open={true} onToggle={() => {}} />))
186
+ expect(element.textContent!.split(hazard)).toHaveLength(2)
187
+ if (mode === 'hazard only') expect(element.textContent).not.toContain('Next step')
188
+ else expect(element.textContent).toContain(nextStep)
189
+ })
190
+ })
@@ -0,0 +1,48 @@
1
+ import { AlertTriangle } from 'lucide-react'
2
+ import { Disclosure } from '../ui/Disclosure'
3
+
4
+ export function issueGuidanceSummary(action?: string, primary?: string, hazard?: string) {
5
+ const summary = primary || action
6
+ if (!hazard || !summary) return summary
7
+ if (summary === hazard) return undefined
8
+ return summary.endsWith(` ${hazard}`) ? summary.slice(0, -hazard.length - 1) : summary
9
+ }
10
+
11
+ export function IssueGuidance({ action, primary, secondaryTitle, secondary, ordered, hazard, className }: {
12
+ action?: string
13
+ /** Replaces a supplied action; primary alone renders no action text. */
14
+ primary?: string
15
+ /** Paired with secondary; no disclosure is shown without both. */
16
+ secondaryTitle?: string
17
+ /** Paired with secondaryTitle; no disclosure is shown without both. */
18
+ secondary?: string[]
19
+ ordered?: boolean
20
+ hazard?: string
21
+ className?: string
22
+ }) {
23
+ if (!action && !hazard && !(secondaryTitle && secondary?.length)) return null
24
+ return (
25
+ <div className={className}>
26
+ {hazard && (
27
+ <div className="mb-2 flex items-start gap-2 text-warning-text">
28
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
29
+ <p>{hazard}</p>
30
+ </div>
31
+ )}
32
+ {action && <p>{primary || action}</p>}
33
+ {secondaryTitle && secondary && (
34
+ <Disclosure summary={secondaryTitle} className="mt-2" summaryClassName="font-medium">
35
+ {ordered ? (
36
+ <ol className="list-decimal space-y-2 pl-5 pt-2">
37
+ {secondary.map((step) => <li key={step}>{step}</li>)}
38
+ </ol>
39
+ ) : (
40
+ <div className="space-y-2 pt-2">
41
+ {secondary.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
42
+ </div>
43
+ )}
44
+ </Disclosure>
45
+ )}
46
+ </div>
47
+ )
48
+ }
@@ -1,7 +1,8 @@
1
+ import { IssueGuidance, issueGuidanceSummary } from './IssueGuidance'
1
2
  import { summarizeSchedulerMessage } from '../resources/resource-utils'
2
3
  import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
3
4
  import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, Eraser, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
4
- import { CardBody, CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
5
+ import { CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
5
6
  import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
6
7
  import { Tooltip } from '../ui/Tooltip';
7
8
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
@@ -368,6 +369,15 @@ export function IssueRow({
368
369
  </div>
369
370
  </div>
370
371
 
372
+ {(issue.action_primary || issue.hazard) && (
373
+ <Collapse open={!open} unmountOnExit>
374
+ <div className="px-4 pb-3 pl-6 text-xs">
375
+ {issue.cause && <p className="mb-1 text-theme-text-primary">{issue.cause}</p>}
376
+ <IssueGuidance hazard={issue.hazard} action={issue.action_primary ? issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard) : undefined} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-theme-text-tertiary" />
377
+ </div>
378
+ </Collapse>
379
+ )}
380
+
371
381
  {/* Details unmount after the close transition: a list can hold hundreds
372
382
  of rows and the body is the expensive part, so closed rows stay light. */}
373
383
  <Collapse open={open} unmountOnExit id={panelId}>
@@ -509,11 +519,13 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
509
519
  </>
510
520
  ) : undefined;
511
521
 
522
+ const guidanceSummary = issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard);
512
523
  const hasNextStep =
513
- !!issue.action || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
524
+ !!guidanceSummary || !!issue.action_secondary?.length || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
514
525
 
515
526
  return (
516
527
  <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
528
+ <IssueGuidance hazard={issue.hazard} className="text-sm leading-relaxed" />
517
529
  {/* When a parsed cause replaces the detector text, the terse reason code
518
530
  (CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
519
531
  identifier operators anchor on, and the collapsed header drops it while
@@ -547,7 +559,7 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
547
559
  tone="fix"
548
560
  labelExtra={nextStepAttribution ?? undefined}
549
561
  >
550
- {sourceNextStep ?? (issue.action ? <CardBody>{issue.action}</CardBody> : null)}
562
+ {sourceNextStep ?? <IssueGuidance action={guidanceSummary} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-sm leading-relaxed text-theme-text-secondary" />}
551
563
  {/* Survives an override: this is a structured one-click fix with an
552
564
  apply path, not advice competing with the host's. */}
553
565
  {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
@@ -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';
@@ -15,6 +15,7 @@ export {
15
15
  memberRef,
16
16
  issueSortAnchor,
17
17
  compareIssueSortAnchors,
18
+ compareIssues,
18
19
  } from './types';
19
20
  export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField, IssueRunningPreviousValue, IssueWithheld } from './types';
20
21
  export {
@@ -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',
@@ -100,6 +104,7 @@ const CATEGORY_LABEL: Record<string, string> = {
100
104
  crossplane_reconcile_failed: 'Crossplane reconcile failed',
101
105
  termination_stuck: 'Stuck terminating',
102
106
  operator_condition_failed: 'Controller reports a problem',
107
+ gitops_resource_warning: 'GitOps resource warning',
103
108
  gitops_sync_failed: 'GitOps sync failed',
104
109
  gitops_render_failed: 'GitOps render failed',
105
110
  gitops_spec_invalid: 'GitOps spec invalid',
@@ -153,6 +158,16 @@ const REASON_TITLE: Record<string, string> = {
153
158
  CNPGInstanceReadinessMismatch: "Instance Pods contradict Cluster readiness",
154
159
  CNPGPrimaryLabelMismatch: "Primary Pod label contradicts Cluster status",
155
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",
156
171
  };
157
172
 
158
173
  /** The title a reason has wherever it is shown, when its category's label would name the wrong operation. */
@@ -231,6 +231,16 @@ export interface Issue {
231
231
  * Server-emitted (omitempty); empty for issues without a parser. */
232
232
  cause?: string;
233
233
  action?: string;
234
+ /** What the suggested or most obvious action could destroy or take over,
235
+ * such as a GitOps sync that would prune every managed resource. Show it
236
+ * even when replacing cause or action with other guidance; action also
237
+ * contains it. Absence means no warning was supplied, not that the action
238
+ * is safe; older producers omit it. */
239
+ hazard?: string;
240
+ /** Optional presentation split; action always retains the complete guidance. */
241
+ action_primary?: string;
242
+ action_secondary_title?: string;
243
+ action_secondary?: string[];
234
244
  remediation_kind?: string;
235
245
  remediation_target?: string;
236
246
  /** A cleanup that is right only when its condition holds — never part of