@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.
- package/package.json +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +30 -12
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +37 -0
- package/src/components/gitops/insights/insights-helpers.ts +12 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/severity.ts +14 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +40 -0
- package/src/types/gitops-insights.ts +11 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- 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("'", '''))
|
|
26
|
+
expect(html).toContain(`>${issue.action!.replaceAll("'", ''')}</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>
|
|
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
|
|
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
|
+
}
|