@skyhook-io/k8s-ui 1.13.3 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/package.json +4 -4
  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 +966 -98
  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/kyverno-legacy-action.test.ts +100 -1
  28. package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
  29. package/src/components/resources/printer-columns.test.ts +298 -0
  30. package/src/components/resources/printer-columns.ts +249 -0
  31. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
  32. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
  33. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
  34. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
  35. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  36. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  37. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  38. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  39. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  40. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
  41. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  42. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  43. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  44. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
  45. package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
  46. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  47. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  48. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
  49. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
  52. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  53. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  54. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  55. package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
  56. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  57. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  58. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  59. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  60. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  61. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  62. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  63. package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
  64. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  65. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  66. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  67. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  68. package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
  69. package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
  70. package/src/components/resources/resource-utils-cnpg.ts +132 -7
  71. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +236 -0
  72. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  73. package/src/components/resources/resource-utils-hpa.ts +6 -2
  74. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  75. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  76. package/src/components/resources/resource-utils-kai.ts +105 -0
  77. package/src/components/resources/resource-utils-kaito.ts +114 -0
  78. package/src/components/resources/resource-utils-kserve.ts +186 -0
  79. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  80. package/src/components/resources/resource-utils-kueue.ts +200 -0
  81. package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
  82. package/src/components/resources/resource-utils-kyverno.ts +51 -2
  83. package/src/components/resources/resource-utils-nim.ts +109 -0
  84. package/src/components/resources/resource-utils-ray.ts +214 -0
  85. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  86. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  87. package/src/components/resources/resource-utils-velero.ts +32 -0
  88. package/src/components/resources/resource-utils-volcano.ts +176 -0
  89. package/src/components/resources/resource-utils.ts +156 -47
  90. package/src/components/resources/resources-column-filter.test.ts +27 -1
  91. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  92. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  93. package/src/components/shared/EditableYamlView.tsx +4 -2
  94. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  95. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  96. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  97. package/src/components/shared/SetImageDialog.test.ts +129 -0
  98. package/src/components/shared/SetImageDialog.tsx +577 -0
  99. package/src/components/shared/index.ts +1 -0
  100. package/src/components/shared/revision-role-badges.test.ts +56 -4
  101. package/src/components/timeline/TimelineList.tsx +19 -3
  102. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  103. package/src/components/trace/ReachabilityView.tsx +1 -1
  104. package/src/components/ui/SelectMenu.tsx +10 -1
  105. package/src/components/ui/drawer-components.tsx +12 -0
  106. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  107. package/src/components/workload/WorkloadView.tsx +144 -81
  108. package/src/hooks/useProgressiveRefresh.ts +37 -0
  109. package/src/source-hygiene.test.ts +28 -0
  110. package/src/theme/tailwind-theme.css +4 -0
  111. package/src/types/core.ts +55 -0
  112. package/src/utils/api-resources.test.ts +40 -1
  113. package/src/utils/api-resources.ts +11 -1
  114. package/src/utils/applications.ts +50 -0
  115. package/src/utils/bulk-workload-actions.test.ts +20 -0
  116. package/src/utils/bulk-workload-actions.ts +15 -0
  117. package/src/utils/navigation.test.ts +31 -1
  118. package/src/utils/navigation.ts +21 -2
  119. package/src/utils/workload-rollout.ts +292 -0
@@ -11,9 +11,17 @@ import {
11
11
  getKyvernoEnforcementPosture,
12
12
  getKyvernoResourceRules,
13
13
  getKyvernoEffectiveFailurePolicy,
14
+ getKyvernoValidationActions,
15
+ getKyvernoEvaluationMode,
16
+ isKyvernoAdmissionEnabled,
14
17
  type KyvernoPolicyFamily,
15
18
  } from '../resource-utils-kyverno-modern'
16
- import { getKyvernoPolicyAction } from '../resource-utils-kyverno'
19
+ import {
20
+ getKyvernoEnforcement,
21
+ getKyvernoPolicyAction,
22
+ getKyvernoPolicyAdmission,
23
+ isKyvernoEnforceAction,
24
+ } from '../resource-utils-kyverno'
17
25
  import type {
18
26
  PolicyCoverageResponse,
19
27
  PolicyCoverageRule,
@@ -160,7 +168,7 @@ function legacyActionsAgree(resource: any): boolean {
160
168
  : []),
161
169
  ].filter(Boolean)
162
170
  const effective = declared.length > 0 ? declared : [spec]
163
- return effective.includes('Enforce')
171
+ return effective.some(isKyvernoEnforceAction)
164
172
  })
165
173
  return answers.every((a: boolean) => a === answers[0])
166
174
  }
@@ -229,17 +237,40 @@ function policyFamilyFor(
229
237
 
230
238
  /**
231
239
  * The families answer from different fields: legacy carries
232
- * `spec.validationFailureAction` (or a per-rule `validate.failureAction`), while
233
- * `getKyvernoEnforcementPosture` reads `spec.validationActions`, absent on
234
- * legacy — which makes an Enforce policy read as audit-only.
240
+ * `spec.validationFailureAction` (or a per-rule `validate.failureAction`) gated
241
+ * by `spec.admission`, while `getKyvernoEnforcementPosture` reads
242
+ * `spec.validationActions` and `spec.evaluation.admission.enabled`, both absent
243
+ * on legacy — which makes an Enforce policy read as audit-only.
235
244
  */
236
245
  function blocksAdmission(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
237
246
  if (isLegacyKyvernoPolicy(resource)) {
238
- return getKyvernoPolicyAction(resource) === 'Enforce'
247
+ return getKyvernoEnforcement(resource).blocks
239
248
  }
240
249
  return getKyvernoEnforcementPosture(resource, family).blocks
241
250
  }
242
251
 
252
+ /**
253
+ * A policy declares a rejecting action yet does not block, because admission
254
+ * evaluation is switched off. The distinction changes the remediation the
255
+ * consequence sentence offers — re-enable admission, not switch the action —
256
+ * so it must not be conflated with an audit policy that simply never blocks.
257
+ *
258
+ * Only the validating families carry `validationActions`, and
259
+ * `getKyvernoValidationActions` applies Kyverno's absent-means-Deny default,
260
+ * so asking a mutating or generating policy would read Deny off a field that
261
+ * kind never has. The same guard `getKyvernoEnforcementPosture` uses.
262
+ */
263
+ function declaresBlockingButAdmissionOff(
264
+ resource: any,
265
+ family: KyvernoPolicyFamily | undefined,
266
+ ): boolean {
267
+ if (isLegacyKyvernoPolicy(resource)) {
268
+ return getKyvernoPolicyAction(resource) === 'Enforce' && !getKyvernoPolicyAdmission(resource)
269
+ }
270
+ if (family !== 'validating' && family !== 'imageValidating') return false
271
+ return getKyvernoValidationActions(resource).includes('Deny') && !isKyvernoAdmissionEnabled(resource)
272
+ }
273
+
243
274
  /**
244
275
  * Whether the consequence sentence can be stated at all.
245
276
  *
@@ -250,6 +281,11 @@ function blocksAdmission(resource: any, family: KyvernoPolicyFamily | undefined)
250
281
  */
251
282
  function canStateConsequence(resource: any, family: KyvernoPolicyFamily | undefined): boolean {
252
283
  if (!isLegacyKyvernoPolicy(resource)) {
284
+ // A JSON-mode policy is evaluated against payloads outside the cluster and
285
+ // never joins admission, so every phrasing of the sentence is false: it
286
+ // rejects nothing today, and neither switching the action nor enabling
287
+ // admission would change that. The posture badge already says "JSON mode".
288
+ if (getKyvernoEvaluationMode(resource) === 'JSON') return false
253
289
  return family === 'validating' || family === 'imageValidating' || family === undefined
254
290
  }
255
291
  const rules: any[] = resource?.spec?.rules ?? []
@@ -273,6 +309,7 @@ function enforcementConsequence(
273
309
  failing: number,
274
310
  blocks: boolean,
275
311
  matchesUpdate: boolean,
312
+ admissionOff = false,
276
313
  ): string {
277
314
  // Deliberately kind-neutral. A policy can match ConfigMaps, Secrets or
278
315
  // ClusterRoles as readily as Pods, and none of those "run" — the fact being
@@ -288,11 +325,24 @@ function enforcementConsequence(
288
325
  : `${exists} and ${one ? 'stays' : 'stay'} as ${itIs} — this policy only checks creation, so ${itIs} rejected only if recreated.`
289
326
  }
290
327
 
328
+ const target = one ? 'this resource' : `any of these ${failing}`
329
+
330
+ // The policy already declares a rejecting action; it does not block only
331
+ // because admission evaluation is disabled. Telling the operator to change
332
+ // the action would point at the wrong knob — the fix is to re-enable
333
+ // admission.
334
+ if (admissionOff) {
335
+ return matchesUpdate
336
+ ? `Nothing is rejected today because admission is disabled for this policy. Enabling admission would reject the next update to ${target}.`
337
+ : `Nothing is rejected today because admission is disabled for this policy. Enabling admission would leave ${
338
+ one ? 'this resource as it is' : `these ${failing} as they are`
339
+ } — only newly created resources would be rejected.`
340
+ }
341
+
291
342
  // "Set to enforce" is the legacy family's vocabulary; the modern API spells
292
343
  // the same thing `validationActions: [Deny]`, and the posture badge above
293
344
  // already says "Deny". Describing the behaviour rather than the field keeps
294
345
  // one sentence correct for both.
295
- const target = one ? 'this resource' : `any of these ${failing}`
296
346
  return matchesUpdate
297
347
  ? `Nothing is rejected today. Switching this policy to block would reject the next update to ${target}.`
298
348
  : `Nothing is rejected today. Switching this policy to block would leave ${
@@ -469,8 +519,14 @@ function Consequence({
469
519
  const failing = data.subjectsFailing ?? data.counts.fail
470
520
 
471
521
  if (failing > 0 && canStateConsequence(resource, family)) {
522
+ const blocks = blocksAdmission(resource, family)
472
523
  lines.push(
473
- enforcementConsequence(failing, blocksAdmission(resource, family), matchesUpdates(resource)),
524
+ enforcementConsequence(
525
+ failing,
526
+ blocks,
527
+ matchesUpdates(resource),
528
+ !blocks && declaresBlockingButAdmissionOff(resource, family),
529
+ ),
474
530
  )
475
531
  }
476
532
 
@@ -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}%`} />
@@ -176,6 +176,53 @@ describe('what a storage location holds', () => {
176
176
  })
177
177
  })
178
178
 
179
+ /**
180
+ * Velero writes why a location is unavailable into `status.message`, and the
181
+ * BSL object carries no conditions, so that field is the only place the reason
182
+ * exists. A page opened from an unavailable-location problem has to show it -
183
+ * the same way the sibling BackupRepository renderer leads with its own reason.
184
+ */
185
+ describe('an unavailable location Velero gave a reason for', () => {
186
+ const bslMsg = (phase: string, message?: string) => ({
187
+ metadata: { name: 'dr-replica', namespace: 'velero' },
188
+ spec: { provider: 'aws', objectStorage: { bucket: 'dr' } },
189
+ status: { phase, ...(message !== undefined ? { message } : {}) },
190
+ })
191
+
192
+ it('leads with the reason Velero gave', () => {
193
+ const html = renderToString(
194
+ <VeleroBSLRenderer
195
+ data={bslMsg('Unavailable', 'BackupStorageLocation is invalid: rpc error: code = Unknown desc = NoSuchBucket')}
196
+ />,
197
+ )
198
+ expect(html).toContain('NoSuchBucket')
199
+ })
200
+
201
+ // Velero can set Unavailable with no message. Saying nothing would leave a red
202
+ // badge and no consequence, so the page states what unavailable costs.
203
+ it('names the consequence when Velero gave no reason', () => {
204
+ const html = renderToString(<VeleroBSLRenderer data={bslMsg('Unavailable')} />)
205
+ expect(html).toContain('cannot be written')
206
+ })
207
+
208
+ it('says nothing alarming when the location is available', () => {
209
+ const html = renderToString(
210
+ <VeleroBSLRenderer data={bslMsg('Available', 'stale message left on the object')} />,
211
+ )
212
+ expect(html).not.toContain('Storage Location Unavailable')
213
+ expect(html).not.toContain('stale message left on the object')
214
+ expect(html).not.toContain('cannot be written')
215
+ })
216
+
217
+ // A location Velero has not validated yet has no phase. It is not unavailable,
218
+ // so it gets no alarm even if a message lingers from a prior state.
219
+ it('raises no alarm on a location Velero has not reported on', () => {
220
+ const html = renderToString(<VeleroBSLRenderer data={bslMsg('', 'old message')} />)
221
+ expect(html).not.toContain('Storage Location Unavailable')
222
+ expect(html).not.toContain('old message')
223
+ })
224
+ })
225
+
179
226
  /**
180
227
  * The list counts what is stored; the restorable point is derived from what
181
228
  * completed. On a healthy location those are the only two numbers on the page,
@@ -1,5 +1,5 @@
1
1
  import { HardDrive, Clock, Archive } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, RelationshipGroup } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, RelationshipGroup, AlertBanner } from '../../ui/drawer-components'
3
3
  import { TimeValue } from '../../ui/ScheduleValue'
4
4
  import {
5
5
  getBSLStatus,
@@ -11,6 +11,7 @@ import {
11
11
  getBSLAccessMode,
12
12
  getBSLLastValidation,
13
13
  getBSLLastSynced,
14
+ getBSLMessage,
14
15
  } from '../resource-utils-velero'
15
16
  import { VeleroPhaseValue } from './velero-cells'
16
17
 
@@ -66,6 +67,7 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
66
67
  const status = data.status || {}
67
68
  const conditions = status.conditions || []
68
69
  const bslStatus = getBSLStatus(data)
70
+ const message = getBSLMessage(data)
69
71
  const config = data.spec?.config || {}
70
72
  // Two things falsify "restorable", and the phase sees neither. A backup that
71
73
  // did complete is still not something to go back to once its TTL has passed —
@@ -90,6 +92,19 @@ export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorable
90
92
 
91
93
  return (
92
94
  <>
95
+ {/* The reason, not just the state. An unavailable location means Velero's
96
+ last validation of the bucket failed, and status.message is the only
97
+ place that reason exists — the object carries no conditions. */}
98
+ {bslStatus.level === 'unhealthy' && (
99
+ <AlertBanner
100
+ variant="error"
101
+ title="Storage Location Unavailable"
102
+ message={message
103
+ ? message
104
+ : 'Velero reported this location as unavailable and gave no reason. New backups cannot be written here, and backups already stored here cannot be restored from, while it stays that way.'}
105
+ />
106
+ )}
107
+
93
108
  {/* Status section */}
94
109
  <Section title="Status" icon={Clock} defaultExpanded>
95
110
  <PropertyList>
@@ -8,6 +8,12 @@ const restore = (status: Record<string, unknown>) => ({
8
8
  status,
9
9
  })
10
10
 
11
+ const scopedRestore = (spec: Record<string, unknown>) => ({
12
+ metadata: { name: 'live-restore', namespace: 'velero' },
13
+ spec: { backupName: 'live-completed', ...spec },
14
+ status: { phase: 'Completed' },
15
+ })
16
+
11
17
  /**
12
18
  * The restore side carries the same claims as the backup side and its own
13
19
  * wording for them, so pinning one does not pin the other. Both banners below
@@ -53,3 +59,85 @@ describe('what a restore claims about itself', () => {
53
59
  expect(html).toContain('Backup live-completed not found')
54
60
  })
55
61
  })
62
+
63
+ /**
64
+ * Scope is where an operator reads what a restore will touch and where it lands.
65
+ * namespaceMapping remaps source namespaces to different targets on restore, so
66
+ * omitting it hides where the data actually goes; a labelSelector narrows the
67
+ * restore to a subset of objects.
68
+ */
69
+ // react-dom/server inserts <!-- --> markers between adjacent text nodes, which
70
+ // splits a badge like `app=nginx` in the raw markup; strip them so assertions
71
+ // read the visible text.
72
+ const renderScope = (data: unknown) =>
73
+ renderToString(<VeleroRestoreRenderer data={data} />).replace(/<!-- -->/g, '')
74
+
75
+ describe('what a restore says it will touch', () => {
76
+ it('shows namespaceMapping as source to target', () => {
77
+ const html = renderScope(
78
+ scopedRestore({ namespaceMapping: { prod: 'prod-clone', staging: 'staging-clone' } }),
79
+ )
80
+ expect(html).toContain('Scope')
81
+ expect(html).toContain('Namespace Mapping')
82
+ expect(html).toContain('prod')
83
+ expect(html).toContain('prod-clone')
84
+ expect(html).toContain('staging-clone')
85
+ })
86
+
87
+ it('shows the label selector that narrows the restore', () => {
88
+ const html = renderScope(
89
+ scopedRestore({
90
+ labelSelector: {
91
+ matchLabels: { app: 'nginx' },
92
+ matchExpressions: [{ key: 'tier', operator: 'In', values: ['frontend'] }],
93
+ },
94
+ }),
95
+ )
96
+ expect(html).toContain('Scope')
97
+ expect(html).toContain('app=nginx')
98
+ expect(html).toContain('tier In frontend')
99
+ })
100
+
101
+ it('renders a Scope section for a restore filtered only by label selector', () => {
102
+ const html = renderScope(scopedRestore({ labelSelector: { matchLabels: { app: 'nginx' } } }))
103
+ expect(html).toContain('Scope')
104
+ expect(html).toContain('app=nginx')
105
+ })
106
+
107
+ it('shows orLabelSelectors joined by OR', () => {
108
+ const html = renderScope(
109
+ scopedRestore({
110
+ orLabelSelectors: [{ matchLabels: { app: 'nginx' } }, { matchLabels: { app: 'redis' } }],
111
+ }),
112
+ )
113
+ expect(html).toContain('Scope')
114
+ expect(html).toContain('app=nginx')
115
+ expect(html).toContain('app=redis')
116
+ })
117
+
118
+ it('shows included and excluded namespaces alongside mapping', () => {
119
+ const html = renderScope(
120
+ scopedRestore({
121
+ includedNamespaces: ['prod'],
122
+ excludedNamespaces: ['kube-system'],
123
+ namespaceMapping: { prod: 'prod-clone' },
124
+ }),
125
+ )
126
+ expect(html).toContain('Included Namespaces')
127
+ expect(html).toContain('Excluded Namespaces')
128
+ expect(html).toContain('kube-system')
129
+ expect(html).toContain('prod-clone')
130
+ })
131
+
132
+ it('renders no empty Scope section for an unscoped restore', () => {
133
+ const html = renderScope(scopedRestore({}))
134
+ expect(html).not.toContain('Scope')
135
+ })
136
+
137
+ // An empty selector matches everything, so it is not scope. Presenting it as
138
+ // scope would tell an operator a restore is filtered when it is not.
139
+ it('treats an empty label selector as unscoped', () => {
140
+ const html = renderScope(scopedRestore({ labelSelector: {}, orLabelSelectors: [{}] }))
141
+ expect(html).not.toContain('Scope')
142
+ })
143
+ })
@@ -1,5 +1,5 @@
1
1
  import { ArchiveRestore, Filter } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, LabelSelectorDisplay } from '../../ui/drawer-components'
3
3
  import {
4
4
  getRestoreStatus,
5
5
  getRestoreBackupName,
@@ -7,6 +7,10 @@ import {
7
7
  getRestoreExcludedNamespaces,
8
8
  getRestoreIncludedResources,
9
9
  getRestoreExcludedResources,
10
+ getRestoreNamespaceMapping,
11
+ getRestoreLabelSelector,
12
+ getRestoreOrLabelSelectors,
13
+ hasLabelSelectorTerms,
10
14
  getRestoreDuration,
11
15
  getRestoreErrors,
12
16
  getRestoreWarnings,
@@ -41,6 +45,19 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
41
45
  const excludedNamespaces = getRestoreExcludedNamespaces(data)
42
46
  const includedResources = getRestoreIncludedResources(data)
43
47
  const excludedResources = getRestoreExcludedResources(data)
48
+ const namespaceMapping = getRestoreNamespaceMapping(data)
49
+ const rawLabelSelector = getRestoreLabelSelector(data)
50
+ const labelSelector = hasLabelSelectorTerms(rawLabelSelector) ? rawLabelSelector : null
51
+ const orLabelSelectors = getRestoreOrLabelSelectors(data).filter(hasLabelSelectorTerms)
52
+ const namespaceMappingEntries = Object.entries(namespaceMapping)
53
+ const hasScope =
54
+ includedNamespaces.length > 0 ||
55
+ excludedNamespaces.length > 0 ||
56
+ includedResources.length > 0 ||
57
+ excludedResources.length > 0 ||
58
+ namespaceMappingEntries.length > 0 ||
59
+ Boolean(labelSelector) ||
60
+ orLabelSelectors.length > 0
44
61
 
45
62
  const phase = status.phase || ''
46
63
  const validationErrors = getRestoreValidationErrors(data)
@@ -148,7 +165,7 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
148
165
  )}
149
166
 
150
167
  {/* Scope section */}
151
- {(includedNamespaces.length > 0 || excludedNamespaces.length > 0 || includedResources.length > 0 || excludedResources.length > 0) && (
168
+ {hasScope && (
152
169
  <Section title="Scope" icon={Filter} defaultExpanded>
153
170
  <PropertyList>
154
171
  {includedNamespaces.length > 0 && (
@@ -172,6 +189,19 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
172
189
  </div>
173
190
  } />
174
191
  )}
192
+ {namespaceMappingEntries.length > 0 && (
193
+ <Property label="Namespace Mapping" value={
194
+ <div className="flex flex-col gap-1">
195
+ {namespaceMappingEntries.map(([source, target]) => (
196
+ <div key={source} className="flex items-center gap-1.5">
197
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">{source}</span>
198
+ <span className="text-theme-text-tertiary" aria-hidden="true">&rarr;</span>
199
+ <span className="badge-sm bg-theme-hover text-theme-text-secondary">{String(target)}</span>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ } />
204
+ )}
175
205
  {includedResources.length > 0 && (
176
206
  <Property label="Included Resources" value={
177
207
  <div className="flex flex-wrap gap-1">
@@ -190,6 +220,18 @@ export function VeleroRestoreRenderer({ data, messages, onNavigate }: VeleroRest
190
220
  </div>
191
221
  } />
192
222
  )}
223
+ {labelSelector && (
224
+ <Property label="Label Selector" value={<LabelSelectorDisplay selector={labelSelector} />} />
225
+ )}
226
+ {orLabelSelectors.length > 0 && (
227
+ <Property label="Label Selectors (match any)" value={
228
+ <div className="flex flex-col gap-1">
229
+ {orLabelSelectors.map((sel: any, i: number) => (
230
+ <LabelSelectorDisplay key={i} selector={sel} />
231
+ ))}
232
+ </div>
233
+ } />
234
+ )}
193
235
  </PropertyList>
194
236
  </Section>
195
237
  )}
@@ -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 () => {}} />,