@skyhook-io/k8s-ui 1.13.3 → 1.13.4
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 +1 -1
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +964 -96
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +198 -0
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- package/src/utils/workload-rollout.ts +292 -0
|
@@ -2,6 +2,8 @@ import { useState } from 'react'
|
|
|
2
2
|
import { Info, CheckCircle, XCircle, AlertCircle, ChevronRight, ChevronDown, FileCode, AlertTriangle, Layers } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
5
|
+
import { getGenericResourceStatus } from '../generic-status'
|
|
6
|
+
import type { HealthLevel } from '../resource-utils'
|
|
5
7
|
|
|
6
8
|
interface GenericRendererProps {
|
|
7
9
|
data: any
|
|
@@ -13,7 +15,7 @@ export function GenericRenderer({ data }: GenericRendererProps) {
|
|
|
13
15
|
const conditions = status.conditions
|
|
14
16
|
|
|
15
17
|
// Determine health status from conditions or status fields
|
|
16
|
-
const healthStatus = getHealthStatus(
|
|
18
|
+
const healthStatus = getHealthStatus(data)
|
|
17
19
|
|
|
18
20
|
// Extract important fields from spec and status
|
|
19
21
|
const statusFields = getImportantFields(status, 'status')
|
|
@@ -138,89 +140,22 @@ interface HealthStatusResult {
|
|
|
138
140
|
message?: string
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return { type: 'healthy', label: phase }
|
|
153
|
-
}
|
|
154
|
-
if (degradedPhases.includes(phase)) {
|
|
155
|
-
return { type: 'degraded', label: phase, message: status.message || status.reason }
|
|
156
|
-
}
|
|
157
|
-
if (unhealthyPhases.includes(phase)) {
|
|
158
|
-
return { type: 'unhealthy', label: phase, message: status.message || status.reason }
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Check conditions
|
|
163
|
-
if (conditions && Array.isArray(conditions) && conditions.length > 0) {
|
|
164
|
-
// Look for Ready or Available condition
|
|
165
|
-
const readyCondition = conditions.find((c: any) =>
|
|
166
|
-
c.type === 'Ready' || c.type === 'Available' || c.type === 'Healthy'
|
|
167
|
-
)
|
|
168
|
-
if (readyCondition) {
|
|
169
|
-
if (readyCondition.status === 'True') {
|
|
170
|
-
return { type: 'healthy', label: 'Ready' }
|
|
171
|
-
}
|
|
172
|
-
if (readyCondition.status === 'False') {
|
|
173
|
-
return {
|
|
174
|
-
type: 'unhealthy',
|
|
175
|
-
label: 'Not Ready',
|
|
176
|
-
message: readyCondition.message || readyCondition.reason
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Check for any False conditions that indicate problems
|
|
182
|
-
const problemConditions = conditions.filter((c: any) =>
|
|
183
|
-
c.status === 'False' && ['Ready', 'Available', 'Healthy', 'Initialized'].includes(c.type)
|
|
184
|
-
)
|
|
185
|
-
if (problemConditions.length > 0) {
|
|
186
|
-
const problem = problemConditions[0]
|
|
187
|
-
return {
|
|
188
|
-
type: 'unhealthy',
|
|
189
|
-
label: `${problem.type}: False`,
|
|
190
|
-
message: problem.message || problem.reason
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Check for warning conditions
|
|
195
|
-
const warningConditions = conditions.filter((c: any) =>
|
|
196
|
-
c.status === 'True' && ['Degraded', 'Warning', 'ScalingLimited'].includes(c.type)
|
|
197
|
-
)
|
|
198
|
-
if (warningConditions.length > 0) {
|
|
199
|
-
const warning = warningConditions[0]
|
|
200
|
-
return {
|
|
201
|
-
type: 'degraded',
|
|
202
|
-
label: warning.type,
|
|
203
|
-
message: warning.message || warning.reason
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Check replica-based status
|
|
209
|
-
if (status.replicas !== undefined) {
|
|
210
|
-
const desired = status.replicas
|
|
211
|
-
const ready = status.readyReplicas || status.availableReplicas || 0
|
|
212
|
-
if (desired > 0 && ready >= desired) {
|
|
213
|
-
return { type: 'healthy', label: `${ready}/${desired} Ready` }
|
|
214
|
-
}
|
|
215
|
-
if (desired > 0 && ready > 0) {
|
|
216
|
-
return { type: 'degraded', label: `${ready}/${desired} Ready` }
|
|
217
|
-
}
|
|
218
|
-
if (desired > 0 && ready === 0) {
|
|
219
|
-
return { type: 'unhealthy', label: `0/${desired} Ready` }
|
|
220
|
-
}
|
|
221
|
-
}
|
|
143
|
+
// This renderer's banner has four tones, while the shared derivation speaks the
|
|
144
|
+
// full HealthLevel vocabulary. `alert` folds into unhealthy and `neutral` into
|
|
145
|
+
// unknown — the banner has no distinct treatment for either.
|
|
146
|
+
const BANNER_TYPE: Record<HealthLevel, HealthType> = {
|
|
147
|
+
healthy: 'healthy',
|
|
148
|
+
degraded: 'degraded',
|
|
149
|
+
alert: 'unhealthy',
|
|
150
|
+
unhealthy: 'unhealthy',
|
|
151
|
+
neutral: 'unknown',
|
|
152
|
+
unknown: 'unknown',
|
|
153
|
+
}
|
|
222
154
|
|
|
223
|
-
|
|
155
|
+
function getHealthStatus(resource: any): HealthStatusResult | null {
|
|
156
|
+
const derived = getGenericResourceStatus(resource)
|
|
157
|
+
if (!derived) return null
|
|
158
|
+
return { type: BANNER_TYPE[derived.tone], label: derived.text, message: derived.reason }
|
|
224
159
|
}
|
|
225
160
|
|
|
226
161
|
// ============================================================================
|
|
@@ -88,4 +88,32 @@ describe('HPARenderer', () => {
|
|
|
88
88
|
expect(html).toContain('TooManyReplicas')
|
|
89
89
|
expect(html).not.toContain('the desired replica count is more than the maximum replica count')
|
|
90
90
|
})
|
|
91
|
+
|
|
92
|
+
it('preserves minReplicas zero and renders ScaledToZero as neutral evidence', () => {
|
|
93
|
+
const html = renderToString(
|
|
94
|
+
<HPARenderer
|
|
95
|
+
data={{
|
|
96
|
+
...baseHPA,
|
|
97
|
+
spec: { ...baseHPA.spec, minReplicas: 0 },
|
|
98
|
+
status: {
|
|
99
|
+
currentReplicas: 0,
|
|
100
|
+
desiredReplicas: 0,
|
|
101
|
+
conditions: [
|
|
102
|
+
{
|
|
103
|
+
type: 'ScaledToZero',
|
|
104
|
+
status: 'True',
|
|
105
|
+
reason: 'SucceededRescale',
|
|
106
|
+
message: 'the HPA controller scaled the target to zero',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
}}
|
|
111
|
+
/>,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(html).toMatch(/>Min<\/span><span[^>]*>0<\/span>/)
|
|
115
|
+
expect(html).toContain('ScaledToZero')
|
|
116
|
+
expect(html).toContain('bg-gray-400/20')
|
|
117
|
+
expect(html).not.toContain('failing')
|
|
118
|
+
})
|
|
91
119
|
})
|
|
@@ -34,6 +34,11 @@ function hpaBadgeSeverity(state: HPADiagnosisState): BadgeSeverity {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function hpaConditionTone(condition: any): ConditionTone | undefined {
|
|
37
|
+
if (condition?.type === 'ScaledToZero') {
|
|
38
|
+
if (condition.status === 'False') return 'ok'
|
|
39
|
+
if (condition.status === 'True') return 'unknown'
|
|
40
|
+
return 'unknown'
|
|
41
|
+
}
|
|
37
42
|
if (condition?.type !== 'ScalingLimited') return undefined
|
|
38
43
|
if (condition.status === 'False') return 'ok'
|
|
39
44
|
if (condition.status !== 'True') return 'unknown'
|
|
@@ -109,7 +114,7 @@ export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: H
|
|
|
109
114
|
} />
|
|
110
115
|
<Property label="Current" value={status.currentReplicas} />
|
|
111
116
|
<Property label="Desired" value={status.desiredReplicas} />
|
|
112
|
-
<Property label="Min" value={spec.minReplicas
|
|
117
|
+
<Property label="Min" value={spec.minReplicas ?? 1} />
|
|
113
118
|
<Property label="Max" value={spec.maxReplicas} />
|
|
114
119
|
{status.lastScaleTime && <Property label="Last Scale" value={formatAge(status.lastScaleTime)} />}
|
|
115
120
|
</PropertyList>
|
|
@@ -21,7 +21,8 @@ export function MetricsUnavailableNotice({ rawError, diagnosis }: MetricsUnavail
|
|
|
21
21
|
This panel uses Kubernetes metrics.k8s.io for live CPU and memory; Prometheus data does not fill it.
|
|
22
22
|
</span>
|
|
23
23
|
<span className="block">
|
|
24
|
-
{diagnosis ||
|
|
24
|
+
{diagnosis ||
|
|
25
|
+
"Check that metrics-server is installed and healthy, and that its metrics.k8s.io APIService is Available."}
|
|
25
26
|
</span>
|
|
26
27
|
<span className="block">
|
|
27
28
|
Raw error: <span className="font-mono break-words">{rawError}</span>
|
|
@@ -20,4 +20,48 @@ describe('PVCRenderer', () => {
|
|
|
20
20
|
expect(html).not.toContain('This is normal')
|
|
21
21
|
expect(html).not.toContain('expected indefinitely')
|
|
22
22
|
})
|
|
23
|
+
|
|
24
|
+
it('surfaces an unused claim as informational cleanup evidence', () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
<PVCRenderer
|
|
27
|
+
data={{
|
|
28
|
+
metadata: { name: 'archive', namespace: 'demo' },
|
|
29
|
+
status: {
|
|
30
|
+
phase: 'Bound',
|
|
31
|
+
conditions: [
|
|
32
|
+
{
|
|
33
|
+
type: 'Unused',
|
|
34
|
+
status: 'True',
|
|
35
|
+
lastTransitionTime: '2026-08-28T12:00:00Z',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
}}
|
|
40
|
+
/>,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(html).toContain('Unused for about')
|
|
44
|
+
expect(html).toContain('No non-terminal Pod currently references this claim')
|
|
45
|
+
expect(html).toContain('Verify its retention policy and data ownership')
|
|
46
|
+
expect(html).toContain('bg-gray-400/20')
|
|
47
|
+
expect(html).not.toContain('failing')
|
|
48
|
+
expect(html).not.toContain('bg-emerald-500/20')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not show cleanup guidance while Unused is false or absent', () => {
|
|
52
|
+
for (const conditions of [
|
|
53
|
+
[{ type: 'Unused', status: 'False' }],
|
|
54
|
+
undefined,
|
|
55
|
+
]) {
|
|
56
|
+
const html = renderToString(
|
|
57
|
+
<PVCRenderer
|
|
58
|
+
data={{
|
|
59
|
+
metadata: { name: 'active', namespace: 'demo' },
|
|
60
|
+
status: { phase: 'Bound', conditions },
|
|
61
|
+
}}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
expect(html).not.toContain('No non-terminal Pod currently references this claim')
|
|
65
|
+
}
|
|
66
|
+
})
|
|
23
67
|
})
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { HardDrive } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown} from '../../ui/drawer-components'
|
|
4
|
+
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown, type ConditionTone } from '../../ui/drawer-components'
|
|
5
|
+
import { formatAge } from '../resource-utils'
|
|
5
6
|
|
|
6
7
|
interface PVCRendererProps {
|
|
7
8
|
data: any
|
|
@@ -22,6 +23,11 @@ function formatAccessModes(modes: string[] | undefined): string | undefined {
|
|
|
22
23
|
return modes.map(m => accessModeShorthand[m] || m).join(', ')
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function pvcConditionTone(condition: any): ConditionTone | undefined {
|
|
27
|
+
if (condition?.type !== 'Unused') return undefined
|
|
28
|
+
return condition.status === 'True' ? 'unknown' : condition.status === 'False' ? 'ok' : 'unknown'
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProps) {
|
|
26
32
|
const status = data.status || {}
|
|
27
33
|
const spec = data.spec || {}
|
|
@@ -39,6 +45,9 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
39
45
|
const bindCompleted = annotations['pv.kubernetes.io/bind-completed']
|
|
40
46
|
const hasProvisionerInfo = provisioner || selectedNode || bindCompleted
|
|
41
47
|
const operationalIssuesShown = useOperationalIssuesShown()
|
|
48
|
+
const unused = Array.isArray(status.conditions)
|
|
49
|
+
? status.conditions.find((condition: any) => condition?.type === 'Unused' && condition.status === 'True')
|
|
50
|
+
: undefined
|
|
42
51
|
|
|
43
52
|
return (
|
|
44
53
|
<>
|
|
@@ -59,6 +68,14 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
59
68
|
/>
|
|
60
69
|
)}
|
|
61
70
|
|
|
71
|
+
{unused && (
|
|
72
|
+
<AlertBanner
|
|
73
|
+
variant="info"
|
|
74
|
+
title={unused.lastTransitionTime ? `Unused for about ${formatAge(unused.lastTransitionTime)}` : 'Unused'}
|
|
75
|
+
message="No non-terminal Pod currently references this claim. Verify its retention policy and data ownership before deleting it."
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
|
|
62
79
|
<Section title="Status" icon={HardDrive}>
|
|
63
80
|
<PropertyList>
|
|
64
81
|
<Property
|
|
@@ -98,7 +115,7 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
|
|
|
98
115
|
|
|
99
116
|
{extraSections}
|
|
100
117
|
|
|
101
|
-
<ConditionsSection conditions={status.conditions} />
|
|
118
|
+
<ConditionsSection conditions={status.conditions} getConditionTone={pvcConditionTone} />
|
|
102
119
|
</>
|
|
103
120
|
)
|
|
104
121
|
}
|
|
@@ -102,6 +102,33 @@ describe('rolloutActions state gating', () => {
|
|
|
102
102
|
expect(blockedReason(settled, 'abort')).toBe('Rollout is fully promoted — roll back instead')
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
it('offers the promotion verbs while the controller is still behind the spec', () => {
|
|
106
|
+
// A rollback bumps the generation; until the controller writes status the phase is
|
|
107
|
+
// still the pre-rollback one, and this is exactly when Promote full is needed.
|
|
108
|
+
const settled = canaryRollout({ phase: 'Healthy', currentStepIndex: 4 }, 4)
|
|
109
|
+
const stale = {
|
|
110
|
+
...settled,
|
|
111
|
+
metadata: { ...settled.metadata, generation: 4 },
|
|
112
|
+
status: { ...settled.status, observedGeneration: '3' },
|
|
113
|
+
}
|
|
114
|
+
expect(blockedReason(stale, 'promote-full')).toBeUndefined()
|
|
115
|
+
// Abort deliberately keeps its phase-based gate: in this window status.stable still
|
|
116
|
+
// points at the revision the operator just rolled away from.
|
|
117
|
+
expect(blockedReason(stale, 'abort')).toBe('Rollout is fully promoted — roll back instead')
|
|
118
|
+
// Once the controller catches up the settled reading is trustworthy again.
|
|
119
|
+
const caughtUp = { ...stale, status: { ...settled.status, observedGeneration: '4' } }
|
|
120
|
+
expect(blockedReason(caughtUp, 'promote-full')).toBe('Nothing left to promote')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('keeps Promote full offered on a workloadRef Rollout, whose template lives elsewhere', () => {
|
|
124
|
+
const settled = canaryRollout({ phase: 'Healthy', currentStepIndex: 4 }, 4)
|
|
125
|
+
const workloadRef = {
|
|
126
|
+
...settled,
|
|
127
|
+
spec: { ...settled.spec, workloadRef: { kind: 'Deployment', name: 'web' } },
|
|
128
|
+
}
|
|
129
|
+
expect(blockedReason(workloadRef, 'promote-full')).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
105
132
|
it('routes an aborted rollout through Retry before it will promote again', () => {
|
|
106
133
|
const aborted = canaryRollout({ phase: 'Degraded', abort: true, currentStepIndex: 1 })
|
|
107
134
|
expect(blockedReason(aborted, 'promote-full')).toBe('Retry the rollout first')
|
|
@@ -138,6 +165,14 @@ describe('rolloutActions state gating', () => {
|
|
|
138
165
|
})
|
|
139
166
|
})
|
|
140
167
|
|
|
168
|
+
describe('RolloutRenderer step display', () => {
|
|
169
|
+
it('does not render beyond the final declared canary step', () => {
|
|
170
|
+
const html = renderToString(<RolloutRenderer data={canaryRollout({ phase: 'Healthy', currentStepIndex: 4 }, 4)} />)
|
|
171
|
+
expect(html).toContain('4/4')
|
|
172
|
+
expect(html).not.toContain('5/4')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
141
176
|
describe('rolloutProblems', () => {
|
|
142
177
|
it('reports an aborted rollout once, not once per matching branch', () => {
|
|
143
178
|
const problems = rolloutProblems(
|
|
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { AlertBanner, Section, PropertyList, Property, ConditionsSection, PodTemplateSection, type ConditionTone } from '../../ui/drawer-components'
|
|
5
5
|
import { Tooltip } from '../../ui/Tooltip'
|
|
6
6
|
import { ConfirmDialog } from '../../ui/ConfirmDialog'
|
|
7
|
-
import { formatAge } from '../resource-utils'
|
|
7
|
+
import { formatAge, getRolloutStep } from '../resource-utils'
|
|
8
8
|
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
9
9
|
|
|
10
10
|
export type RolloutAction = 'abort' | 'retry' | 'promote' | 'promote-full' | 'skip-step'
|
|
@@ -46,6 +46,22 @@ interface RolloutRendererProps {
|
|
|
46
46
|
pendingAction?: RolloutAction | null
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Has the controller reconciled the template as it stands? A rollback changes the
|
|
50
|
+
// template before the controller writes status, and until it catches up a Healthy phase
|
|
51
|
+
// still describes the previous revision.
|
|
52
|
+
//
|
|
53
|
+
// A workloadRef Rollout keeps its template on another object, and a rollback patches
|
|
54
|
+
// THAT object — this payload carries neither its generation nor a way to reach it, so
|
|
55
|
+
// the honest answer is "unknown", not "yes".
|
|
56
|
+
function controllerCaughtUp(data: any): boolean {
|
|
57
|
+
if (data?.spec?.workloadRef) return false
|
|
58
|
+
const observed = data?.status?.observedGeneration
|
|
59
|
+
if (observed === undefined || observed === null) return true
|
|
60
|
+
const seen = Number(observed)
|
|
61
|
+
if (!Number.isFinite(seen)) return true
|
|
62
|
+
return seen >= Number(data?.metadata?.generation ?? 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
// A denied capability omits the verb entirely; `blocked` covers wrong-state only.
|
|
50
66
|
export function rolloutActions(data: any, capabilities?: RolloutCapabilities): RolloutActionSpec[] {
|
|
51
67
|
if (!capabilities) return []
|
|
@@ -55,6 +71,9 @@ export function rolloutActions(data: any, capabilities?: RolloutCapabilities): R
|
|
|
55
71
|
const steps = data?.spec?.strategy?.canary?.steps || []
|
|
56
72
|
const isAborted = status.abort === true
|
|
57
73
|
const isSettled = phase === 'Healthy' && !isAborted
|
|
74
|
+
// Promote full is the one verb needed DURING the window where status is stale, so it
|
|
75
|
+
// only claims "nothing to promote" when the controller is known to have caught up.
|
|
76
|
+
const isFullyPromoted = isSettled && controllerCaughtUp(data)
|
|
58
77
|
const stepsRemaining = steps.length > 0 && (status.currentStepIndex ?? 0) < steps.length
|
|
59
78
|
// Mid-analysis a canary is Progressing with nothing paused, and promote advances
|
|
60
79
|
// the step rather than clearing a pause — so gating on Paused alone hides it.
|
|
@@ -87,7 +106,7 @@ export function rolloutActions(data: any, capabilities?: RolloutCapabilities): R
|
|
|
87
106
|
pendingLabel: 'Promoting…',
|
|
88
107
|
icon: FastForward,
|
|
89
108
|
hint: 'Skip every remaining step, pause, and analysis — emergency hotfix path',
|
|
90
|
-
blocked:
|
|
109
|
+
blocked: isFullyPromoted ? 'Nothing left to promote' : isAborted ? 'Retry the rollout first' : undefined,
|
|
91
110
|
confirm: {
|
|
92
111
|
title: 'Promote fully?',
|
|
93
112
|
message:
|
|
@@ -413,7 +432,7 @@ export function RolloutRenderer({ data, onNavigate, capabilities, onAction, pend
|
|
|
413
432
|
<>
|
|
414
433
|
<Property label="Strategy" value="Canary" />
|
|
415
434
|
{steps.length > 0 && currentStepIndex !== undefined && (
|
|
416
|
-
<Property label="Current Step" value={
|
|
435
|
+
<Property label="Current Step" value={getRolloutStep(data)} />
|
|
417
436
|
)}
|
|
418
437
|
{status.canary?.weights?.canary?.weight !== undefined && (
|
|
419
438
|
<Property label="Canary Weight" value={`${status.canary.weights.canary.weight}%`} />
|
|
@@ -9,6 +9,89 @@ const deployment = {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
describe('WorkloadRenderer', () => {
|
|
12
|
+
it('shows a calm rollout notice even when every desired replica remains ready', () => {
|
|
13
|
+
const html = renderToString(
|
|
14
|
+
<WorkloadRenderer
|
|
15
|
+
kind="deployments"
|
|
16
|
+
data={{
|
|
17
|
+
...deployment,
|
|
18
|
+
metadata: { ...deployment.metadata, generation: 2 },
|
|
19
|
+
status: { observedGeneration: 2, replicas: 4, readyReplicas: 4, availableReplicas: 4, updatedReplicas: 2 },
|
|
20
|
+
}}
|
|
21
|
+
/>,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
expect(html).toContain('Rolling out')
|
|
25
|
+
expect(html).toContain('2/3 updated · 4 available')
|
|
26
|
+
expect(html).not.toContain('Issues Detected')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('uses new-revision Pod failure evidence in the rollout notice', () => {
|
|
30
|
+
const html = renderToString(
|
|
31
|
+
<WorkloadRenderer
|
|
32
|
+
kind="deployments"
|
|
33
|
+
data={{
|
|
34
|
+
...deployment,
|
|
35
|
+
metadata: { ...deployment.metadata, generation: 2 },
|
|
36
|
+
status: {
|
|
37
|
+
observedGeneration: 2,
|
|
38
|
+
replicas: 4,
|
|
39
|
+
readyReplicas: 2,
|
|
40
|
+
availableReplicas: 2,
|
|
41
|
+
unavailableReplicas: 1,
|
|
42
|
+
updatedReplicas: 1,
|
|
43
|
+
conditions: [{ type: 'Progressing', status: 'False', message: 'Progress deadline exceeded' }],
|
|
44
|
+
},
|
|
45
|
+
}}
|
|
46
|
+
workloadPods={[{
|
|
47
|
+
name: 'api-new-revision',
|
|
48
|
+
containers: ['api'],
|
|
49
|
+
ready: false,
|
|
50
|
+
healthLevel: 'unhealthy',
|
|
51
|
+
reason: 'ImagePullBackOff',
|
|
52
|
+
updatedRevision: true,
|
|
53
|
+
}]}
|
|
54
|
+
/>,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(html).toContain('New revision cannot start')
|
|
58
|
+
expect(html).toContain('ImagePullBackOff · Pod api-new-revision')
|
|
59
|
+
expect(html).toContain('Issues Detected')
|
|
60
|
+
expect(html).toContain('Progressing: Progress deadline exceeded')
|
|
61
|
+
expect(html).not.toContain('Rolling out')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('explains that OnDelete needs a manual Pod restart', () => {
|
|
65
|
+
const html = renderToString(
|
|
66
|
+
<WorkloadRenderer
|
|
67
|
+
kind="statefulsets"
|
|
68
|
+
data={{
|
|
69
|
+
metadata: { name: 'db', namespace: 'prod', generation: 3 },
|
|
70
|
+
spec: { replicas: 3, updateStrategy: { type: 'OnDelete' } },
|
|
71
|
+
status: { observedGeneration: 3, replicas: 3, readyReplicas: 3, updatedReplicas: 0 },
|
|
72
|
+
}}
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(html).toContain('Waiting for Pod restart')
|
|
77
|
+
expect(html).toContain('OnDelete strategy · 0/3 updated')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('keeps outage problems visible when an OnDelete workload has no update pending', () => {
|
|
81
|
+
const html = renderToString(
|
|
82
|
+
<WorkloadRenderer
|
|
83
|
+
kind="statefulsets"
|
|
84
|
+
data={{
|
|
85
|
+
metadata: { name: 'db', namespace: 'prod', generation: 3 },
|
|
86
|
+
spec: { replicas: 3, updateStrategy: { type: 'OnDelete' } },
|
|
87
|
+
status: { observedGeneration: 3, replicas: 3, readyReplicas: 0, updatedReplicas: 3 },
|
|
88
|
+
}}
|
|
89
|
+
/>,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(html).toContain('3 of 3 replicas are not ready')
|
|
93
|
+
})
|
|
94
|
+
|
|
12
95
|
it('enables manual scaling when no replica controller targets the workload', () => {
|
|
13
96
|
const html = renderToString(
|
|
14
97
|
<WorkloadRenderer kind="deployments" data={deployment} onScale={async () => {}} />,
|