@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,121 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { KueueWorkloadRenderer, getKueueWorkloadConditionTone } from './KueueWorkloadRenderer'
|
|
4
|
+
|
|
5
|
+
const base = {
|
|
6
|
+
apiVersion: 'kueue.x-k8s.io/v1beta2', kind: 'Workload',
|
|
7
|
+
metadata: { name: 'training', namespace: 'ml' },
|
|
8
|
+
spec: { queueName: 'gpu', podSets: [{ name: 'workers', count: 4, minCount: 2 }] },
|
|
9
|
+
}
|
|
10
|
+
const render = (status: any = {}, data: any = base) => renderToString(<KueueWorkloadRenderer data={{ ...data, status }} />).replace(/<!-- -->/g, '')
|
|
11
|
+
|
|
12
|
+
describe('Kueue Workload detail', () => {
|
|
13
|
+
it('shows evaluated totals and exact quota blocker without inventing a failure', () => {
|
|
14
|
+
const html = render({
|
|
15
|
+
resourceRequests: [{ name: 'workers', resources: { cpu: '8', memory: '32Gi', 'nvidia.com/gpu': '4' } }],
|
|
16
|
+
conditions: [{ type: 'QuotaReserved', status: 'False', reason: 'Pending', message: 'insufficient quota for nvidia.com/gpu' }],
|
|
17
|
+
})
|
|
18
|
+
for (const text of ['Evaluated Requests', 'CPU: 8', 'nvidia.com/gpu: 4', 'insufficient quota', 'No reservation reported']) expect(html).toContain(text)
|
|
19
|
+
expect(html).not.toContain('failing')
|
|
20
|
+
expect(html).not.toContain('Workload finished unsuccessfully')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it.each(['v1beta1', 'v1beta2'])('separates reservation from admission for %s', (version) => {
|
|
24
|
+
const html = render({
|
|
25
|
+
admission: { clusterQueue: 'team-quota', podSetAssignments: [{ name: 'workers', count: 2, resourceUsage: { cpu: '4', 'nvidia.com/gpu': '2' }, flavors: { 'nvidia.com/gpu': 'a100' } }] },
|
|
26
|
+
admissionChecks: [{ name: 'capacity', state: 'Pending', message: 'Waiting for provisioned nodes' }],
|
|
27
|
+
conditions: [{ type: 'QuotaReserved', status: 'True' }, { type: 'Admitted', status: 'False', reason: 'AdmissionCheck' }],
|
|
28
|
+
}, { ...base, apiVersion: `kueue.x-k8s.io/${version}` })
|
|
29
|
+
for (const text of ['QuotaReserved', 'Pods at Reservation', 'Resources at Reservation', 'CPU: 4', 'nvidia.com/gpu: a100', 'team-quota', 'Waiting for provisioned nodes']) expect(html).toContain(text)
|
|
30
|
+
expect(html).not.toContain('Admitted Pods')
|
|
31
|
+
expect(html).not.toContain('Admitted Demand')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('joins PodSet assignments by name rather than array position', () => {
|
|
35
|
+
const html = render({ admission: { podSetAssignments: [{ name: 'other', resourceUsage: { cpu: '99' } }, { name: 'workers', count: 0, resourceUsage: { cpu: 0, 'example.com/gpu': '0' } }] }, reclaimablePods: [{ name: 'workers', count: 0 }] })
|
|
36
|
+
expect(html).toContain('example.com/gpu: 0')
|
|
37
|
+
expect(html).toContain('CPU: 0')
|
|
38
|
+
expect(html).toContain('Reclaimable Pods')
|
|
39
|
+
expect(html).not.toContain('CPU: 99')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not reconstruct controller totals or turn missing status into zero', () => {
|
|
43
|
+
const html = render({}, { ...base, spec: { podSets: [{ name: 'workers', count: 4, template: { spec: { containers: [{ resources: { requests: { cpu: '12' } } }] } } }] } })
|
|
44
|
+
expect(html).toContain('Not reported')
|
|
45
|
+
expect(html).not.toContain('CPU: 12')
|
|
46
|
+
expect(html).not.toContain('Pods at Reservation')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it.each(['Failed', 'FailedToStart', 'OutOfSync', 'OwnerNotFound'])('highlights terminal %s with its exact reason', (reason) => {
|
|
50
|
+
const html = render({ conditions: [{ type: 'Finished', status: 'True', reason, message: 'controller explanation' }] })
|
|
51
|
+
expect(html).toContain('Workload finished unsuccessfully')
|
|
52
|
+
expect(html).toContain('controller explanation')
|
|
53
|
+
expect(getKueueWorkloadConditionTone({ type: 'Finished', status: 'True', reason })).toBe('fail')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps unknown completion and condition semantics neutral', () => {
|
|
57
|
+
expect(getKueueWorkloadConditionTone({ type: 'Finished', status: 'True', reason: 'FutureReason' })).toBe('unknown')
|
|
58
|
+
expect(getKueueWorkloadConditionTone({ type: 'FutureCondition', status: 'True' })).toBe('unknown')
|
|
59
|
+
expect(getKueueWorkloadConditionTone({ type: 'Admitted', status: 'Unknown' })).toBe('unknown')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it.each(['Ready', 'Pending', 'Retry', 'Rejected', 'FutureState'])('preserves admission check %s and its message', (state) => {
|
|
63
|
+
const html = render({ admissionChecks: [{ name: 'capacity', state, message: 'Exact controller message' }] })
|
|
64
|
+
expect(html).toContain(state)
|
|
65
|
+
expect(html).toContain('Exact controller message')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('labels stale failure evidence without displaying it as a current failure', () => {
|
|
70
|
+
const html = render({ conditions: [{ type: 'Finished', status: 'True', reason: 'FailedToStart', observedGeneration: 2 }] }, { ...base, metadata: { ...base.metadata, generation: 3 } })
|
|
71
|
+
expect(html).toContain('earlier generation')
|
|
72
|
+
expect(html).toContain('Finished (generation 2)')
|
|
73
|
+
expect(html).not.toContain('Workload finished unsuccessfully')
|
|
74
|
+
expect(html).not.toContain('failing')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
it('does not neutralize current admission because a historical condition is stale', () => {
|
|
79
|
+
const html = render({ conditions: [{ type: 'Admitted', status: 'True', observedGeneration: 3 }, { type: 'Requeued', status: 'False', observedGeneration: 2 }] }, { ...base, metadata: { ...base.metadata, generation: 3 } })
|
|
80
|
+
expect(html).toContain('Admitted')
|
|
81
|
+
expect(html).not.toContain('earlier generation')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
it.each([
|
|
86
|
+
[{ priority: 0 }, '0'],
|
|
87
|
+
[{ priorityClassName: 'batch-priority' }, 'batch-priority'],
|
|
88
|
+
[{ priorityClassRef: { name: 'training-priority' } }, 'training-priority'],
|
|
89
|
+
])('shows priority from native fields %j', (fields, value) => {
|
|
90
|
+
const html = render({}, { ...base, spec: { ...base.spec, ...fields } })
|
|
91
|
+
expect(html).toContain('Priority')
|
|
92
|
+
expect(html).toContain(value)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('labels a stale terminal report even when an admission condition has a newer generation', () => {
|
|
96
|
+
const html = render({ conditions: [
|
|
97
|
+
{ type: 'Finished', status: 'True', reason: 'FailedToStart', observedGeneration: 2 },
|
|
98
|
+
{ type: 'Admitted', status: 'True', observedGeneration: 3 },
|
|
99
|
+
] }, { ...base, metadata: { ...base.metadata, generation: 3 } })
|
|
100
|
+
expect(html).toContain('Reported state describes an earlier generation')
|
|
101
|
+
expect(html).toContain('Reported state may not reflect the current specification')
|
|
102
|
+
expect(html).not.toContain('Workload finished unsuccessfully')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
it('summarizes reported checks without inferring an admission decision', () => {
|
|
107
|
+
const html = render({
|
|
108
|
+
admissionChecks: ['Pending', 'Rejected', 'Pending', 'Ready', 'Retry', 'FutureState'].map((state, index) => ({ name: `check-${index}`, state })),
|
|
109
|
+
conditions: [{ type: 'Finished', status: 'True', reason: 'Succeeded' }],
|
|
110
|
+
})
|
|
111
|
+
expect(html).toContain('Reported Checks')
|
|
112
|
+
expect(html).toContain('1 Rejected · 1 Retry · 2 Pending · 1 Ready · 1 FutureState')
|
|
113
|
+
expect(html).toContain('Finished')
|
|
114
|
+
expect(html).not.toContain('blocked by')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not interpret absent checks as readiness', () => {
|
|
118
|
+
const html = render()
|
|
119
|
+
expect(html).not.toContain('Reported Checks')
|
|
120
|
+
expect(html).not.toContain('Admission Checks (')
|
|
121
|
+
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Cpu, Gauge, ListChecks } from 'lucide-react'
|
|
3
|
+
import { formatRelativeAgeTime } from '../../../utils/format'
|
|
4
|
+
import { Badge, type BadgeSeverity } from '../../ui/Badge'
|
|
5
|
+
import {
|
|
6
|
+
AlertBanner,
|
|
7
|
+
ConditionsSection,
|
|
8
|
+
Property,
|
|
9
|
+
PropertyList,
|
|
10
|
+
ResourceLink,
|
|
11
|
+
Section,
|
|
12
|
+
type ConditionTone,
|
|
13
|
+
} from '../../ui/drawer-components'
|
|
14
|
+
import { formatResources } from '../resource-utils'
|
|
15
|
+
import { getKueueWorkloadStatus, getKueueWorkloadPriority, getKueueWorkloadStatusCondition, isKueueWorkloadFailureReason, isKueueConditionStale } from '../resource-utils-kueue'
|
|
16
|
+
|
|
17
|
+
const KUEUE_GROUP = 'kueue.x-k8s.io'
|
|
18
|
+
|
|
19
|
+
interface KueueWorkloadRendererProps {
|
|
20
|
+
data: any
|
|
21
|
+
provisioning?: ReactNode
|
|
22
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function admissionCheckSeverity(state: string): BadgeSeverity {
|
|
26
|
+
switch (state) {
|
|
27
|
+
case 'Ready':
|
|
28
|
+
return 'success'
|
|
29
|
+
case 'Rejected':
|
|
30
|
+
return 'error'
|
|
31
|
+
case 'Retry':
|
|
32
|
+
return 'warning'
|
|
33
|
+
default:
|
|
34
|
+
return 'neutral'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resourceList(resources: any): string | null {
|
|
39
|
+
if (!resources || typeof resources !== 'object' || Object.keys(resources).length === 0) return null
|
|
40
|
+
return formatResources(Object.fromEntries(Object.entries(resources).map(([name, quantity]) => [name, String(quantity)])))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getKueueWorkloadConditionTone(condition: any): ConditionTone {
|
|
44
|
+
if (condition?.status !== 'True' && condition?.status !== 'False') return 'unknown'
|
|
45
|
+
|
|
46
|
+
if (condition.type === 'Finished') {
|
|
47
|
+
if (condition.status !== 'True') return 'unknown'
|
|
48
|
+
return isKueueWorkloadFailureReason(condition.reason) ? 'fail' : condition.reason === 'Succeeded' ? 'ok' : 'unknown'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (['Evicted', 'Preempted', 'DeactivationTarget', 'BlockedOnPreemptionGates', 'WaitingForReplacementPods'].includes(condition.type)) {
|
|
52
|
+
return condition.status === 'True' ? 'warning' : 'ok'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (['Admitted', 'QuotaReserved', 'PodsReady'].includes(condition.type)) {
|
|
56
|
+
return condition.status === 'True' ? 'ok' : 'unknown'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return 'unknown'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function FlavorAssignments({ flavors, onNavigate }: { flavors: any; onNavigate?: KueueWorkloadRendererProps['onNavigate'] }) {
|
|
63
|
+
if (!flavors || typeof flavors !== 'object' || Object.keys(flavors).length === 0) return null
|
|
64
|
+
return (
|
|
65
|
+
<div className="space-y-1 break-all [&_button]:text-left">
|
|
66
|
+
{Object.entries(flavors).map(([resource, flavor]) => (
|
|
67
|
+
<div key={resource} className="text-xs">
|
|
68
|
+
{resource}:{' '}
|
|
69
|
+
<ResourceLink name={String(flavor)} kind="resourceflavors" group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function KueueWorkloadRenderer({ data, onNavigate, provisioning }: KueueWorkloadRendererProps) {
|
|
77
|
+
const spec = data.spec || {}
|
|
78
|
+
const status = data.status || {}
|
|
79
|
+
const namespace = data.metadata?.namespace || ''
|
|
80
|
+
const workloadStatus = getKueueWorkloadStatus(data)
|
|
81
|
+
const priority = getKueueWorkloadPriority(data)
|
|
82
|
+
const statusCondition = getKueueWorkloadStatusCondition(data)
|
|
83
|
+
const stale = isKueueConditionStale(statusCondition, data.metadata?.generation)
|
|
84
|
+
const failure = workloadStatus.level === 'unhealthy' && !stale ? statusCondition : undefined
|
|
85
|
+
const podSets = Array.isArray(spec.podSets) ? spec.podSets : []
|
|
86
|
+
const assignments = new Map(
|
|
87
|
+
(Array.isArray(status.admission?.podSetAssignments) ? status.admission.podSetAssignments : []).map((assignment: any) => [
|
|
88
|
+
assignment.name,
|
|
89
|
+
assignment,
|
|
90
|
+
]),
|
|
91
|
+
)
|
|
92
|
+
const requests = new Map(
|
|
93
|
+
(Array.isArray(status.resourceRequests) ? status.resourceRequests : []).map((request: any) => [request.name, request]),
|
|
94
|
+
)
|
|
95
|
+
const reclaimable = new Map(
|
|
96
|
+
(Array.isArray(status.reclaimablePods) ? status.reclaimablePods : []).map((entry: any) => [entry.name, entry.count]),
|
|
97
|
+
)
|
|
98
|
+
const checks = Array.isArray(status.admissionChecks) ? status.admissionChecks : []
|
|
99
|
+
const checkCounts = new Map<string, number>(['Rejected', 'Retry', 'Pending', 'Ready'].map(state => [state, 0]))
|
|
100
|
+
for (const check of checks) {
|
|
101
|
+
const state = check?.state || 'Unknown'
|
|
102
|
+
checkCounts.set(state, (checkCounts.get(state) || 0) + 1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
{failure && <AlertBanner variant="error" title="Workload finished unsuccessfully" message={failure.message || failure.reason} />}
|
|
108
|
+
|
|
109
|
+
{stale && <AlertBanner variant="info" title="Reported state describes an earlier generation" message={`Workload generation ${data.metadata.generation}. Stale condition: ${statusCondition?.type} (generation ${statusCondition?.observedGeneration}). Reported state may not reflect the current specification.`} />}
|
|
110
|
+
|
|
111
|
+
<Section title="Admission" icon={Gauge} defaultExpanded>
|
|
112
|
+
<PropertyList>
|
|
113
|
+
<Property label="Reported State" value={<Badge colorClass={stale ? undefined : workloadStatus.color} severity={stale ? 'neutral' : undefined}>{workloadStatus.text}</Badge>} />
|
|
114
|
+
<Property
|
|
115
|
+
label="Local Queue"
|
|
116
|
+
value={
|
|
117
|
+
spec.queueName ? (
|
|
118
|
+
<ResourceLink name={spec.queueName} kind="localqueues" namespace={namespace} group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
119
|
+
) : (
|
|
120
|
+
'Not assigned'
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
/>
|
|
124
|
+
<Property
|
|
125
|
+
label="Cluster Queue"
|
|
126
|
+
value={
|
|
127
|
+
status.admission?.clusterQueue ? (
|
|
128
|
+
<ResourceLink name={status.admission.clusterQueue} kind="clusterqueues" group={KUEUE_GROUP} onNavigate={onNavigate} />
|
|
129
|
+
) : (
|
|
130
|
+
'No reservation reported'
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
{priority !== '-' && <Property label="Priority" value={priority} />}
|
|
135
|
+
{checks.length > 0 && (
|
|
136
|
+
<Property label="Reported Checks" value={Array.from(checkCounts).filter(([, count]) => count > 0).map(([state, count]) => `${count} ${state}`).join(' · ')} />
|
|
137
|
+
)}
|
|
138
|
+
<Property label="Workload Active" value={spec.active === false ? 'No' : 'Yes'} />
|
|
139
|
+
</PropertyList>
|
|
140
|
+
</Section>
|
|
141
|
+
|
|
142
|
+
{checks.length > 0 && (
|
|
143
|
+
<Section title={`Admission Checks (${checks.length})`} icon={ListChecks} defaultExpanded>
|
|
144
|
+
<div className="max-w-2xl space-y-2">
|
|
145
|
+
{checks.map((check: any, index: number) => (
|
|
146
|
+
<div key={check?.name || index} className="card-inner">
|
|
147
|
+
<div className="mb-2 grid max-w-md grid-cols-[minmax(0,1fr)_6rem] items-start gap-2">
|
|
148
|
+
<span className="min-w-0 break-words text-sm font-medium text-theme-text-primary [&_button]:text-left">
|
|
149
|
+
<ResourceLink
|
|
150
|
+
name={check?.name || `check-${index + 1}`}
|
|
151
|
+
kind="admissionchecks"
|
|
152
|
+
group={KUEUE_GROUP}
|
|
153
|
+
onNavigate={onNavigate}
|
|
154
|
+
/>
|
|
155
|
+
</span>
|
|
156
|
+
<Badge severity={admissionCheckSeverity(check?.state)} size="sm" className="justify-self-start">
|
|
157
|
+
{check?.state || 'Unknown'}
|
|
158
|
+
</Badge>
|
|
159
|
+
</div>
|
|
160
|
+
{check?.message && <p className="whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{check.message}</p>}
|
|
161
|
+
{check?.lastTransitionTime && (
|
|
162
|
+
<div className="mt-2">
|
|
163
|
+
<PropertyList>
|
|
164
|
+
<Property label="Last Transition" value={formatRelativeAgeTime(check.lastTransitionTime)} />
|
|
165
|
+
</PropertyList>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</Section>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{provisioning}
|
|
175
|
+
|
|
176
|
+
<ConditionsSection conditions={status.conditions} getConditionTone={(condition) => isKueueConditionStale(condition, data.metadata?.generation) ? 'unknown' : getKueueWorkloadConditionTone(condition)} defaultExpanded />
|
|
177
|
+
|
|
178
|
+
<Section title={`Pod Sets (${podSets.length})`} icon={Cpu} defaultExpanded>
|
|
179
|
+
<p className="mb-2 text-xs text-theme-text-tertiary">
|
|
180
|
+
Controller-reported requests for each PodSet, not measured usage. Reservation values are recorded when quota is assigned; they do not decrease after quota reclamation or completion.
|
|
181
|
+
</p>
|
|
182
|
+
{podSets.length > 0 ? (
|
|
183
|
+
<div className="space-y-2">
|
|
184
|
+
{podSets.map((podSet: any) => {
|
|
185
|
+
const name = podSet?.name || 'main'
|
|
186
|
+
const assignment: any = assignments.get(name)
|
|
187
|
+
const request: any = requests.get(name)
|
|
188
|
+
const evaluatedDemand = resourceList(request?.resources)
|
|
189
|
+
const reservedResources = resourceList(assignment?.resourceUsage)
|
|
190
|
+
const reserved = status.admission != null
|
|
191
|
+
const reclaimableCount = reclaimable.get(name)
|
|
192
|
+
return (
|
|
193
|
+
<div key={name} className="card-inner">
|
|
194
|
+
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
195
|
+
<span className="text-sm font-medium text-theme-text-primary">{name}</span>
|
|
196
|
+
<Badge tone="structural" size="sm">
|
|
197
|
+
desired {String(podSet?.count ?? 'Not reported')}
|
|
198
|
+
</Badge>
|
|
199
|
+
{typeof podSet?.minCount === 'number' && (
|
|
200
|
+
<Badge tone="note" size="sm">
|
|
201
|
+
minimum {podSet.minCount}
|
|
202
|
+
</Badge>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
<PropertyList>
|
|
206
|
+
{assignment && <Property label="Pods at Reservation" value={assignment.count ?? 'Not reported'} />}
|
|
207
|
+
{typeof reclaimableCount === 'number' && <Property label="Reclaimable Pods" value={reclaimableCount} />}
|
|
208
|
+
<Property
|
|
209
|
+
label={reserved ? 'Resources at Reservation' : 'Evaluated Requests'}
|
|
210
|
+
value={reserved ? reservedResources || 'Not reported' : evaluatedDemand || 'Not reported'}
|
|
211
|
+
/>
|
|
212
|
+
{assignment?.flavors && (
|
|
213
|
+
<Property
|
|
214
|
+
label="Resource Flavors"
|
|
215
|
+
value={<FlavorAssignments flavors={assignment.flavors} onNavigate={onNavigate} />}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
</PropertyList>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
})}
|
|
222
|
+
</div>
|
|
223
|
+
) : (
|
|
224
|
+
<div className="text-sm text-theme-text-tertiary">No PodSets declared</div>
|
|
225
|
+
)}
|
|
226
|
+
</Section>
|
|
227
|
+
|
|
228
|
+
</>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { RayClusterRenderer, rayClusterConditionTone } from './RayClusterRenderer'
|
|
4
|
+
const root = { apiVersion: 'ray.io/v1', kind: 'RayCluster', metadata: { name: 'cluster', namespace: 'ml', uid: 'uid', generation: 2 }, spec: { workerGroupSpecs: [{ groupName: 'zero', replicas: 0, minReplicas: 0, maxReplicas: 3, numOfHosts: 2 }] }, status: { observedGeneration: 2, conditions: [{ type: 'HeadPodReady', status: 'True' }, { type: 'RayClusterProvisioned', status: 'True' }] } }
|
|
5
|
+
describe('RayCluster detail evidence boundaries', () => {
|
|
6
|
+
it('renders reconciled zeros, declared configuration and historical provisioning', () => {
|
|
7
|
+
const html = renderToStaticMarkup(<RayClusterRenderer data={root} />)
|
|
8
|
+
for (const text of ['Runtime Health', 'ready workers', 'Declared sizing', 'zero', 'Hosts per Replica', 'initial provisioning']) expect(html).toContain(text)
|
|
9
|
+
expect(rayClusterConditionTone({ type: 'RayClusterProvisioned', status: 'True' })).toBe('unknown')
|
|
10
|
+
})
|
|
11
|
+
it('does not show cached Pod evidence as current when a refresh failed', () => {
|
|
12
|
+
const html = renderToStaticMarkup(<RayClusterRenderer data={root} onSelectPods={() => {}} podsError="Access denied" podEvidence={{ pods: [{ name: 'STALE-POD', ready: true, containers: [] }], total: 1, truncated: false }} />)
|
|
13
|
+
expect(html).toContain('Access denied'); expect(html).not.toContain('STALE-POD')
|
|
14
|
+
})
|
|
15
|
+
it('states selected-scope count and truncation without inventing group readiness', () => {
|
|
16
|
+
const html = renderToStaticMarkup(<RayClusterRenderer data={root} onSelectPods={() => {}} selection={{ nodeType: 'worker', workerGroup: 'zero' }} podEvidence={{ pods: [{ name: 'example', ready: false, containers: [] }], total: 250, truncated: true }} />)
|
|
17
|
+
expect(html).toContain('250 Pods'); expect(html).toContain('showing 1, failures first')
|
|
18
|
+
expect(html).not.toContain('No directly owned Pods')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('distinguishes unreported counters and humanizes the API default bound', () => {
|
|
23
|
+
const html = renderToStaticMarkup(<RayClusterRenderer data={{ ...root, spec: { workerGroupSpecs: [{ groupName: 'workers', maxReplicas: 2147483647 }] }, status: {} }} />)
|
|
24
|
+
expect(html).toContain('Not reported'); expect(html).toContain('No limit'); expect(html).not.toContain('2147483647')
|
|
25
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import { Activity, Boxes, Server } from 'lucide-react'
|
|
3
|
+
import type { ResourceRef, WorkloadPodInfo } from '../../../types'
|
|
4
|
+
import { apiVersionToGroup } from '../../../utils/navigation'
|
|
5
|
+
import { Badge } from '../../ui/Badge'
|
|
6
|
+
import { SelectMenu } from '../../ui/SelectMenu'
|
|
7
|
+
import { AlertBanner, ConditionsSection, type ConditionTone, Property, PropertyList, ResourceLink, Section } from '../../ui/drawer-components'
|
|
8
|
+
import { PodRow, PodListFrame, PodListToggle, workloadPodDetail } from '../../workload/PodList'
|
|
9
|
+
import { isRayObservationStale, rayClusterWorkerCounts } from '../resource-utils-ray'
|
|
10
|
+
|
|
11
|
+
export interface RayClusterPodSelection { nodeType?: 'head' | 'worker'; workerGroup?: string }
|
|
12
|
+
export interface RayClusterRendererProps {
|
|
13
|
+
data: any
|
|
14
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
15
|
+
podEvidence?: { pods: WorkloadPodInfo[]; total: number; truncated: boolean }
|
|
16
|
+
podsLoading?: boolean
|
|
17
|
+
podsError?: string
|
|
18
|
+
selection?: RayClusterPodSelection
|
|
19
|
+
onSelectPods?: (selection: RayClusterPodSelection) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function rayClusterConditionTone(condition: any): ConditionTone {
|
|
23
|
+
if (!['True', 'False'].includes(condition.status)) return 'unknown'
|
|
24
|
+
if (condition.type === 'HeadPodReady') return condition.status === 'True' ? 'ok' : 'warning'
|
|
25
|
+
if (condition.type === 'RayClusterReplicaFailure') return condition.status === 'True' ? 'fail' : 'unknown'
|
|
26
|
+
if (condition.type === 'RayClusterSuspending') return condition.status === 'True' ? 'warning' : 'unknown'
|
|
27
|
+
return 'unknown'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function RayClusterRenderer({ data, onNavigate, podEvidence, podsLoading, podsError, selection = {}, onSelectPods }: RayClusterRendererProps) {
|
|
31
|
+
const [expanded, setExpanded] = useState(false)
|
|
32
|
+
const podsRef = useRef<HTMLDivElement>(null)
|
|
33
|
+
const status = data.status ?? {}
|
|
34
|
+
const spec = data.spec ?? {}
|
|
35
|
+
const conditions = status.conditions ?? []
|
|
36
|
+
const stale = isRayObservationStale(data.metadata?.generation, status.observedGeneration)
|
|
37
|
+
const head = conditions.find((c: any) => c.type === 'HeadPodReady')
|
|
38
|
+
const counts = rayClusterWorkerCounts(data)
|
|
39
|
+
const groups: any[] = spec.workerGroupSpecs ?? []
|
|
40
|
+
const owners = (data.metadata?.ownerReferences ?? []).filter((owner: any) => owner.controller === true && apiVersionToGroup(owner.apiVersion) === 'ray.io' && ['RayService', 'RayJob'].includes(owner.kind))
|
|
41
|
+
const scope = selection.workerGroup != null ? `Worker group: ${selection.workerGroup}` : selection.nodeType === 'head' ? 'Head' : 'All runtime Pods'
|
|
42
|
+
const select = (value: RayClusterPodSelection) => { setExpanded(false); onSelectPods?.(value) }
|
|
43
|
+
const row = (pod: WorkloadPodInfo) => <PodRow key={pod.name} name={pod.name} namespace={data.metadata.namespace} ready={pod.ready} healthLevel={pod.healthLevel} detail={workloadPodDetail(pod)} onNavigate={onNavigate} />
|
|
44
|
+
return <>
|
|
45
|
+
{stale && <AlertBanner variant="info" title="Status describes an earlier generation" message={`Specification generation ${data.metadata.generation}; controller observed ${status.observedGeneration}. Counts and conditions below retain that observation.`} />}
|
|
46
|
+
<Section title="Runtime Health" icon={Activity} defaultExpanded>
|
|
47
|
+
<PropertyList>
|
|
48
|
+
<Property label="Ray Version" value={spec.rayVersion || 'Not specified'} />
|
|
49
|
+
<Property label="Head" value={<Badge severity={stale ? 'neutral' : head?.status === 'True' ? 'success' : head?.status === 'False' ? 'warning' : 'neutral'}>{head?.status === 'True' ? 'Ready' : head?.status === 'False' ? 'Not ready' : 'Not reported'}{stale ? ' (stale)' : ''}</Badge>} />
|
|
50
|
+
<Property label="Suspension Requested" value={spec.suspend === true ? 'Yes' : 'No'} />
|
|
51
|
+
{status.state && <Property label="Reported State" value={status.state} />}
|
|
52
|
+
</PropertyList>
|
|
53
|
+
{head?.message && <p className="mt-2 whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{head.message}</p>}
|
|
54
|
+
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
55
|
+
{(['ready', 'running', 'desired'] as const).map(key => <div className="card-inner min-w-0" key={key}><p className="text-xs capitalize text-theme-text-secondary">{key} workers</p><p className="mt-1 text-lg font-semibold text-theme-text-primary">{counts[key] ?? 'Not reported'}</p></div>)}
|
|
56
|
+
</div>
|
|
57
|
+
<p className="mt-2 text-xs text-theme-text-tertiary">Controller-reported worker Pod counts. Running does not mean ready. These label-based counts can differ from the directly owned Pods below.</p>
|
|
58
|
+
{status.observedGeneration == null && <p className="mt-2 text-xs text-theme-text-tertiary">Observed generation is not reported.</p>}
|
|
59
|
+
{conditions.some((c: any) => c.type === 'RayClusterProvisioned' && c.status === 'True') && <p className="mt-2 text-xs text-theme-text-tertiary">Provisioned records initial provisioning, not continuing runtime health.</p>}
|
|
60
|
+
</Section>
|
|
61
|
+
{onSelectPods && <div ref={podsRef}><Section title="Runtime Pods" icon={Server} defaultExpanded>
|
|
62
|
+
<SelectMenu ariaLabel="Pod scope" className="mb-3" searchPlaceholder="Find a worker group…"
|
|
63
|
+
value={selection.workerGroup != null ? `group:${selection.workerGroup}` : selection.nodeType === 'head' ? 'head' : 'all'}
|
|
64
|
+
options={[{ value: 'all', label: 'All runtime Pods' }, { value: 'head', label: 'Head' }, ...groups.map((g: any) => ({ value: `group:${g.groupName}`, label: `Worker group: ${g.groupName}` }))]}
|
|
65
|
+
onChange={value => select(value === 'all' ? {} : value === 'head' ? { nodeType: 'head' } : { nodeType: 'worker', workerGroup: value.slice(6) })} />
|
|
66
|
+
<p className="mb-2 text-xs text-theme-text-tertiary">Directly controlled by this RayCluster incarnation. Cleanup Job descendants are excluded. Open a Pod for logs.</p>
|
|
67
|
+
{podsError ? <AlertBanner variant="warning" title="Pod evidence unavailable" message={podsError} /> : podsLoading ? <p className="text-sm text-theme-text-secondary">Reading runtime Pods…</p> : podEvidence ? <>
|
|
68
|
+
<p className="mb-2 text-sm text-theme-text-secondary">{scope} · {podEvidence.total} {podEvidence.total === 1 ? 'Pod' : 'Pods'}{podEvidence.truncated ? ` (showing ${podEvidence.pods.length}, failures first)` : ''}</p>
|
|
69
|
+
{podEvidence.total === 0 && <p className="text-sm text-theme-text-secondary">No directly owned Pods in this scope.</p>}
|
|
70
|
+
<PodListFrame expanded={expanded} hasOverflow={podEvidence.pods.length > 20} overflow={podEvidence.pods.slice(20).map(row)} toggle={<PodListToggle expanded={expanded} hiddenCount={podEvidence.pods.length - 20} label="Pods" onToggle={() => setExpanded(!expanded)} />}>{podEvidence.pods.slice(0,20).map(row)}</PodListFrame>
|
|
71
|
+
</> : <p className="text-sm text-theme-text-secondary">Pod evidence is not available.</p>}
|
|
72
|
+
</Section></div>}
|
|
73
|
+
<Section title={`Worker Groups (${groups.length})`} icon={Boxes} defaultExpanded>
|
|
74
|
+
<p className="mb-3 text-xs text-theme-text-tertiary">Declared sizing, not observed group health. Replicas can span multiple hosts; controller-reported worker counts above are Pods. In-tree autoscaling: {spec.enableInTreeAutoscaling === true ? 'enabled' : 'disabled'}.</p>
|
|
75
|
+
<div className="space-y-3">{groups.map((group: any) => <div className="card-inner min-w-0" key={group.groupName}>
|
|
76
|
+
<div className="mb-2 flex items-start justify-between gap-3"><span className="break-all text-sm font-medium text-theme-text-primary">{group.groupName}</span>{onSelectPods && <button className="shrink-0 text-xs text-accent-text hover:underline" onClick={() => { select({ nodeType: 'worker', workerGroup: group.groupName }); podsRef.current?.scrollIntoView({ block: 'start' }) }}>View Pods</button>}</div>
|
|
77
|
+
<PropertyList>
|
|
78
|
+
<Property label="Replicas" value={group.replicas ?? 'Not specified'} />
|
|
79
|
+
<Property label="Min / Max Replicas" value={`${group.minReplicas ?? 'Not specified'} / ${group.maxReplicas === 2147483647 ? 'No limit' : group.maxReplicas ?? 'Not specified'}`} />
|
|
80
|
+
<Property label="Hosts per Replica" value={group.numOfHosts ?? 'Not specified'} />
|
|
81
|
+
{group.suspend != null && <Property label="Controller Suspension" value={group.suspend ? 'Requested' : 'Not requested'} />}
|
|
82
|
+
</PropertyList>
|
|
83
|
+
</div>)}</div>
|
|
84
|
+
{groups.length === 0 && <p className="text-sm text-theme-text-secondary">No worker groups declared.</p>}
|
|
85
|
+
</Section>
|
|
86
|
+
{(owners.length > 0 || status.head?.podName || status.head?.serviceName) && <Section title="References" defaultExpanded><PropertyList>
|
|
87
|
+
{owners.map((owner: any) => <Property key={owner.uid} label="Controller" value={<ResourceLink kind={owner.kind === 'RayService' ? 'rayservices' : 'rayjobs'} group="ray.io" namespace={data.metadata.namespace} name={owner.name} onNavigate={onNavigate} />} />)}
|
|
88
|
+
{status.head?.podName && <Property label="Reported Head Pod" value={<ResourceLink kind="pods" namespace={data.metadata.namespace} name={status.head.podName} onNavigate={onNavigate} />} />}
|
|
89
|
+
{status.head?.serviceName && <Property label="Reported Head Service" value={<ResourceLink kind="services" namespace={data.metadata.namespace} name={status.head.serviceName} onNavigate={onNavigate} />} />}
|
|
90
|
+
</PropertyList></Section>}
|
|
91
|
+
<ConditionsSection conditions={conditions} getConditionTone={c => stale ? 'unknown' : rayClusterConditionTone(c)} defaultExpanded />
|
|
92
|
+
</>
|
|
93
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { RayServiceRenderer, rayServiceConditionTone, serveStateSeverity } from './RayServiceRenderer'
|
|
4
|
+
|
|
5
|
+
const condition = (type: string, status = 'True', extra = {}) => ({ type, status, observedGeneration: 3, ...extra })
|
|
6
|
+
const root = (status = {}, spec = {}) => ({ apiVersion: 'ray.io/v1', kind: 'RayService', metadata: { namespace: 'ml', name: 'serve', uid: 'root', generation: 3 }, spec, status: { observedGeneration: 3, ...status } })
|
|
7
|
+
const render = (data: any) => renderToStaticMarkup(<RayServiceRenderer data={data} />)
|
|
8
|
+
|
|
9
|
+
describe('RayService detail', () => {
|
|
10
|
+
it('keeps proxy readiness, upgrade and rollback independent', () => {
|
|
11
|
+
const html = render(root({ conditions: [condition('Ready'), condition('UpgradeInProgress'), condition('RollbackInProgress')], numServeEndpoints: 2 }))
|
|
12
|
+
for (const text of ['Proxy Readiness', 'Ready', 'UpgradeInProgress', 'RollbackInProgress', 'Reported Serve Endpoints', 'not that every application']) expect(html).toContain(text)
|
|
13
|
+
})
|
|
14
|
+
it('shows suspension intent separately from acknowledgement and retains observed teardown after intent changes', () => {
|
|
15
|
+
expect(render(root({}, { suspend: true }))).toContain('Suspension Requested')
|
|
16
|
+
expect(render(root({ conditions: [condition('Suspending')] }, { suspend: false }))).toContain('Suspending')
|
|
17
|
+
expect(render(root({ conditions: [condition('Suspended')] }))).toContain('Suspended')
|
|
18
|
+
})
|
|
19
|
+
it('does not turn absent maps or absent percentages into zeros or an outage', () => {
|
|
20
|
+
const html = render(root({ activeServiceStatus: { rayClusterName: 'old' }, pendingServiceStatus: { rayClusterName: 'new', targetCapacity: 0, trafficRoutedPercent: 0 } }))
|
|
21
|
+
for (const text of ['Active Revision', 'Pending Revision', 'Application status is not reported', 'endpoint still serves requests', '0%', 'configured route weight']) expect(html).toContain(text)
|
|
22
|
+
expect(html).not.toContain('Applications (0)')
|
|
23
|
+
expect(html.match(/Serve Target Capacity/g)).toHaveLength(1)
|
|
24
|
+
expect(html.match(/Configured Traffic Share/g)).toHaveLength(1)
|
|
25
|
+
})
|
|
26
|
+
it('supports traffic-only incremental observations and strategy None without inventing pending slots', () => {
|
|
27
|
+
const html = render(root({ activeServiceStatus: { rayClusterName: 'old', trafficRoutedPercent: 100 } }, { upgradeStrategy: { type: 'None' } }))
|
|
28
|
+
expect(html).toContain('100%')
|
|
29
|
+
expect(html).not.toContain('Serve Target Capacity')
|
|
30
|
+
expect(html).not.toContain('Pending Revision')
|
|
31
|
+
expect(html).toContain('None')
|
|
32
|
+
})
|
|
33
|
+
it('preserves app/deployment failures even when Ready and a runtime is healthy', () => {
|
|
34
|
+
const data = root({ conditions: [condition('Ready')], pendingServiceStatus: { rayClusterName: 'new', applicationStatuses: { model: { status: 'DEPLOY_FAILED', message: 'Model import failed', serveDeploymentStatuses: { decoder: { status: 'UNHEALTHY', message: 'Replica constructor failed' } } } } } })
|
|
35
|
+
const html = renderToStaticMarkup(<RayServiceRenderer data={data} runtimeEvidence={{ pending: <p>HeadPodReady</p> }} />)
|
|
36
|
+
for (const text of ['Ready', 'HeadPodReady', 'DEPLOY_FAILED', 'Model import failed', 'UNHEALTHY', 'Replica constructor failed']) expect(html).toContain(text)
|
|
37
|
+
})
|
|
38
|
+
it('never trusts embedded cluster conditions and bounds large application maps with nested failures first', () => {
|
|
39
|
+
const apps = Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`app-${i}`, { status: 'RUNNING' }])) as Record<string, any>
|
|
40
|
+
apps['zz-failed-deployment'] = { status: 'RUNNING', serveDeploymentStatuses: { replica: { status: 'UNHEALTHY', message: 'Visible failure' } } }
|
|
41
|
+
const html = render(root({ activeServiceStatus: { rayClusterName: 'old', rayClusterStatus: { conditions: [condition('HeadPodReady', 'False', { message: 'OBSOLETE EMBEDDED FAILURE' })] }, applicationStatuses: apps } }))
|
|
42
|
+
expect(html).toContain('Visible failure')
|
|
43
|
+
expect(html).toContain('Showing 20 of 22 applications')
|
|
44
|
+
expect(html).not.toContain('OBSOLETE EMBEDDED FAILURE')
|
|
45
|
+
})
|
|
46
|
+
it('labels missing and stale observations without inventing conditions', () => {
|
|
47
|
+
expect(render(root({ observedGeneration: 2 }))).toContain('Observed generation differs')
|
|
48
|
+
expect(render(root({ observedGeneration: undefined }))).toContain('not reported an observed generation')
|
|
49
|
+
expect(render(root({ conditions: [condition('Ready', 'True', { observedGeneration: 2 })] }))).not.toContain('(stale)')
|
|
50
|
+
expect(rayServiceConditionTone(condition('Ready', 'True', { observedGeneration: 2 }))).toBe('ok')
|
|
51
|
+
for (const entry of [condition('Suspending'), condition('Ready', 'False', { reason: 'ValidationFailed' }), condition('Ready', 'False', { reason: 'InitializingTimeout' })]) {
|
|
52
|
+
const html = render(root({ observedGeneration: 2, conditions: [entry] }, { suspend: false }))
|
|
53
|
+
expect(html).not.toContain('(stale)')
|
|
54
|
+
expect(html).toContain('Observed generation differs')
|
|
55
|
+
}
|
|
56
|
+
const suspended = render(root({ observedGeneration: 2, conditions: [condition('Suspended')] }, { suspend: true }))
|
|
57
|
+
expect(suspended).toContain('Reconciliation is paused')
|
|
58
|
+
expect(suspended).not.toContain('Observed generation differs')
|
|
59
|
+
expect(serveStateSeverity('FUTURE_STATE')).toBe('neutral')
|
|
60
|
+
expect(serveStateSeverity('DELETING')).toBe('warning')
|
|
61
|
+
})
|
|
62
|
+
})
|