@skyhook-io/k8s-ui 1.14.9 → 1.14.11
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 +3 -2
- package/src/components/applications/ApplicationDetail.tsx +24 -11
- package/src/components/audit/AuditAlerts.tsx +1 -3
- package/src/components/audit/AuditCoveragePresentation.test.tsx +48 -2
- package/src/components/checks/ChecksView.test.tsx +55 -0
- package/src/components/checks/ChecksView.tsx +38 -27
- package/src/components/dock/NodeTerminalTab.test.tsx +89 -0
- package/src/components/dock/NodeTerminalTab.tsx +51 -47
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +17 -9
- package/src/components/gitops/detail-helpers.test.ts +32 -0
- package/src/components/gitops/detail-helpers.ts +21 -0
- package/src/components/gitops/health-provenance.test.ts +4 -3
- package/src/components/gitops/index.ts +2 -1
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +131 -54
- package/src/components/issues/IssuesView.tsx +6 -0
- package/src/components/issues/ResourceIssuesSection.test.tsx +36 -0
- package/src/components/issues/diagnostic.ts +4 -0
- package/src/components/logs/LogCore.tsx +3 -3
- package/src/components/logs/WorkloadLogsViewer.tsx +41 -11
- package/src/components/logs/useLogBuffer.ts +1 -0
- package/src/components/logs/useLogStream.ts +1 -2
- package/src/components/resources/KueueAdmissionSection.test.tsx +95 -0
- package/src/components/resources/KueueAdmissionSection.tsx +87 -0
- package/src/components/resources/ResourcesView.default-sort.test.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +91 -16
- package/src/components/resources/curated-column-ownership.test.ts +45 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +4 -3
- package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +10 -3
- package/src/components/resources/renderers/JobSetRenderer.test.tsx +280 -0
- package/src/components/resources/renderers/JobSetRenderer.tsx +322 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.test.tsx +67 -0
- package/src/components/resources/renderers/KueueProvisioningRenderers.tsx +91 -0
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +69 -0
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +123 -0
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +299 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +121 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +230 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +93 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +62 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +114 -0
- package/src/components/resources/renderers/index.ts +11 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +103 -2
- package/src/components/resources/resource-utils-jobset-lws.ts +43 -9
- package/src/components/resources/resource-utils-kueue.ts +145 -50
- package/src/components/resources/resource-utils-ray.test.ts +58 -1
- package/src/components/resources/resource-utils-ray.ts +56 -90
- package/src/components/shared/ResourceRendererDispatch.test.tsx +117 -2
- package/src/components/shared/ResourceRendererDispatch.tsx +47 -10
- package/src/components/timeline/TimelineList.tsx +5 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +15 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +43 -23
- package/src/components/topology/K8sResourceNode.tsx +3 -3
- package/src/components/ui/CodeViewer.tsx +4 -0
- package/src/components/ui/Toast.tsx +9 -3
- package/src/components/ui/drawer-components.tsx +3 -1
- package/src/components/workload/PodList.tsx +154 -0
- package/src/components/workload/WorkloadView.tsx +29 -159
- package/src/components/workload/index.ts +11 -1
- package/src/components/workload/workload-logs-availability.test.ts +20 -0
- package/src/components/workload/workload-pod-presentation.test.ts +27 -0
- package/src/types/core.ts +24 -0
- package/src/types/gitops-tree.ts +5 -2
- package/src/types/scheduling.ts +71 -0
- package/src/utils/api-resources.test.ts +20 -1
- package/src/utils/api-resources.ts +26 -0
- package/src/utils/application-topology.test.ts +25 -0
- package/src/utils/application-topology.ts +2 -1
- package/src/utils/applications.test.ts +82 -0
- package/src/utils/applications.ts +62 -24
- package/src/utils/gitops-route.test.ts +20 -1
- package/src/utils/gitops-route.ts +15 -7
- package/src/utils/log-format.ts +5 -2
- package/src/utils/log-stream-error.test.ts +13 -0
- package/src/utils/navigation.test.ts +2 -0
- package/src/utils/navigation.ts +1 -1
- package/src/utils/resource-hierarchy.test.ts +402 -15
- package/src/utils/resource-hierarchy.ts +263 -178
- package/src/utils/topology-neighborhood.test.ts +72 -1
- package/src/utils/topology-neighborhood.ts +40 -18
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Activity, GitBranch } from 'lucide-react'
|
|
3
|
+
import type { ResourceRef } from '../../../types'
|
|
4
|
+
import { Badge, type BadgeSeverity } from '../../ui/Badge'
|
|
5
|
+
import { AlertBanner, ConditionsSection, type ConditionTone, Property, PropertyList, ResourceLink, Section } from '../../ui/drawer-components'
|
|
6
|
+
import { isRayObservationStale, isRayServiceConditionStale } from '../resource-utils-ray'
|
|
7
|
+
|
|
8
|
+
export interface RayServiceRendererProps {
|
|
9
|
+
data: any
|
|
10
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
11
|
+
runtimeEvidence?: Partial<Record<'active' | 'pending', ReactNode>>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const visibleEntries = 20
|
|
15
|
+
|
|
16
|
+
export function serveStateSeverity(state?: string): BadgeSeverity {
|
|
17
|
+
if (state === 'RUNNING' || state === 'HEALTHY') return 'success'
|
|
18
|
+
if (state === 'DEPLOY_FAILED' || state === 'UNHEALTHY') return 'error'
|
|
19
|
+
if (['DEPLOYING', 'UPDATING', 'UPSCALING', 'DOWNSCALING', 'DELETING'].includes(state ?? '')) return 'warning'
|
|
20
|
+
return 'neutral'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function orderedStatuses(statuses: Record<string, any> = {}) {
|
|
24
|
+
const rank = (status?: string) => {
|
|
25
|
+
const severity = serveStateSeverity(status)
|
|
26
|
+
return severity === 'error' ? 0 : severity === 'neutral' ? 1 : severity === 'warning' ? 2 : 3
|
|
27
|
+
}
|
|
28
|
+
const entryRank = (entry: any) => Math.min(rank(entry.status), ...Object.values(entry.serveDeploymentStatuses ?? {}).map((deployment: any) => rank(deployment.status)))
|
|
29
|
+
return Object.entries(statuses).sort(([a, av], [b, bv]) => entryRank(av) - entryRank(bv) || a.localeCompare(b))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rayServiceConditionTone(condition: any): ConditionTone {
|
|
33
|
+
if (!['True', 'False'].includes(condition.status)) return 'unknown'
|
|
34
|
+
if (condition.type === 'Ready') return condition.status === 'True' ? 'ok' : 'warning'
|
|
35
|
+
if (['UpgradeInProgress', 'RollbackInProgress', 'Suspending'].includes(condition.type)) return condition.status === 'True' ? 'warning' : 'unknown'
|
|
36
|
+
return 'unknown'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function NativeState({ name, data }: { name: string; data: any }) {
|
|
40
|
+
return <>
|
|
41
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
|
42
|
+
<span className="break-words text-sm font-medium text-theme-text-primary">{name}</span>
|
|
43
|
+
<Badge severity={serveStateSeverity(data.status)} size="sm">{data.status || 'Not reported'}</Badge>
|
|
44
|
+
</div>
|
|
45
|
+
{data.message && <p className="mt-1 whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{data.message}</p>}
|
|
46
|
+
</>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ServeApplications({ slot }: { slot: any }) {
|
|
50
|
+
const apps = orderedStatuses(slot.applicationStatuses)
|
|
51
|
+
return <div className="mt-3 space-y-3">
|
|
52
|
+
<p className="text-xs text-theme-text-tertiary">Application states are snapshots reported by KubeRay, not a live Serve dashboard query.</p>
|
|
53
|
+
{apps.length === 0 ? <p className="text-sm text-theme-text-secondary">Application status is not reported for this revision. During a NewCluster upgrade, the active application map can be cleared while its endpoint still serves requests.</p>
|
|
54
|
+
: apps.slice(0, visibleEntries).map(([name, app]) => {
|
|
55
|
+
const deployments = orderedStatuses(app.serveDeploymentStatuses)
|
|
56
|
+
return <div className="card-inner min-w-0" key={name}>
|
|
57
|
+
<NativeState name={name} data={app} />
|
|
58
|
+
<div className="mt-3 space-y-2 border-t border-theme-border pt-2">
|
|
59
|
+
<p className="text-xs text-theme-text-tertiary">Serve deployments{deployments.length > 0 ? ` (${deployments.length})` : ''}</p>
|
|
60
|
+
{deployments.slice(0, visibleEntries).map(([deployment, state]) => <div className="min-w-0" key={deployment}><NativeState name={deployment} data={state} /></div>)}
|
|
61
|
+
{deployments.length === 0 && <p className="text-sm text-theme-text-secondary">Deployment status is not reported.</p>}
|
|
62
|
+
{deployments.length > visibleEntries && <p className="text-xs text-theme-text-tertiary">Showing {visibleEntries} of {deployments.length} deployments, failures first. See YAML for the full list.</p>}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
})}
|
|
66
|
+
{apps.length > visibleEntries && <p className="text-xs text-theme-text-tertiary">Showing {visibleEntries} of {apps.length} applications, failures first. See YAML for the full list.</p>}
|
|
67
|
+
</div>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function RayServiceRenderer({ data, onNavigate, runtimeEvidence }: RayServiceRendererProps) {
|
|
71
|
+
const status = data.status ?? {}
|
|
72
|
+
const conditions = status.conditions ?? []
|
|
73
|
+
const generation = data.metadata?.generation
|
|
74
|
+
const suspended = data.spec?.suspend === true && conditions.some((condition: any) => condition.type === 'Suspended' && condition.status === 'True')
|
|
75
|
+
const behind = isRayObservationStale(generation, status.observedGeneration)
|
|
76
|
+
const ready = conditions.find((condition: any) => condition.type === 'Ready')
|
|
77
|
+
const readyStale = isRayServiceConditionStale(data, ready)
|
|
78
|
+
const readyLabel = ready?.status === 'True' ? 'Ready' : ready?.status === 'False' ? 'Not ready' : ready ? 'Unknown' : 'Not reported'
|
|
79
|
+
const slots = (['active', 'pending'] as const).filter(slot => status[`${slot}ServiceStatus`]?.rayClusterName)
|
|
80
|
+
return <>
|
|
81
|
+
{behind && !suspended && <AlertBanner variant="info" title="Observed generation differs" message={`Specification generation ${generation}; controller reports observed generation ${status.observedGeneration}. KubeRay can retain this value during suspension or terminal failure; conditions below preserve its reported state.`} />}
|
|
82
|
+
{behind && suspended && <p className="mb-3 text-sm text-theme-text-secondary">Reconciliation is paused while suspended; the controller may retain an earlier observed generation.</p>}
|
|
83
|
+
<Section title="Serving and Rollout" icon={Activity} defaultExpanded>
|
|
84
|
+
<PropertyList>
|
|
85
|
+
<Property label="Proxy Readiness" value={<Badge severity={readyStale ? 'neutral' : ready?.status === 'True' ? 'success' : ready?.status === 'False' ? 'warning' : 'neutral'}>{readyLabel}{readyStale ? ' (stale)' : ''}</Badge>} />
|
|
86
|
+
{ready?.reason && <Property label="Readiness Reason" value={ready.reason} />}
|
|
87
|
+
{status.numServeEndpoints != null && <Property label="Reported Serve Endpoints" value={status.numServeEndpoints} />}
|
|
88
|
+
<Property label="Suspension Requested" value={data.spec?.suspend === true ? 'Yes' : 'No'} />
|
|
89
|
+
<Property label="Declared Upgrade Strategy" value={data.spec?.upgradeStrategy?.type || 'Not specified'} />
|
|
90
|
+
{conditions.filter((condition: any) => ['UpgradeInProgress', 'RollbackInProgress', 'Suspending', 'Suspended'].includes(condition.type) && condition.status === 'True').map((condition: any) => <Property key={condition.type} label={condition.type} value={<Badge severity={isRayServiceConditionStale(data, condition) ? 'neutral' : condition.type === 'Suspended' ? 'info' : 'warning'}>{condition.reason || 'True'}{isRayServiceConditionStale(data, condition) ? ' (stale)' : ''}</Badge>} />)}
|
|
91
|
+
</PropertyList>
|
|
92
|
+
{ready?.message && <p className="mt-2 whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{ready.message}</p>}
|
|
93
|
+
<p className="mt-2 text-xs text-theme-text-tertiary">Ready means Serve proxy endpoints are reported, not that every application or revision is healthy. Endpoint counts span revisions. Suspension is complete only when the controller reports Suspended.</p>
|
|
94
|
+
{status.observedGeneration == null && <p className="mt-2 text-xs text-theme-text-tertiary">The controller has not reported an observed generation.</p>}
|
|
95
|
+
</Section>
|
|
96
|
+
{slots.length === 0 && <Section title="Runtime Revisions" icon={GitBranch} defaultExpanded><p className="text-sm text-theme-text-secondary">No named runtime revision is reported.</p></Section>}
|
|
97
|
+
{slots.map(role => {
|
|
98
|
+
const slot = status[`${role}ServiceStatus`]
|
|
99
|
+
return <Section key={role} title={`${role === 'active' ? 'Active' : 'Pending'} Revision`} icon={GitBranch} defaultExpanded>
|
|
100
|
+
<div className="min-w-0 break-all [&_button]:text-left"><ResourceLink kind="rayclusters" group="ray.io" namespace={data.metadata?.namespace} name={slot.rayClusterName} onNavigate={onNavigate} /></div>
|
|
101
|
+
{runtimeEvidence?.[role] ?? <p className="mt-2 text-sm text-theme-text-secondary">Open the RayCluster for its directly observed runtime status.</p>}
|
|
102
|
+
{(slot.targetCapacity != null || slot.trafficRoutedPercent != null) && <div className="mt-3">
|
|
103
|
+
<PropertyList>
|
|
104
|
+
{slot.targetCapacity != null && <Property label="Serve Target Capacity" value={`${slot.targetCapacity}%`} />}
|
|
105
|
+
{slot.trafficRoutedPercent != null && <Property label="Configured Traffic Share" value={`${slot.trafficRoutedPercent}%`} />}
|
|
106
|
+
</PropertyList>
|
|
107
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">Target capacity scales Serve replica targets. Traffic share is configured route weight, not measured requests.</p>
|
|
108
|
+
</div>}
|
|
109
|
+
<ServeApplications slot={slot} />
|
|
110
|
+
</Section>
|
|
111
|
+
})}
|
|
112
|
+
<ConditionsSection conditions={conditions} getConditionTone={condition => isRayServiceConditionStale(data, condition) ? 'unknown' : rayServiceConditionTone(condition)} defaultExpanded />
|
|
113
|
+
</>
|
|
114
|
+
}
|
|
@@ -55,6 +55,7 @@ export * from './IstioPeerAuthenticationRenderer'
|
|
|
55
55
|
export * from './IstioServiceEntryRenderer'
|
|
56
56
|
export * from './IstioVirtualServiceRenderer'
|
|
57
57
|
export * from './JobRenderer'
|
|
58
|
+
export * from './JobSetRenderer'
|
|
58
59
|
export * from './karpenter-cells'
|
|
59
60
|
export * from './KarpenterEC2NodeClassRenderer'
|
|
60
61
|
export * from './KarpenterNodeClaimRenderer'
|
|
@@ -179,3 +180,13 @@ export * from './KyvernoGlobalContextRenderer'
|
|
|
179
180
|
export * from './KyvernoQueueRenderers'
|
|
180
181
|
export * from './CNPGObjectStoreRenderer'
|
|
181
182
|
export * from './CNPGDeclarativeRenderer'
|
|
183
|
+
|
|
184
|
+
export { KueueWorkloadRenderer } from './KueueWorkloadRenderer'
|
|
185
|
+
|
|
186
|
+
export { LocalQueueRenderer, ClusterQueueRenderer } from './KueueQueueRenderers'
|
|
187
|
+
|
|
188
|
+
export { AdmissionCheckRenderer, ProvisioningRequestRenderer } from './KueueProvisioningRenderers'
|
|
189
|
+
|
|
190
|
+
export { RayServiceRenderer } from './RayServiceRenderer'
|
|
191
|
+
|
|
192
|
+
export { RayClusterRenderer } from './RayClusterRenderer'
|
|
@@ -74,13 +74,15 @@ describe('GPU ecosystem API contracts', () => {
|
|
|
74
74
|
expect(getRayClusterStatus({
|
|
75
75
|
status: {
|
|
76
76
|
conditions: [{ type: 'HeadPodReady', status: 'True' }],
|
|
77
|
+
readyWorkerReplicas: 1,
|
|
77
78
|
availableWorkerReplicas: 1,
|
|
78
79
|
desiredWorkerReplicas: 2,
|
|
79
80
|
},
|
|
80
|
-
}).text).toBe('Workers 1/2')
|
|
81
|
+
}).text).toBe('Workers 1/2 ready')
|
|
81
82
|
expect(getRayClusterStatus({
|
|
82
83
|
status: {
|
|
83
84
|
conditions: [{ type: 'HeadPodReady', status: 'True' }],
|
|
85
|
+
readyWorkerReplicas: 2,
|
|
84
86
|
availableWorkerReplicas: 2,
|
|
85
87
|
desiredWorkerReplicas: 2,
|
|
86
88
|
},
|
|
@@ -139,7 +141,87 @@ describe('GPU ecosystem API contracts', () => {
|
|
|
139
141
|
|
|
140
142
|
it('distinguishes pending JobSets from active work', () => {
|
|
141
143
|
expect(getJobSetStatus({ status: { replicatedJobsStatus: [{ active: 0, ready: 0 }] } }).text).toBe('Pending')
|
|
142
|
-
expect(getJobSetStatus({ status: { replicatedJobsStatus: [{ active: 1, ready: 0 }] } }).text).toBe('
|
|
144
|
+
expect(getJobSetStatus({ status: { replicatedJobsStatus: [{ active: 1, ready: 0 }] } }).text).toBe('Active')
|
|
145
|
+
expect(getJobSetStatus({}).text).toBe('Unknown')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('uses the same JobSet state precedence as execution context', () => {
|
|
149
|
+
const liveCounts = { replicatedJobsStatus: [{ active: 1, ready: 1 }] }
|
|
150
|
+
|
|
151
|
+
expect(getJobSetStatus({
|
|
152
|
+
spec: { suspend: true },
|
|
153
|
+
status: {
|
|
154
|
+
...liveCounts,
|
|
155
|
+
terminalState: 'Failed',
|
|
156
|
+
conditions: [{ type: 'Completed', status: 'True' }],
|
|
157
|
+
},
|
|
158
|
+
}).text).toBe('Failed')
|
|
159
|
+
expect(getJobSetStatus({
|
|
160
|
+
status: {
|
|
161
|
+
...liveCounts,
|
|
162
|
+
terminalState: 'Completed',
|
|
163
|
+
conditions: [{ type: 'Failed', status: 'True', reason: 'ReachedMaxRestarts' }],
|
|
164
|
+
},
|
|
165
|
+
}).text).toBe('Completed')
|
|
166
|
+
expect(getJobSetStatus({
|
|
167
|
+
status: {
|
|
168
|
+
...liveCounts,
|
|
169
|
+
conditions: [
|
|
170
|
+
{ type: 'Failed', status: 'True', reason: 'ReachedMaxRestarts' },
|
|
171
|
+
{ type: 'Completed', status: 'True' },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
}).text).toBe('ReachedMaxRestarts')
|
|
175
|
+
expect(getJobSetStatus({
|
|
176
|
+
status: {
|
|
177
|
+
...liveCounts,
|
|
178
|
+
conditions: [
|
|
179
|
+
{ type: 'Completed', status: 'True' },
|
|
180
|
+
{ type: 'Suspended', status: 'True' },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
}).text).toBe('Completed')
|
|
184
|
+
expect(getJobSetStatus({
|
|
185
|
+
status: {
|
|
186
|
+
...liveCounts,
|
|
187
|
+
conditions: [
|
|
188
|
+
{ type: 'Suspended', status: 'True' },
|
|
189
|
+
{ type: 'RestartingJobSet', status: 'True' },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
}).text).toBe('Suspended')
|
|
193
|
+
expect(getJobSetStatus({
|
|
194
|
+
spec: { suspend: true },
|
|
195
|
+
status: {
|
|
196
|
+
...liveCounts,
|
|
197
|
+
conditions: [{ type: 'RestartingJobSet', status: 'True' }],
|
|
198
|
+
},
|
|
199
|
+
}).text).toBe('Restarting')
|
|
200
|
+
expect(getJobSetStatus({
|
|
201
|
+
status: {
|
|
202
|
+
...liveCounts,
|
|
203
|
+
conditions: [
|
|
204
|
+
{ type: 'RestartingJobSet', status: 'True' },
|
|
205
|
+
{ type: 'StartupPolicyInProgress', status: 'True' },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
}).text).toBe('Starting')
|
|
209
|
+
expect(getJobSetStatus({
|
|
210
|
+
status: {
|
|
211
|
+
...liveCounts,
|
|
212
|
+
conditions: [{ type: 'StartupPolicyInProgress', status: 'True' }],
|
|
213
|
+
},
|
|
214
|
+
}).text).toBe('Starting')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('reports JobSet restart recovery and terminal-state fallbacks', () => {
|
|
218
|
+
expect(
|
|
219
|
+
getJobSetStatus({
|
|
220
|
+
status: { conditions: [{ type: 'RestartingJobSet', status: 'True' }], replicatedJobsStatus: [] },
|
|
221
|
+
}).text,
|
|
222
|
+
).toBe('Restarting')
|
|
223
|
+
expect(getJobSetStatus({ status: { terminalState: 'Completed' } }).text).toBe('Completed')
|
|
224
|
+
expect(getJobSetStatus({ status: { terminalState: 'Failed' } }).text).toBe('Failed')
|
|
143
225
|
})
|
|
144
226
|
|
|
145
227
|
it('surfaces an unavailable LeaderWorkerSet before progress', () => {
|
|
@@ -234,3 +316,22 @@ describe('GPU ecosystem API contracts', () => {
|
|
|
234
316
|
})).toBe('Worker 2/4 (2 failed)')
|
|
235
317
|
})
|
|
236
318
|
})
|
|
319
|
+
|
|
320
|
+
describe('JobSet status evidence boundaries', () => {
|
|
321
|
+
it('does not turn pending Pods or child failures into root health or outcomes', () => {
|
|
322
|
+
for (const counts of [{ active: 1, ready: 0 }, { failed: 1 }, { succeeded: 1 }]) {
|
|
323
|
+
expect(getJobSetStatus({ status: { replicatedJobsStatus: [counts] } })).toMatchObject({ text: 'Active', level: 'neutral' })
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
it('requires observed counts for pending and preserves unknown native states', () => {
|
|
327
|
+
expect(getJobSetStatus({ status: {} }).text).toBe('Unknown')
|
|
328
|
+
expect(getJobSetStatus({ status: { replicatedJobsStatus: [{ active: 0 }] } }).text).toBe('Pending')
|
|
329
|
+
expect(getJobSetStatus({ status: { terminalState: 'Future', conditions: [{ type: 'Completed', status: 'True' }] } }).text).toBe('Unknown')
|
|
330
|
+
})
|
|
331
|
+
it('separates suspension intent and in-order resume evidence', () => {
|
|
332
|
+
expect(getJobSetStatus({ spec: { suspend: true }, status: { replicatedJobsStatus: [{ active: 1 }] } }).text).toBe('Active')
|
|
333
|
+
const conditions = [{ type: 'Suspended', status: 'True' }, { type: 'StartupPolicyInProgress', status: 'True' }]
|
|
334
|
+
expect(getJobSetStatus({ spec: { suspend: false }, status: { conditions } }).text).toBe('Starting')
|
|
335
|
+
expect(getJobSetStatus({ spec: { suspend: true }, status: { conditions } }).text).toBe('Suspended')
|
|
336
|
+
})
|
|
337
|
+
})
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import type { StatusBadge } from './resource-utils'
|
|
4
4
|
import { healthColors } from './resource-utils'
|
|
5
5
|
|
|
6
|
+
export const JOBSET_API_VERSION = 'jobset.x-k8s.io/v1alpha2'
|
|
7
|
+
|
|
8
|
+
export function isJobSetV1Alpha2(resource: any): boolean {
|
|
9
|
+
return resource?.kind === 'JobSet' && resource?.apiVersion === JOBSET_API_VERSION
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
// ============================================================================
|
|
7
13
|
// LEADERWORKERSET UTILITIES
|
|
8
14
|
// ============================================================================
|
|
@@ -61,28 +67,56 @@ export function getJobSetStatus(resource: any): StatusBadge {
|
|
|
61
67
|
const conditions = resource.status?.conditions || []
|
|
62
68
|
|
|
63
69
|
const failedCond = conditions.find((c: any) => c.type === 'Failed')
|
|
70
|
+
const completedCond = conditions.find((c: any) => c.type === 'Completed')
|
|
71
|
+
|
|
72
|
+
if (resource.status?.terminalState === 'Failed') {
|
|
73
|
+
return { text: failedCond?.status === 'True' ? failedCond.reason || 'Failed' : 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (resource.status?.terminalState === 'Completed') {
|
|
77
|
+
return { text: 'Completed', color: healthColors.neutral, level: 'neutral' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (resource.status?.terminalState) {
|
|
81
|
+
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
82
|
+
}
|
|
83
|
+
|
|
64
84
|
if (failedCond?.status === 'True') {
|
|
65
85
|
return { text: failedCond.reason || 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
66
86
|
}
|
|
67
87
|
|
|
68
|
-
const completedCond = conditions.find((c: any) => c.type === 'Completed')
|
|
69
88
|
if (completedCond?.status === 'True') {
|
|
70
89
|
return { text: 'Completed', color: healthColors.neutral, level: 'neutral' }
|
|
71
90
|
}
|
|
72
91
|
|
|
92
|
+
const startupCond = conditions.find((c: any) => c.type === 'StartupPolicyInProgress')
|
|
93
|
+
// In-order resume retains Suspended until every role starts.
|
|
94
|
+
if (startupCond?.status === 'True' && resource.spec?.suspend !== true) {
|
|
95
|
+
return { text: 'Starting', color: healthColors.degraded, level: 'degraded' }
|
|
96
|
+
}
|
|
97
|
+
|
|
73
98
|
const suspendedCond = conditions.find((c: any) => c.type === 'Suspended')
|
|
74
|
-
if (suspendedCond?.status === 'True'
|
|
99
|
+
if (suspendedCond?.status === 'True') {
|
|
75
100
|
return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
|
|
76
101
|
}
|
|
77
102
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (live > 0) {
|
|
82
|
-
return { text: 'Running', color: healthColors.healthy, level: 'healthy' }
|
|
103
|
+
const restartingCond = conditions.find((c: any) => c.type === 'RestartingJobSet')
|
|
104
|
+
if (restartingCond?.status === 'True') {
|
|
105
|
+
return { text: 'Restarting', color: healthColors.degraded, level: 'degraded' }
|
|
83
106
|
}
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
|
|
108
|
+
if (startupCond?.status === 'True') {
|
|
109
|
+
return { text: 'Starting', color: healthColors.degraded, level: 'degraded' }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const statuses = resource.status?.replicatedJobsStatus
|
|
113
|
+
if (Array.isArray(statuses)) {
|
|
114
|
+
// Child activity includes Pending Pods and cleanup, not application health.
|
|
115
|
+
const activity = ['active', 'ready', 'succeeded', 'failed', 'suspended']
|
|
116
|
+
.some((field) => sumReplicatedJobsField(resource, field) > 0)
|
|
117
|
+
return activity
|
|
118
|
+
? { text: 'Active', color: healthColors.neutral, level: 'neutral' }
|
|
119
|
+
: { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
86
120
|
}
|
|
87
121
|
|
|
88
122
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
@@ -5,6 +5,14 @@ import { healthColors } from './resource-utils'
|
|
|
5
5
|
|
|
6
6
|
const failedFinishedReasons = new Set(['Failed', 'FailedToStart', 'OutOfSync', 'OwnerNotFound'])
|
|
7
7
|
|
|
8
|
+
export function isKueueConditionStale(condition: { observedGeneration?: number } | undefined, generation?: number): boolean {
|
|
9
|
+
return !!condition?.observedGeneration && !!generation && condition.observedGeneration < generation
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isKueueWorkloadFailureReason(reason: string): boolean {
|
|
13
|
+
return failedFinishedReasons.has(reason)
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
// ============================================================================
|
|
9
17
|
// SHARED HELPERS
|
|
10
18
|
// ============================================================================
|
|
@@ -24,6 +32,88 @@ function activeConditionStatus(resource: any): StatusBadge {
|
|
|
24
32
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
export function isKueueQueueResource(resource: any): boolean {
|
|
36
|
+
return resource?.apiVersion === 'kueue.x-k8s.io/v1beta1' || resource?.apiVersion === 'kueue.x-k8s.io/v1beta2'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getKueueQueueCondition(resource: any): any {
|
|
40
|
+
return findCondition(resource, 'Active')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function queueConditionStatus(resource: any): StatusBadge {
|
|
44
|
+
const active = getKueueQueueCondition(resource)
|
|
45
|
+
if (isKueueConditionStale(active, resource?.metadata?.generation)) {
|
|
46
|
+
return { text: `${active.status === 'True' ? 'Active' : active.reason || 'Unknown'} (stale)`, color: healthColors.unknown, level: 'unknown' }
|
|
47
|
+
}
|
|
48
|
+
if (active?.status === 'False' && active.reason === 'Stopped') {
|
|
49
|
+
return { text: 'Stopped', color: healthColors.neutral, level: 'neutral' }
|
|
50
|
+
}
|
|
51
|
+
return activeConditionStatus(resource)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Quantity = string | number
|
|
55
|
+
interface QueueResourceUsage {
|
|
56
|
+
name: string
|
|
57
|
+
total?: Quantity
|
|
58
|
+
borrowed?: Quantity
|
|
59
|
+
}
|
|
60
|
+
interface QueueFlavorUsage {
|
|
61
|
+
name: string
|
|
62
|
+
resources?: QueueResourceUsage[]
|
|
63
|
+
}
|
|
64
|
+
export interface KueueQuotaRow {
|
|
65
|
+
flavor: string
|
|
66
|
+
resource: string
|
|
67
|
+
configured: boolean
|
|
68
|
+
nominal?: Quantity
|
|
69
|
+
borrowingLimit?: Quantity
|
|
70
|
+
lendingLimit?: Quantity
|
|
71
|
+
reserved?: Quantity
|
|
72
|
+
used?: Quantity
|
|
73
|
+
borrowedReservation?: Quantity
|
|
74
|
+
borrowedUsage?: Quantity
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getKueueQueueQuotaRows(resource: any, clusterQueue: boolean): KueueQuotaRow[] {
|
|
78
|
+
const rows = new Map<string, KueueQuotaRow>()
|
|
79
|
+
const rowFor = (flavor: string, name: string) => {
|
|
80
|
+
const key = JSON.stringify([flavor, name])
|
|
81
|
+
let row = rows.get(key)
|
|
82
|
+
if (!row) {
|
|
83
|
+
row = { flavor, resource: name, configured: false }
|
|
84
|
+
rows.set(key, row)
|
|
85
|
+
}
|
|
86
|
+
return row
|
|
87
|
+
}
|
|
88
|
+
if (clusterQueue) {
|
|
89
|
+
for (const group of resource?.spec?.resourceGroups ?? []) {
|
|
90
|
+
for (const flavor of group.flavors ?? []) {
|
|
91
|
+
for (const quota of flavor.resources ?? []) {
|
|
92
|
+
Object.assign(rowFor(flavor.name, quota.name), {
|
|
93
|
+
configured: true, nominal: quota.nominalQuota,
|
|
94
|
+
borrowingLimit: quota.borrowingLimit, lendingLimit: quota.lendingLimit,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const reservation: QueueFlavorUsage[] = resource?.status?.flavorsReservation ?? []
|
|
101
|
+
const usage: QueueFlavorUsage[] = !clusterQueue && resource?.apiVersion === 'kueue.x-k8s.io/v1beta1'
|
|
102
|
+
? resource?.status?.flavorUsage ?? []
|
|
103
|
+
: resource?.status?.flavorsUsage ?? []
|
|
104
|
+
for (const flavor of reservation) {
|
|
105
|
+
for (const entry of flavor.resources ?? []) {
|
|
106
|
+
Object.assign(rowFor(flavor.name, entry.name), { reserved: entry.total, borrowedReservation: entry.borrowed })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const flavor of usage) {
|
|
110
|
+
for (const entry of flavor.resources ?? []) {
|
|
111
|
+
Object.assign(rowFor(flavor.name, entry.name), { used: entry.total, borrowedUsage: entry.borrowed })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...rows.values()]
|
|
115
|
+
}
|
|
116
|
+
|
|
27
117
|
function formatWorkloadCount(value: any): string {
|
|
28
118
|
return typeof value === 'number' ? String(value) : '-'
|
|
29
119
|
}
|
|
@@ -33,7 +123,7 @@ function formatWorkloadCount(value: any): string {
|
|
|
33
123
|
// ============================================================================
|
|
34
124
|
|
|
35
125
|
export function getClusterQueueStatus(resource: any): StatusBadge {
|
|
36
|
-
return
|
|
126
|
+
return queueConditionStatus(resource)
|
|
37
127
|
}
|
|
38
128
|
|
|
39
129
|
export function getClusterQueueCohort(resource: any): string {
|
|
@@ -64,7 +154,7 @@ export function getClusterQueueFlavors(resource: any): string {
|
|
|
64
154
|
// ============================================================================
|
|
65
155
|
|
|
66
156
|
export function getLocalQueueStatus(resource: any): StatusBadge {
|
|
67
|
-
return
|
|
157
|
+
return queueConditionStatus(resource)
|
|
68
158
|
}
|
|
69
159
|
|
|
70
160
|
export function getLocalQueueClusterQueue(resource: any): string {
|
|
@@ -83,36 +173,30 @@ export function getLocalQueueAdmittedWorkloads(resource: any): string {
|
|
|
83
173
|
// KUEUE WORKLOAD UTILITIES
|
|
84
174
|
// ============================================================================
|
|
85
175
|
|
|
86
|
-
export function
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
return { text: finished.reason, color: healthColors.unhealthy, level: 'unhealthy' }
|
|
91
|
-
}
|
|
92
|
-
return { text: 'Finished', color: healthColors.neutral, level: 'neutral' }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const evicted = findCondition(resource, 'Evicted')
|
|
96
|
-
if (evicted?.status === 'True') {
|
|
97
|
-
return { text: 'Evicted', color: healthColors.degraded, level: 'degraded' }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const preempted = findCondition(resource, 'Preempted')
|
|
101
|
-
if (preempted?.status === 'True') {
|
|
102
|
-
return { text: 'Preempted', color: healthColors.degraded, level: 'degraded' }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const admitted = findCondition(resource, 'Admitted')
|
|
106
|
-
if (admitted?.status === 'True') {
|
|
107
|
-
return { text: 'Admitted', color: healthColors.healthy, level: 'healthy' }
|
|
176
|
+
export function getKueueWorkloadStatusCondition(resource: any): any {
|
|
177
|
+
for (const type of ['Finished', 'Evicted', 'Preempted', 'Admitted', 'QuotaReserved']) {
|
|
178
|
+
const condition = findCondition(resource, type)
|
|
179
|
+
if (condition?.status === 'True') return condition
|
|
108
180
|
}
|
|
181
|
+
}
|
|
109
182
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
183
|
+
export function getKueueWorkloadStatus(resource: any): StatusBadge {
|
|
184
|
+
const condition = getKueueWorkloadStatusCondition(resource)
|
|
185
|
+
switch (condition?.type) {
|
|
186
|
+
case 'Finished':
|
|
187
|
+
return isKueueWorkloadFailureReason(condition.reason)
|
|
188
|
+
? { text: condition.reason, color: healthColors.unhealthy, level: 'unhealthy' }
|
|
189
|
+
: { text: 'Finished', color: healthColors.neutral, level: 'neutral' }
|
|
190
|
+
case 'Evicted':
|
|
191
|
+
case 'Preempted':
|
|
192
|
+
return { text: condition.type, color: healthColors.degraded, level: 'degraded' }
|
|
193
|
+
case 'Admitted':
|
|
194
|
+
return { text: 'Admitted', color: healthColors.healthy, level: 'healthy' }
|
|
195
|
+
case 'QuotaReserved':
|
|
196
|
+
return { text: 'QuotaReserved', color: healthColors.neutral, level: 'neutral' }
|
|
197
|
+
default:
|
|
198
|
+
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
113
199
|
}
|
|
114
|
-
|
|
115
|
-
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
116
200
|
}
|
|
117
201
|
|
|
118
202
|
export function getKueueWorkloadQueueName(resource: any): string {
|
|
@@ -151,6 +235,10 @@ export function getResourceFlavorTaintCount(resource: any): number {
|
|
|
151
235
|
// ============================================================================
|
|
152
236
|
|
|
153
237
|
export function getAdmissionCheckStatus(resource: any): StatusBadge {
|
|
238
|
+
const condition = findCondition(resource, 'Active')
|
|
239
|
+
if (isKueueConditionStale(condition, resource?.metadata?.generation)) {
|
|
240
|
+
return { text: 'Stale', color: healthColors.unknown, level: 'unknown' }
|
|
241
|
+
}
|
|
154
242
|
return activeConditionStatus(resource)
|
|
155
243
|
}
|
|
156
244
|
|
|
@@ -162,33 +250,40 @@ export function getAdmissionCheckControllerName(resource: any): string {
|
|
|
162
250
|
// CLUSTER AUTOSCALER PROVISIONINGREQUEST UTILITIES
|
|
163
251
|
// ============================================================================
|
|
164
252
|
|
|
165
|
-
export function
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const capacityRevoked = findCondition(resource, 'CapacityRevoked')
|
|
172
|
-
if (capacityRevoked?.status === 'True') {
|
|
173
|
-
return { text: 'CapacityRevoked', color: healthColors.degraded, level: 'degraded' }
|
|
253
|
+
export function getProvisioningRequestStatusCondition(resource: any): any {
|
|
254
|
+
for (const type of ['Failed', 'CapacityRevoked', 'BookingExpired', 'Provisioned', 'Accepted']) {
|
|
255
|
+
const condition = findCondition(resource, type)
|
|
256
|
+
if (condition?.status === 'True') return condition
|
|
174
257
|
}
|
|
258
|
+
}
|
|
175
259
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
260
|
+
export function getProvisioningRequestMessage(resource: any): string | undefined {
|
|
261
|
+
const condition = getProvisioningRequestStatusCondition(resource)
|
|
262
|
+
if (condition?.type === 'Accepted') {
|
|
263
|
+
const progress = findCondition(resource, 'Provisioned')
|
|
264
|
+
if (progress?.status === 'False' && !isKueueConditionStale(progress, resource?.metadata?.generation) && progress.message) return progress.message
|
|
179
265
|
}
|
|
266
|
+
return condition?.message
|
|
267
|
+
}
|
|
180
268
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
269
|
+
export function getProvisioningRequestStatus(resource: any): StatusBadge {
|
|
270
|
+
const condition = getProvisioningRequestStatusCondition(resource)
|
|
271
|
+
if (isKueueConditionStale(condition, resource?.metadata?.generation)) {
|
|
272
|
+
return { text: `${condition.type} (stale)`, color: healthColors.unknown, level: 'unknown' }
|
|
184
273
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
274
|
+
switch (condition?.type) {
|
|
275
|
+
case 'Failed':
|
|
276
|
+
case 'CapacityRevoked':
|
|
277
|
+
return { text: condition.type, color: healthColors.unhealthy, level: 'unhealthy' }
|
|
278
|
+
case 'BookingExpired':
|
|
279
|
+
return { text: 'BookingExpired', color: healthColors.neutral, level: 'neutral' }
|
|
280
|
+
case 'Provisioned':
|
|
281
|
+
return { text: 'Provisioned', color: healthColors.healthy, level: 'healthy' }
|
|
282
|
+
case 'Accepted':
|
|
283
|
+
return { text: 'Accepted', color: healthColors.neutral, level: 'neutral' }
|
|
284
|
+
default:
|
|
285
|
+
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
189
286
|
}
|
|
190
|
-
|
|
191
|
-
return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
|
|
192
287
|
}
|
|
193
288
|
|
|
194
289
|
export function getProvisioningRequestClassName(resource: any): string {
|