@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.
- package/package.json +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- 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/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- 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 +65 -0
- package/src/components/gitops/insights/insights-helpers.ts +17 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +15 -3
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +15 -0
- package/src/components/issues/types.ts +10 -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 +41 -0
- package/src/types/gitops-insights.ts +19 -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,100 @@
|
|
|
1
|
+
// Plain-language meanings for Kueue queue policy values, one dictionary per
|
|
2
|
+
// served API version. Wording follows the CRD field descriptions; a value a
|
|
3
|
+
// dictionary doesn't list is shown raw, never mapped to a nearby meaning.
|
|
4
|
+
|
|
5
|
+
export type KueuePolicyField =
|
|
6
|
+
| 'stopPolicy'
|
|
7
|
+
| 'queueingStrategy'
|
|
8
|
+
| 'preemption.withinClusterQueue'
|
|
9
|
+
| 'preemption.reclaimWithinCohort'
|
|
10
|
+
| 'preemption.borrowWithinCohort.policy'
|
|
11
|
+
| 'flavorFungibility.whenCanBorrow'
|
|
12
|
+
| 'flavorFungibility.whenCanPreempt'
|
|
13
|
+
| 'flavorFungibility.preference'
|
|
14
|
+
| 'admissionScope.admissionMode'
|
|
15
|
+
|
|
16
|
+
type Dictionary = Partial<Record<KueuePolicyField, Record<string, string>>>
|
|
17
|
+
|
|
18
|
+
const shared: Dictionary = {
|
|
19
|
+
stopPolicy: {
|
|
20
|
+
None: 'Not stopped by policy.',
|
|
21
|
+
Hold: 'Stopped: no new reservations; reservations are cancelled for Workloads not yet admitted, and admitted Workloads run to completion.',
|
|
22
|
+
HoldAndDrain: 'Stopped and draining: no new reservations; reservations are cancelled and admitted Workloads are evicted.',
|
|
23
|
+
},
|
|
24
|
+
queueingStrategy: {
|
|
25
|
+
StrictFIFO: 'By priority, then creation time; the Workload at the head that can’t be admitted blocks those behind it, even if they fit.',
|
|
26
|
+
BestEffortFIFO: 'By priority, then creation time; a Workload that can’t be admitted doesn’t block those behind it that fit.',
|
|
27
|
+
},
|
|
28
|
+
'preemption.withinClusterQueue': {
|
|
29
|
+
Never: 'A pending Workload never preempts Workloads in this ClusterQueue.',
|
|
30
|
+
LowerPriority: 'A pending Workload that doesn’t fit nominal quota may preempt lower-priority Workloads in this ClusterQueue.',
|
|
31
|
+
LowerOrNewerEqualPriority: 'A pending Workload may preempt Workloads in this ClusterQueue with lower priority, or equal priority and newer.',
|
|
32
|
+
},
|
|
33
|
+
'preemption.reclaimWithinCohort': {
|
|
34
|
+
Never: 'Never preempts Workloads of other ClusterQueues in the cohort.',
|
|
35
|
+
LowerPriority: 'May preempt lower-priority Workloads of cohort ClusterQueues that use more than their nominal quota.',
|
|
36
|
+
Any: 'May preempt Workloads of cohort ClusterQueues that use more than their nominal quota, regardless of priority.',
|
|
37
|
+
},
|
|
38
|
+
'preemption.borrowWithinCohort.policy': {
|
|
39
|
+
Never: 'A Workload that must borrow never preempts in other cohort ClusterQueues.',
|
|
40
|
+
LowerPriority: 'A Workload that must borrow may preempt lower-priority Workloads in other cohort ClusterQueues.',
|
|
41
|
+
},
|
|
42
|
+
'flavorFungibility.preference': {
|
|
43
|
+
BorrowingOverPreemption: 'When every flavor needs borrowing or preemption, prefer borrowing.',
|
|
44
|
+
PreemptionOverBorrowing: 'When every flavor needs borrowing or preemption, prefer preemption.',
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const admissionMode = {
|
|
49
|
+
UsageBasedAdmissionFairSharing: 'Admission fair sharing: Workloads are ordered by their LocalQueue’s recent resource usage.',
|
|
50
|
+
NoAdmissionFairSharing: 'Admission fair sharing is off for this ClusterQueue.',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const mayStopSearchBorrow = 'Stop at the first flavor where the Workload fits or fits by borrowing.'
|
|
54
|
+
const mayStopSearchPreempt = 'Stop at the first flavor where the Workload fits or fits by preempting.'
|
|
55
|
+
|
|
56
|
+
const v1beta1: Dictionary = {
|
|
57
|
+
...shared,
|
|
58
|
+
'admissionScope.admissionMode': admissionMode,
|
|
59
|
+
'flavorFungibility.whenCanBorrow': {
|
|
60
|
+
MayStopSearch: mayStopSearchBorrow,
|
|
61
|
+
TryNextFlavor: 'Try the next flavor before borrowing in this one.',
|
|
62
|
+
Borrow: `${mayStopSearchBorrow} (Equivalent to MayStopSearch in newer Kueue.)`,
|
|
63
|
+
},
|
|
64
|
+
'flavorFungibility.whenCanPreempt': {
|
|
65
|
+
MayStopSearch: mayStopSearchPreempt,
|
|
66
|
+
TryNextFlavor: 'Try the next flavor before preempting in this one.',
|
|
67
|
+
Preempt: `${mayStopSearchPreempt} (Equivalent to MayStopSearch in newer Kueue.)`,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const v1beta2: Dictionary = {
|
|
72
|
+
...shared,
|
|
73
|
+
'flavorFungibility.whenCanBorrow': {
|
|
74
|
+
MayStopSearch: mayStopSearchBorrow,
|
|
75
|
+
TryNextFlavor: 'Try the next flavor before borrowing in this one.',
|
|
76
|
+
},
|
|
77
|
+
'flavorFungibility.whenCanPreempt': {
|
|
78
|
+
MayStopSearch: mayStopSearchPreempt,
|
|
79
|
+
TryNextFlavor: 'Try the next flavor before preempting in this one.',
|
|
80
|
+
},
|
|
81
|
+
'admissionScope.admissionMode': admissionMode,
|
|
82
|
+
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const dictionaries: Record<string, Dictionary> = {
|
|
86
|
+
'kueue.x-k8s.io/v1beta1': v1beta1,
|
|
87
|
+
'kueue.x-k8s.io/v1beta2': v1beta2,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface KueuePolicyValue {
|
|
91
|
+
raw: string
|
|
92
|
+
/** Absent when this Radar has no meaning for the value at this API version. */
|
|
93
|
+
meaning?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function describeKueuePolicy(apiVersion: string | undefined, field: KueuePolicyField, value: unknown): KueuePolicyValue {
|
|
97
|
+
const raw = String(value)
|
|
98
|
+
const meaning = dictionaries[apiVersion ?? '']?.[field]?.[raw]
|
|
99
|
+
return meaning ? { raw, meaning } : { raw }
|
|
100
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ClusterQueueRenderer, LocalQueueRenderer } from '../resources/renderers/KueueQueueRenderers'
|
|
4
|
+
import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
|
|
5
|
+
import { localQueueInactiveBecauseParentStopped, localQueueStatusWithParent } from './queue-status'
|
|
6
|
+
import { getClusterQueueStatus, getLocalQueueStatus } from '../resources/resource-utils-kueue'
|
|
7
|
+
import { diagnoseHealthHint } from '../shared/ResourceRendererDispatch'
|
|
8
|
+
import type { RelatedRead } from './related-read'
|
|
9
|
+
|
|
10
|
+
const v = 'kueue.x-k8s.io/v1beta2'
|
|
11
|
+
const active = (status: string, reason: string, observedGeneration: number | undefined = 1, message = '') => ({ type: 'Active', status, reason, message, observedGeneration, lastTransitionTime: '2026-11-03T01:27:53Z' })
|
|
12
|
+
const clusterQueue = (name: string, condition: any, spec: any = {}, generation = 1) => ({ apiVersion: v, kind: 'ClusterQueue', metadata: { name, generation }, spec, status: { conditions: [condition] } })
|
|
13
|
+
const localQueue = (condition: any, generation = 1, parent = 'parent') => ({ apiVersion: v, kind: 'LocalQueue', metadata: { name: 'lq', namespace: 'team', generation }, spec: { clusterQueue: parent }, status: { conditions: [condition], pendingWorkloads: 1, reservingWorkloads: 0, admittedWorkloads: 0 } })
|
|
14
|
+
const found = (object: any): RelatedRead => ({ state: 'found', object })
|
|
15
|
+
|
|
16
|
+
// Prototype scenario 3 (live: gpu-night-813-team/stopped) and its look-alike (gpu-night-813-team/missing-flavor).
|
|
17
|
+
describe('parent-aware LocalQueue tone', () => {
|
|
18
|
+
const inactive = localQueue(active('False', 'ClusterQueueIsInactive', 1, "Can't submit new workloads to clusterQueue"))
|
|
19
|
+
const stoppedParent = found(clusterQueue('parent', active('False', 'Stopped', 1, "Can't admit new workloads: is stopped."), { stopPolicy: 'Hold' }))
|
|
20
|
+
const brokenParent = found(clusterQueue('parent', active('False', 'FlavorNotFound', 1, "Can't admit new workloads: references missing ResourceFlavor(s): x.")))
|
|
21
|
+
|
|
22
|
+
it('is neutral only when the ClusterQueue, read now, reports Stopped and both reports are current', () => {
|
|
23
|
+
expect(localQueueStatusWithParent(inactive, stoppedParent)).toMatchObject({ text: 'ClusterQueueIsInactive', level: 'neutral' })
|
|
24
|
+
expect(localQueueStatusWithParent(inactive, brokenParent).level).toBe('alert')
|
|
25
|
+
expect(localQueueStatusWithParent(inactive, undefined).level).toBe('alert')
|
|
26
|
+
expect(localQueueStatusWithParent(inactive, { state: 'unavailable', code: 'user_forbidden' }).level).toBe('alert')
|
|
27
|
+
expect(localQueueStatusWithParent(inactive, { state: 'not_found' }).level).toBe('alert')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps the alert tone when either report is stale or carries no generation', () => {
|
|
31
|
+
expect(localQueueInactiveBecauseParentStopped(localQueue(active('False', 'ClusterQueueIsInactive', 1), 2), stoppedParent)).toBe(false)
|
|
32
|
+
expect(localQueueInactiveBecauseParentStopped(localQueue({ type: 'Active', status: 'False', reason: 'ClusterQueueIsInactive' }), stoppedParent)).toBe(false)
|
|
33
|
+
expect(localQueueInactiveBecauseParentStopped(inactive, found(clusterQueue('parent', active('False', 'Stopped', 1), {}, 2)))).toBe(false)
|
|
34
|
+
expect(localQueueInactiveBecauseParentStopped(inactive, found(clusterQueue('other', active('False', 'Stopped', 1))))).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('shows the unknown tone, not alert, while the parent read that decides it is in flight', () => {
|
|
38
|
+
expect(localQueueStatusWithParent(inactive, { state: 'loading' }).level).toBe('unknown')
|
|
39
|
+
expect(localQueueStatusWithParent(localQueue(active('False', 'ClusterQueueDoesNotExist', 1)), { state: 'loading' }).level).toBe('alert')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps the alert when the stopped ClusterQueue also reports other causes', () => {
|
|
43
|
+
// Kueue reports only the first cause as the reason, and Stopped comes first.
|
|
44
|
+
const both = clusterQueue('parent', active('False', 'Stopped', 1, "Can't admit new workloads: is stopped, references missing ResourceFlavor(s): x."), { stopPolicy: 'Hold' })
|
|
45
|
+
expect(getClusterQueueStatus(both).level).toBe('alert')
|
|
46
|
+
expect(getClusterQueueStatus(clusterQueue('parent', active('False', 'Stopped', 1, "Can't admit new workloads: is stopped."))).level).toBe('neutral')
|
|
47
|
+
expect(localQueueStatusWithParent(inactive, found(both)).level).toBe('alert')
|
|
48
|
+
expect(getLocalQueueStatus(localQueue(active('False', 'Stopped', 1, 'LocalQueue is stopped'))).level).toBe('neutral')
|
|
49
|
+
// Wording this Radar doesn't recognise keeps the stop rather than inventing an alert.
|
|
50
|
+
expect(getClusterQueueStatus(clusterQueue('parent', active('False', 'Stopped', 1, 'ClusterQueue is stopped by policy'))).level).toBe('neutral')
|
|
51
|
+
// Lending the stop to a LocalQueue's tone needs Kueue's single-cause wording.
|
|
52
|
+
expect(localQueueStatusWithParent(inactive, found(clusterQueue('parent', active('False', 'Stopped', 1, 'ClusterQueue is stopped by policy'))))).toMatchObject({ level: 'alert' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('keeps the alert when the ClusterQueue can’t be read', () => {
|
|
56
|
+
expect(localQueueStatusWithParent(inactive, { state: 'unavailable', code: 'user_forbidden' }).level).toBe('alert')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps the Investigate hint on the LocalQueue’s own report, which can’t read the parent', () => {
|
|
60
|
+
// Quieting it would also hide a ClusterQueue that is broken, not just stopped.
|
|
61
|
+
expect(diagnoseHealthHint('localqueues', inactive)).toBe('problem')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('never hides the LocalQueue’s own faults behind a stopped parent', () => {
|
|
65
|
+
expect(localQueueStatusWithParent(localQueue(active('False', 'ClusterQueueDoesNotExist', 1)), stoppedParent).level).toBe('alert')
|
|
66
|
+
expect(localQueueStatusWithParent(localQueue(active('False', 'Stopped', 1)), stoppedParent).level).toBe('neutral')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('labels the parent fact as read from the ClusterQueue, and keeps the LocalQueue’s own report verbatim', () => {
|
|
70
|
+
const html = renderToStaticMarkup(<LocalQueueRenderer data={inactive} reads={{ clusterQueue: stoppedParent }} />)
|
|
71
|
+
expect(html).toContain('ClusterQueue parent reports Stopped (read from the ClusterQueue)')
|
|
72
|
+
expect(html).toContain('ClusterQueueIsInactive')
|
|
73
|
+
expect(html).toContain('Can't submit new workloads to clusterQueue')
|
|
74
|
+
expect(html).toContain('stop policy Hold')
|
|
75
|
+
const broken = renderToStaticMarkup(<LocalQueueRenderer data={inactive} reads={{ clusterQueue: brokenParent }} />)
|
|
76
|
+
expect(broken).toContain('Inspect the ClusterQueue')
|
|
77
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={inactive} reads={{ clusterQueue: found(clusterQueue('parent', active('True', 'Ready', 1))) }} />)).toContain('now reports Active; this LocalQueue’s report hasn’t caught up')
|
|
78
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={inactive} reads={{ clusterQueue: { state: 'loading' } }} />)).not.toContain('Inspect the ClusterQueue')
|
|
79
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={inactive} reads={{ clusterQueue: { state: 'not_found' } }} />)).not.toContain('Inspect the ClusterQueue')
|
|
80
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={inactive} reads={{ clusterQueue: found(clusterQueue('parent', active('True', 'Ready', 1), {}, 2)) }} />)).not.toContain('hasn’t caught up')
|
|
81
|
+
expect(broken).toContain('references missing ResourceFlavor(s): x.')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('LocalQueue parent and namespace eligibility', () => {
|
|
86
|
+
const lq = localQueue(active('True', 'Ready', 1))
|
|
87
|
+
const withSelector = (namespaceSelector: any) => found(clusterQueue('parent', active('True', 'Ready', 1), { namespaceSelector }))
|
|
88
|
+
|
|
89
|
+
it('reads null as no namespace and {} as all without reading the namespace', () => {
|
|
90
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={lq} reads={{ clusterQueue: withSelector(undefined) }} />)).toContain('sets no namespaceSelector, so no namespace matches')
|
|
91
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={lq} reads={{ clusterQueue: withSelector({}) }} />)).toContain('the selector is empty ({}), so every namespace matches')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('evaluates a selector against the namespace labels and says when they were not read', () => {
|
|
95
|
+
const selector = withSelector({ matchLabels: { team: 'a' } })
|
|
96
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={lq} reads={{ clusterQueue: selector, namespace: found({ metadata: { labels: { team: 'a' } } }) }} />)).toContain('labels match the selector')
|
|
97
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={lq} reads={{ clusterQueue: selector, namespace: found({ metadata: { labels: {} } }) }} />)).toContain('labels don’t match the selector')
|
|
98
|
+
expect(renderToStaticMarkup(<LocalQueueRenderer data={lq} reads={{ clusterQueue: selector, namespace: { state: 'unavailable', code: 'user_forbidden' } }} />)).toContain('its labels weren’t read (you can’t read it)')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('does not decide eligibility when the ClusterQueue itself was not read', () => {
|
|
102
|
+
const html = renderToStaticMarkup(<LocalQueueRenderer data={lq} reads={{ clusterQueue: { state: 'unavailable', code: 'user_forbidden' } }} />)
|
|
103
|
+
expect(html).toContain('Unknown: ClusterQueue not read (you can’t read it)')
|
|
104
|
+
const inactive = renderToStaticMarkup(<LocalQueueRenderer data={localQueue(active('False', 'ClusterQueueIsInactive', 1))} reads={{ clusterQueue: { state: 'unavailable', code: 'user_forbidden' } }} />)
|
|
105
|
+
expect(inactive).toContain('a cluster administrator can check it')
|
|
106
|
+
expect(inactive).not.toContain('Inspect the ClusterQueue')
|
|
107
|
+
expect(html).not.toContain('no namespace matches')
|
|
108
|
+
const missing = renderToStaticMarkup(<LocalQueueRenderer data={lq} reads={{ clusterQueue: { state: 'not_found' } }} />)
|
|
109
|
+
expect(missing).toContain('points at a ClusterQueue that doesn't exist')
|
|
110
|
+
expect(missing).toContain('Unknown: the ClusterQueue was not found')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('shows names without read state when the host reads nothing', () => {
|
|
114
|
+
const html = renderToStaticMarkup(<LocalQueueRenderer data={lq} />)
|
|
115
|
+
expect(html).toContain('parent')
|
|
116
|
+
expect(html).not.toContain('Not read')
|
|
117
|
+
expect(html).not.toContain('Eligible')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('ClusterQueue related objects', () => {
|
|
122
|
+
// Prototype scenario 4 (live: gpu-night-813-missing-flavor).
|
|
123
|
+
const cq = clusterQueue('cq', active('False', 'FlavorNotFound', 1, "Can't admit new workloads: references missing ResourceFlavor(s): gone."), {
|
|
124
|
+
cohortName: 'pool',
|
|
125
|
+
concurrentAdmissionPolicy: { migration: { mode: 'TryPreferredFlavors' } },
|
|
126
|
+
namespaceSelector: {},
|
|
127
|
+
resourceGroups: [{ coveredResources: ['cpu'], flavors: [{ name: 'small', resources: [] }, { name: 'gone', resources: [] }] }, { coveredResources: ['nvidia.com/gpu'], flavors: [{ name: 'a100', resources: [] }] }],
|
|
128
|
+
admissionChecksStrategy: { admissionChecks: [{ name: 'prov', onFlavors: ['a100'] }, { name: 'hidden' }] },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('lists flavors in configured preference order during concurrent admission, each with its read state', () => {
|
|
132
|
+
const html = renderToStaticMarkup(<ClusterQueueRenderer data={cq} reads={{
|
|
133
|
+
flavors: { small: found({}), gone: { state: 'not_found' }, a100: { state: 'unavailable', code: 'radar_kind_not_collected' } },
|
|
134
|
+
admissionChecks: { prov: found({ apiVersion: v, metadata: { generation: 1 }, status: { conditions: [active('False', 'BadParametersRef', 1, 'ProvisioningRequestConfig missing')] } }), hidden: { state: 'unavailable', code: 'user_forbidden' } },
|
|
135
|
+
cohortMembers: found(['other-cq']),
|
|
136
|
+
cohortMembersCompleteness: 'complete',
|
|
137
|
+
}} />)
|
|
138
|
+
expect(html).toContain('Resource flavors, in configured preference order')
|
|
139
|
+
expect(html).not.toContain('in the order tried')
|
|
140
|
+
expect(html.indexOf('>small<')).toBeLessThan(html.indexOf('>gone<'))
|
|
141
|
+
expect(html).toContain('Not found')
|
|
142
|
+
expect(html).toContain('Not read: Radar doesn’t collect it')
|
|
143
|
+
expect(html).toContain('BadParametersRef')
|
|
144
|
+
expect(html).toContain('ProvisioningRequestConfig missing')
|
|
145
|
+
expect(html).toContain('Not read: you can’t read it')
|
|
146
|
+
expect(html).toContain('Applies to flavors: a100')
|
|
147
|
+
expect(html).toContain('other-cq')
|
|
148
|
+
expect(html).toContain('references missing ResourceFlavor(s): gone.')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('marks a related object’s message from an earlier generation', () => {
|
|
152
|
+
const html = renderToStaticMarkup(<LocalQueueRenderer data={localQueue(active('False', 'ClusterQueueIsInactive', 1))} reads={{ clusterQueue: found(clusterQueue('parent', active('False', 'FlavorNotFound', 1, 'old cause'), {}, 3)) }} />)
|
|
153
|
+
expect(html).toContain('Reported for generation 1 (now 3): </span>old cause')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('says when the cohort member list may be incomplete or unread', () => {
|
|
157
|
+
expect(renderToStaticMarkup(<ClusterQueueRenderer data={cq} reads={{ cohortMembers: found([]), cohortMembersCompleteness: 'partial' }} />)).toContain('may be more')
|
|
158
|
+
expect(renderToStaticMarkup(<ClusterQueueRenderer data={cq} reads={{ cohortMembers: found([]), cohortMembersCompleteness: 'unknown' }} />)).toContain('doesn’t say whether its ClusterQueue list is complete')
|
|
159
|
+
expect(renderToStaticMarkup(<ClusterQueueRenderer data={cq} reads={{ flavors: { gone: { state: 'unconfirmed' } } }} />)).toContain('can’t confirm whether it exists')
|
|
160
|
+
expect(renderToStaticMarkup(<ClusterQueueRenderer data={cq} reads={{ cohortMembers: { state: 'unavailable', code: 'user_forbidden' } }} />)).toContain('Other members not read: you can’t read it.')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('shows reported and configured side by side with the stale marker', () => {
|
|
164
|
+
// Prototype scenario 5: observedGeneration behind generation.
|
|
165
|
+
const stale = { ...cq, metadata: { name: 'cq', generation: 2 } }
|
|
166
|
+
const html = renderToStaticMarkup(<ClusterQueueRenderer data={stale} />)
|
|
167
|
+
expect(html).toContain('Reported by controller')
|
|
168
|
+
expect(html).toContain('Configured')
|
|
169
|
+
expect(html).toContain('(stale)')
|
|
170
|
+
expect(html).toContain('observed generation 1 of 2')
|
|
171
|
+
expect(html).toContain('earlier generation')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('Workload admission path', () => {
|
|
176
|
+
const workload = (spec: any, status: any = {}, metadata: any = {}) => ({ apiVersion: v, kind: 'Workload', metadata: { name: 'job-x', namespace: 'team', generation: 1, ...metadata }, spec, status })
|
|
177
|
+
const owner = { relationships: { owner: { kind: 'Job', group: 'batch', namespace: 'team', name: 'x' }, ownership: { children: { complete: true } } } }
|
|
178
|
+
|
|
179
|
+
it('follows LocalQueue → configured ClusterQueue → reservation → flavors per PodSet (prototype scenario 1)', () => {
|
|
180
|
+
const data = workload({ queueName: 'lq', podSets: [{ name: 'driver', count: 1 }, { name: 'workers', count: 2 }] }, {
|
|
181
|
+
admission: { clusterQueue: 'cq', podSetAssignments: [{ name: 'driver', flavors: { cpu: 'small' } }, { name: 'workers', flavors: { cpu: 'small', memory: 'spot' } }] },
|
|
182
|
+
conditions: [{ type: 'Admitted', status: 'True', reason: 'Admitted', observedGeneration: 1 }],
|
|
183
|
+
})
|
|
184
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={data} relationships={owner.relationships as any} reads={{
|
|
185
|
+
localQueue: found(localQueue(active('True', 'Ready', 1), 1, 'cq')),
|
|
186
|
+
clusterQueues: { cq: found(clusterQueue('cq', active('True', 'Ready', 1), { namespaceSelector: {} })) },
|
|
187
|
+
}} />)
|
|
188
|
+
for (const text of ['Admission Path', '>x<', 'Configured by LocalQueue lq', 'Quota reserved in', 'workers', 'memory', 'spot']) expect(html).toContain(text)
|
|
189
|
+
// Eligibility explains a missing reservation; once reserved it is left out.
|
|
190
|
+
expect(html).not.toContain('every namespace matches')
|
|
191
|
+
expect(html).not.toContain('names cq.')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('names each stuck state explicitly', () => {
|
|
195
|
+
const none = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({})} reads={{}} />)
|
|
196
|
+
expect(none).toContain('No queue name')
|
|
197
|
+
expect(none).toContain('No reservation reported')
|
|
198
|
+
const lost = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'nope' })} reads={{ localQueue: { state: 'not_found' } }} />)
|
|
199
|
+
expect(lost).toContain('No LocalQueue nope exists in namespace team')
|
|
200
|
+
expect(lost).toContain('None: LocalQueue nope doesn't exist.')
|
|
201
|
+
expect(lost).not.toContain('Unknown until the LocalQueue is read')
|
|
202
|
+
const denied = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'lq' })} reads={{ localQueue: { state: 'unavailable', code: 'user_forbidden' } }} />)
|
|
203
|
+
expect(denied).toContain('Not read: you can’t read it')
|
|
204
|
+
expect(denied).not.toContain('No LocalQueue')
|
|
205
|
+
const moved = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'lq' }, { admission: { clusterQueue: 'old' } })} reads={{ localQueue: found(localQueue(active('True', 'Ready', 1), 1, 'new')), clusterQueues: {} }} />)
|
|
206
|
+
expect(moved).toContain('Reserved in old; LocalQueue lq, as it exists now, names new.')
|
|
207
|
+
const movedRead = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'lq' }, { admission: { clusterQueue: 'old' } })} reads={{ localQueue: found(localQueue(active('True', 'Ready', 1), 1, 'new')), clusterQueues: { new: found(clusterQueue('new', active('True', 'Ready', 1), {})), old: { state: 'not_found' } } }} />)
|
|
208
|
+
expect(movedRead).toContain('Not found')
|
|
209
|
+
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('tones the path’s LocalQueue by its stopped ClusterQueue like the LocalQueue detail does', () => {
|
|
213
|
+
const lq = localQueue(active('False', 'ClusterQueueIsInactive', 1), 1, 'cq')
|
|
214
|
+
const stopped = found(clusterQueue('cq', active('False', 'Stopped', 1, "Can't admit new workloads: is stopped."), { stopPolicy: 'Hold', namespaceSelector: {} }))
|
|
215
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'lq' })} reads={{ localQueue: found(lq), clusterQueues: { cq: stopped } }} />)
|
|
216
|
+
expect(html).toContain('ClusterQueueIsInactive')
|
|
217
|
+
expect(html).not.toContain('status-alert')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('shows the Workload’s own pending message when eligibility can’t be evaluated', () => {
|
|
221
|
+
const data = workload({ queueName: 'lq' }, { conditions: [{ type: 'QuotaReserved', status: 'False', reason: 'Pending', message: "Workload namespace doesn't match ClusterQueue selector", observedGeneration: 1 }] })
|
|
222
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={data} reads={{
|
|
223
|
+
localQueue: found(localQueue(active('True', 'Ready', 1), 1, 'cq')),
|
|
224
|
+
clusterQueues: { cq: found(clusterQueue('cq', active('True', 'Ready', 1), { namespaceSelector: { matchLabels: { team: 'a' } } })) },
|
|
225
|
+
namespace: { state: 'unavailable', code: 'user_forbidden' },
|
|
226
|
+
}} />)
|
|
227
|
+
expect(html).toContain('its labels weren’t read (you can’t read it)')
|
|
228
|
+
expect(html).toContain('Controller-reported on QuotaReserved: </span>Workload namespace doesn't match ClusterQueue selector')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('shows Kueue’s own pending message on the path unless it is the flavor-assignment message', () => {
|
|
232
|
+
const msg = (message: string) => workload({ queueName: 'lq' }, { conditions: [{ type: 'QuotaReserved', status: 'False', reason: 'Inadmissible', message, observedGeneration: 1 }] })
|
|
233
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={msg('ClusterQueue cq is inactive')} reads={{}} />)).toContain('Controller-reported on QuotaReserved: </span>ClusterQueue cq is inactive')
|
|
234
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={msg("couldn't assign flavors to pod set main: flavor a not found")} reads={{}} />)).not.toContain('Controller-reported on QuotaReserved')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('doesn’t claim a reserved Workload can’t be admitted when its LocalQueue was deleted later', () => {
|
|
238
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'gone' }, { admission: { clusterQueue: 'cq' } })} reads={{ localQueue: { state: 'not_found' }, clusterQueues: { cq: found(clusterQueue('cq', active('True', 'Ready', 1))) } }} />)
|
|
239
|
+
expect(html).toContain('the reservation below was made through it earlier')
|
|
240
|
+
expect(html).not.toContain('can't admit this Workload')
|
|
241
|
+
const finished = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'lq' }, { admission: { clusterQueue: 'cq', podSetAssignments: [{ name: 'main', flavors: { cpu: 'old-flavor' } }] }, conditions: [{ type: 'Finished', status: 'True', reason: 'Succeeded', observedGeneration: 1 }] })} reads={{ flavors: { 'old-flavor': { state: 'not_found' } } }} />)
|
|
242
|
+
expect(finished).toContain('the Workload finished, so its quota was released')
|
|
243
|
+
expect(finished).toContain('No longer exists')
|
|
244
|
+
const unconfirmed = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'lq' })} reads={{ localQueue: { state: 'unconfirmed' } }} />)
|
|
245
|
+
expect(unconfirmed).toContain('can't confirm LocalQueue lq exists')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('keeps the reserved ClusterQueue’s read state when the LocalQueue can’t be read', () => {
|
|
249
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={workload({ queueName: 'lq' }, { admission: { clusterQueue: 'cq' } })} reads={{ localQueue: { state: 'unavailable', code: 'user_forbidden' }, clusterQueues: { cq: { state: 'unavailable', code: 'user_forbidden' } } }} />)
|
|
250
|
+
expect(html).toContain('Unknown until the LocalQueue is read')
|
|
251
|
+
expect(html.match(/Not read: you can’t read it/g)?.length).toBe(2)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('links no owner from a Radar without ownership answers, naming the controller reference instead', () => {
|
|
255
|
+
const owned = workload({}, {}, { ownerReferences: [{ kind: 'Job', name: 'x', controller: true, apiVersion: 'batch/v1', uid: 'u' }] })
|
|
256
|
+
for (const owner of [{ kind: 'Job', group: 'batch', namespace: 'team', name: 'x' }, { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'app' }]) {
|
|
257
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={owned} relationships={{ owner } as any} />)
|
|
258
|
+
expect(html).not.toContain(`>${owner.name}</`)
|
|
259
|
+
expect(html).toContain('Controller reference: Job x')
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('takes the owner from the ownership answer and keeps its unanswered states', () => {
|
|
264
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={workload({})} />)).toContain('Owner not read here.')
|
|
265
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={workload({})} relationships={{ ownership: { primaryOwnerState: 'gone', ownerRef: { kind: 'Job', name: 'x' }, children: { complete: true } } } as any} />)).toContain('Job x no longer exists.')
|
|
266
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={workload({})} relationships={{ ownership: { primaryOwnerState: 'withheld', children: { complete: true } } } as any} />)).toContain('Not visible to you.')
|
|
267
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={workload({})} relationships={{ ownership: { primaryOwnerState: 'several', children: { complete: true } } } as any} />)).toContain('Several owner references')
|
|
268
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={workload({})} relationships={{ ownership: { unavailableReason: 'user_forbidden', children: { complete: true } } } as any} />)).toContain('your access lets you open this Workload')
|
|
269
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={workload({}, { clusterName: 'worker-1' })} relationships={{}} />)).toContain('doesn't follow that name elsewhere')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('reasons reported per flavor', () => {
|
|
274
|
+
const pending = (message: string, observedGeneration = 1) => ({ apiVersion: v, kind: 'Workload', metadata: { name: 'w', namespace: 'team', generation: 1 }, spec: { queueName: 'busy' }, status: { conditions: [{ type: 'QuotaReserved', status: 'False', reason: 'Pending', message, observedGeneration }] } })
|
|
275
|
+
const live = "couldn't assign flavors to pod set main: insufficient quota for cpu in flavor gpu-night-813-small, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1), insufficient quota for cpu in flavor gpu-night-813-spot, previously considered podsets requests (0) + current podset request (3) > maximum capacity (1)"
|
|
276
|
+
|
|
277
|
+
it('lists the parsed reasons with the alphabetical-order caveat and no attempt age or causal sentence', () => {
|
|
278
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={pending(live)} />)
|
|
279
|
+
expect(html).toContain('Reasons Reported per Flavor')
|
|
280
|
+
expect(html).toContain('not in the order it tried flavors')
|
|
281
|
+
expect(html).toContain('gpu-night-813-spot')
|
|
282
|
+
expect(html).toContain('Over maximum capacity')
|
|
283
|
+
expect(html).not.toContain('accounting now')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('groups several resources under the flavor they name', () => {
|
|
287
|
+
const html = renderToStaticMarkup(<KueueWorkloadRenderer data={pending("couldn't assign flavors to pod set main: insufficient unused quota for cpu in flavor a, 1 more needed, insufficient unused quota for nvidia.com/gpu in flavor a, 2 more needed")} />)
|
|
288
|
+
expect(html.match(/>a</g)?.length).toBe(1)
|
|
289
|
+
expect(html).toContain('nvidia.com/gpu')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('falls back to the verbatim message, marks a stale one, and skips unrelated messages', () => {
|
|
293
|
+
const odd = "couldn't assign flavors to pod set main: topology \"rack\" doesn't allow to fit any of 3 pod(s)"
|
|
294
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={pending(odd)} />)).toContain('topology "rack" doesn't allow to fit any of 3 pod(s)')
|
|
295
|
+
// Kueue's error form isn't a per-flavor account: it stays on the path, verbatim.
|
|
296
|
+
const failed = renderToStaticMarkup(<KueueWorkloadRenderer data={pending('failed to assign flavors to pod set main: boom')} reads={{}} />)
|
|
297
|
+
expect(failed).not.toContain('Reasons Reported per Flavor')
|
|
298
|
+
expect(failed).toContain('Controller-reported on QuotaReserved: </span>failed to assign flavors to pod set main: boom')
|
|
299
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={{ ...pending(live), metadata: { name: 'w', namespace: 'team', generation: 2 } }} />)).toContain('Reported for generation 1 (now 2)')
|
|
300
|
+
expect(renderToStaticMarkup(<KueueWorkloadRenderer data={pending("LocalQueue no-such-localqueue doesn't exist")} />)).not.toContain('Reasons Reported per Flavor')
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
for (const [kind, Renderer, after] of [
|
|
305
|
+
['LocalQueue', LocalQueueRenderer, 'Conditions'],
|
|
306
|
+
['ClusterQueue', ClusterQueueRenderer, 'Admission Policy'],
|
|
307
|
+
] as const) {
|
|
308
|
+
it(`renders the ${kind} host Workloads slot after quota and before ${after}`, () => {
|
|
309
|
+
const data: any = kind === 'LocalQueue'
|
|
310
|
+
? localQueue(active('True', 'Ready'))
|
|
311
|
+
: clusterQueue('parent', active('True', 'Ready'))
|
|
312
|
+
data.status = { ...data.status, flavorsReservation: [{ name: 'default', resources: [{ name: 'cpu', total: '1' }] }] }
|
|
313
|
+
const html = renderToStaticMarkup(<Renderer data={data} workloads={<section>Host Workloads</section>} />)
|
|
314
|
+
expect(html.indexOf('Host Workloads')).toBeGreaterThan(html.indexOf(kind === 'LocalQueue' ? 'Quota Accounting' : 'Quota by Flavor'))
|
|
315
|
+
expect(html.indexOf('Host Workloads')).toBeLessThan(html.indexOf(after))
|
|
316
|
+
})
|
|
317
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { healthColors, type StatusBadge } from '../resources/resource-utils'
|
|
2
|
+
import { getKueueQueueCondition, getLocalQueueStatus, isKueueClusterQueueStopOnlyMessage } from '../resources/resource-utils-kueue'
|
|
3
|
+
import type { RelatedRead } from './related-read'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The condition describes the object's current generation, as the object
|
|
7
|
+
* itself records it. Stricter than isKueueConditionStale, which treats a
|
|
8
|
+
* missing observedGeneration as not stale: a tone that hides an alert needs
|
|
9
|
+
* positive evidence, not the absence of contrary evidence.
|
|
10
|
+
*/
|
|
11
|
+
function conditionIsCurrent(condition: { observedGeneration?: number } | undefined, generation: number | undefined): boolean {
|
|
12
|
+
return !!condition?.observedGeneration && !!generation && condition.observedGeneration === generation
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The LocalQueue reports ClusterQueueIsInactive and its ClusterQueue, read
|
|
17
|
+
* now, reports it was stopped on purpose. Both reports must be current for
|
|
18
|
+
* their own generation; anything less leaves the LocalQueue's own tone.
|
|
19
|
+
*/
|
|
20
|
+
export function localQueueInactiveBecauseParentStopped(localQueue: any, parent: RelatedRead | undefined): boolean {
|
|
21
|
+
const own = getKueueQueueCondition(localQueue)
|
|
22
|
+
if (own?.status !== 'False' || own.reason !== 'ClusterQueueIsInactive') return false
|
|
23
|
+
if (!conditionIsCurrent(own, localQueue?.metadata?.generation)) return false
|
|
24
|
+
if (parent?.state !== 'found' || parent.object?.metadata?.name !== localQueue?.spec?.clusterQueue) return false
|
|
25
|
+
const theirs = getKueueQueueCondition(parent.object)
|
|
26
|
+
return isKueueClusterQueueStopOnlyMessage(theirs) && conditionIsCurrent(theirs, parent.object?.metadata?.generation)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The LocalQueue's reported state. Its text is always the LocalQueue's own
|
|
31
|
+
* report; only the tone follows the parent, and only when the parent's stop
|
|
32
|
+
* explains it.
|
|
33
|
+
*/
|
|
34
|
+
export function localQueueStatusWithParent(localQueue: any, parent: RelatedRead | undefined): StatusBadge {
|
|
35
|
+
const own = getLocalQueueStatus(localQueue)
|
|
36
|
+
if (localQueueInactiveBecauseParentStopped(localQueue, parent)) return { ...own, color: healthColors.neutral, level: 'neutral' }
|
|
37
|
+
// While the parent read that decides the tone is in flight, don't flash the alert tone first.
|
|
38
|
+
if (parent?.state === 'loading' && own.level === 'alert' && getKueueQueueCondition(localQueue)?.reason === 'ClusterQueueIsInactive') {
|
|
39
|
+
return { ...own, color: healthColors.unknown, level: 'unknown' }
|
|
40
|
+
}
|
|
41
|
+
return own
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { canonicalReadCode, readActionFor } from '../../utils/read-actions'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What Radar's read of one related object found, as the viewer. `not_found`
|
|
5
|
+
* comes only from a read that could prove absence; a denial, a kind still
|
|
6
|
+
* loading or any other failure is `unavailable` with its read code. A host
|
|
7
|
+
* that doesn't read related objects passes no RelatedRead at all, and the
|
|
8
|
+
* renderer shows names without read state.
|
|
9
|
+
*/
|
|
10
|
+
export type RelatedRead<T = any> =
|
|
11
|
+
| { state: 'loading' }
|
|
12
|
+
| { state: 'found'; object: T }
|
|
13
|
+
| { state: 'not_found' }
|
|
14
|
+
/** Not seen, and the read can't vouch for absence (an older Radar's list without coverage, or its uncoded 404). */
|
|
15
|
+
| { state: 'unconfirmed' }
|
|
16
|
+
| { state: 'unavailable'; code?: string; message?: string }
|
|
17
|
+
|
|
18
|
+
export function isDenied(read: RelatedRead | undefined): boolean {
|
|
19
|
+
return read?.state === 'unavailable' && readActionFor(read.code) === 'user_access'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Why a related object couldn't be read, in a few words that fit "<name>: …". */
|
|
23
|
+
export function unreadReason(read: RelatedRead | undefined): string {
|
|
24
|
+
if (!read || read.state === 'loading') return 'checking…'
|
|
25
|
+
if (read.state === 'unconfirmed') return 'this Radar can’t confirm whether it exists'
|
|
26
|
+
if (read.state !== 'unavailable') return ''
|
|
27
|
+
if (isDenied(read)) return 'you can’t read it'
|
|
28
|
+
switch (canonicalReadCode(read.code)) {
|
|
29
|
+
case 'kind_sync_pending':
|
|
30
|
+
case 'cluster_connecting':
|
|
31
|
+
return 'Radar is still loading it'
|
|
32
|
+
case 'kind_sync_timed_out':
|
|
33
|
+
return 'Radar’s copy is overdue'
|
|
34
|
+
case 'radar_forbidden':
|
|
35
|
+
case 'radar_credentials_rejected':
|
|
36
|
+
return 'Radar can’t read it'
|
|
37
|
+
case 'radar_kind_not_collected':
|
|
38
|
+
case 'radar_namespace_not_collected':
|
|
39
|
+
return 'Radar doesn’t collect it'
|
|
40
|
+
case 'connection_changed':
|
|
41
|
+
case 'cluster_disconnected':
|
|
42
|
+
return 'Radar’s connection changed'
|
|
43
|
+
case 'kind_unknown':
|
|
44
|
+
return 'Radar’s API discovery doesn’t list its kind'
|
|
45
|
+
default:
|
|
46
|
+
return 'the read failed'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enums": {
|
|
3
|
+
"kueue.x-k8s.io/v1beta1": {
|
|
4
|
+
"admissionScope.admissionMode": [
|
|
5
|
+
"NoAdmissionFairSharing",
|
|
6
|
+
"UsageBasedAdmissionFairSharing"
|
|
7
|
+
],
|
|
8
|
+
"flavorFungibility.preference": [
|
|
9
|
+
"BorrowingOverPreemption",
|
|
10
|
+
"PreemptionOverBorrowing"
|
|
11
|
+
],
|
|
12
|
+
"flavorFungibility.whenCanBorrow": [
|
|
13
|
+
"Borrow",
|
|
14
|
+
"MayStopSearch",
|
|
15
|
+
"TryNextFlavor"
|
|
16
|
+
],
|
|
17
|
+
"flavorFungibility.whenCanPreempt": [
|
|
18
|
+
"MayStopSearch",
|
|
19
|
+
"Preempt",
|
|
20
|
+
"TryNextFlavor"
|
|
21
|
+
],
|
|
22
|
+
"preemption.borrowWithinCohort.policy": [
|
|
23
|
+
"LowerPriority",
|
|
24
|
+
"Never"
|
|
25
|
+
],
|
|
26
|
+
"preemption.reclaimWithinCohort": [
|
|
27
|
+
"Any",
|
|
28
|
+
"LowerPriority",
|
|
29
|
+
"Never"
|
|
30
|
+
],
|
|
31
|
+
"preemption.withinClusterQueue": [
|
|
32
|
+
"LowerOrNewerEqualPriority",
|
|
33
|
+
"LowerPriority",
|
|
34
|
+
"Never"
|
|
35
|
+
],
|
|
36
|
+
"queueingStrategy": [
|
|
37
|
+
"BestEffortFIFO",
|
|
38
|
+
"StrictFIFO"
|
|
39
|
+
],
|
|
40
|
+
"stopPolicy": [
|
|
41
|
+
"Hold",
|
|
42
|
+
"HoldAndDrain",
|
|
43
|
+
"None"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"kueue.x-k8s.io/v1beta2": {
|
|
47
|
+
"admissionScope.admissionMode": [
|
|
48
|
+
"NoAdmissionFairSharing",
|
|
49
|
+
"UsageBasedAdmissionFairSharing"
|
|
50
|
+
],
|
|
51
|
+
"flavorFungibility.preference": [
|
|
52
|
+
"BorrowingOverPreemption",
|
|
53
|
+
"PreemptionOverBorrowing"
|
|
54
|
+
],
|
|
55
|
+
"flavorFungibility.whenCanBorrow": [
|
|
56
|
+
"MayStopSearch",
|
|
57
|
+
"TryNextFlavor"
|
|
58
|
+
],
|
|
59
|
+
"flavorFungibility.whenCanPreempt": [
|
|
60
|
+
"MayStopSearch",
|
|
61
|
+
"TryNextFlavor"
|
|
62
|
+
],
|
|
63
|
+
"preemption.borrowWithinCohort.policy": [
|
|
64
|
+
"LowerPriority",
|
|
65
|
+
"Never"
|
|
66
|
+
],
|
|
67
|
+
"preemption.reclaimWithinCohort": [
|
|
68
|
+
"Any",
|
|
69
|
+
"LowerPriority",
|
|
70
|
+
"Never"
|
|
71
|
+
],
|
|
72
|
+
"preemption.withinClusterQueue": [
|
|
73
|
+
"LowerOrNewerEqualPriority",
|
|
74
|
+
"LowerPriority",
|
|
75
|
+
"Never"
|
|
76
|
+
],
|
|
77
|
+
"queueingStrategy": [
|
|
78
|
+
"BestEffortFIFO",
|
|
79
|
+
"StrictFIFO"
|
|
80
|
+
],
|
|
81
|
+
"stopPolicy": [
|
|
82
|
+
"Hold",
|
|
83
|
+
"HoldAndDrain",
|
|
84
|
+
"None"
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"source": "Kueue v0.18.0 CRDs served on radar-test-nonprod, 2026-11-03 (enum values; v1beta1 admissionMode from its field description)"
|
|
89
|
+
}
|
|
@@ -74,3 +74,25 @@ it('filters expanded strings, numbers and booleans by their displayed value', ()
|
|
|
74
74
|
act(() => root.unmount())
|
|
75
75
|
}
|
|
76
76
|
})
|
|
77
|
+
|
|
78
|
+
describe('StructuredLogLine without summary fields', () => {
|
|
79
|
+
it('shows the line itself instead of only a field count', () => {
|
|
80
|
+
const line = '2026-11-04T10:25:18Z INFO northstar.train step=4280 loss=1.73 lr=0.00015 tokens_per_sec=48200'
|
|
81
|
+
const html = renderToStaticMarkup(<StructuredLogLine content={line} level="info" wordWrap={false} isLogfmt />)
|
|
82
|
+
expect(html).toContain('step=4280 loss=1.73')
|
|
83
|
+
expect(html).toContain('northstar.train')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('treats a blank message as no summary', () => {
|
|
87
|
+
const html = render(JSON.stringify({ level: 'info', msg: '', step: 4280 }))
|
|
88
|
+
expect(html).toContain('4280')
|
|
89
|
+
const logfmt = renderToStaticMarkup(<StructuredLogLine content={'level=info msg="" step=4280 loss=1.73'} level="info" wordWrap={false} isLogfmt />)
|
|
90
|
+
expect(logfmt).toContain('step=4280 loss=1.73')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('keeps msg-only summaries free of the raw line', () => {
|
|
94
|
+
const html = render(JSON.stringify({ level: 'info', msg: 'ready', extra: 'hidden-detail' }))
|
|
95
|
+
expect(html).toContain('ready')
|
|
96
|
+
expect(html).not.toContain('hidden-detail')
|
|
97
|
+
})
|
|
98
|
+
})
|