@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.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +39 -7
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +964 -96
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  36. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  40. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  41. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  42. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  43. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  44. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  45. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  46. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  47. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  48. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  49. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  50. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  51. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  52. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  53. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  54. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  55. package/src/components/resources/resource-utils-hpa.ts +6 -2
  56. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  57. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  58. package/src/components/resources/resource-utils-kai.ts +105 -0
  59. package/src/components/resources/resource-utils-kaito.ts +114 -0
  60. package/src/components/resources/resource-utils-kserve.ts +186 -0
  61. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  62. package/src/components/resources/resource-utils-kueue.ts +198 -0
  63. package/src/components/resources/resource-utils-nim.ts +109 -0
  64. package/src/components/resources/resource-utils-ray.ts +214 -0
  65. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  66. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  67. package/src/components/resources/resource-utils-volcano.ts +176 -0
  68. package/src/components/resources/resource-utils.ts +156 -47
  69. package/src/components/resources/resources-column-filter.test.ts +27 -1
  70. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  71. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  72. package/src/components/shared/EditableYamlView.tsx +4 -2
  73. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  74. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  75. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  76. package/src/components/shared/SetImageDialog.test.ts +129 -0
  77. package/src/components/shared/SetImageDialog.tsx +577 -0
  78. package/src/components/shared/index.ts +1 -0
  79. package/src/components/shared/revision-role-badges.test.ts +56 -4
  80. package/src/components/timeline/TimelineList.tsx +19 -3
  81. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  82. package/src/components/trace/ReachabilityView.tsx +1 -1
  83. package/src/components/ui/drawer-components.tsx +12 -0
  84. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  85. package/src/components/workload/WorkloadView.tsx +144 -81
  86. package/src/hooks/useProgressiveRefresh.ts +37 -0
  87. package/src/source-hygiene.test.ts +28 -0
  88. package/src/theme/tailwind-theme.css +4 -0
  89. package/src/types/core.ts +55 -0
  90. package/src/utils/api-resources.test.ts +40 -1
  91. package/src/utils/api-resources.ts +11 -1
  92. package/src/utils/applications.ts +50 -0
  93. package/src/utils/bulk-workload-actions.test.ts +20 -0
  94. package/src/utils/bulk-workload-actions.ts +15 -0
  95. package/src/utils/navigation.test.ts +31 -1
  96. package/src/utils/navigation.ts +21 -2
  97. 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(status, conditions)
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
- function getHealthStatus(status: any, conditions?: any[]): HealthStatusResult | null {
142
- if (!status) return null
143
-
144
- // Check phase first (common pattern)
145
- if (status.phase) {
146
- const phase = String(status.phase)
147
- const healthyPhases = ['Running', 'Active', 'Succeeded', 'Ready', 'Healthy', 'Available', 'Bound', 'Complete']
148
- const degradedPhases = ['Pending', 'Progressing', 'Unknown', 'Terminating', 'Waiting']
149
- const unhealthyPhases = ['Failed', 'Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull']
150
-
151
- if (healthyPhases.includes(phase)) {
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
- return null
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 || 1} />
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 || 'Check that metrics-server is installed and healthy, and that the v1beta1.metrics.k8s.io APIService is Available.'}
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: isSettled ? 'Nothing left to promote' : isAborted ? 'Retry the rollout first' : undefined,
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={`${currentStepIndex}/${steps.length}`} />
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 () => {}} />,