@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.
- package/package.json +4 -4
- 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 +966 -98
- 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/kyverno-legacy-action.test.ts +100 -1
- package/src/components/resources/kyverno-modern-posture.test.ts +36 -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/CNPGBackupRenderer.test.tsx +29 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
- 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/KyvernoPolicyReportRenderer.tsx +12 -7
- 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/PolicyCoverageSection.test.tsx +92 -1
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
- 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/cnpg-cells.tsx +12 -1
- 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/kyverno-cells.tsx +8 -4
- 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-cnpg.golden.test.ts +13 -5
- package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
- package/src/components/resources/resource-utils-cnpg.ts +132 -7
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +236 -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 +200 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
- package/src/components/resources/resource-utils-kyverno.ts +51 -2
- 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-velero.ts +32 -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/SelectMenu.tsx +10 -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
|
@@ -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 {
|
|
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.
|
|
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`)
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
|
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(
|
|
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:
|
|
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}%`} />
|
|
@@ -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
|
-
{
|
|
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">→</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 () => {}} />,
|