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