@skyhook-io/k8s-ui 1.12.0 → 1.13.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/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +410 -5
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +12 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +3 -0
- package/src/types/core.ts +29 -0
- package/src/types/policy.ts +153 -2
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- package/src/utils/resource-icons.ts +29 -2
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { VeleroBSLRenderer } from './VeleroBSLRenderer'
|
|
4
|
+
|
|
5
|
+
const bsl = (phase: string) => ({
|
|
6
|
+
metadata: { name: 'dr-replica', namespace: 'velero' },
|
|
7
|
+
spec: { provider: 'aws', objectStorage: { bucket: 'dr' } },
|
|
8
|
+
status: { phase },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const completed = (name: string, completedAt: string) => ({
|
|
12
|
+
namespace: 'velero',
|
|
13
|
+
name,
|
|
14
|
+
phase: 'Completed',
|
|
15
|
+
completed: completedAt,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A storage location's phase is a fact about a bucket. What an operator opens the
|
|
20
|
+
* page to learn is what that fact costs them, and the answer is the set of
|
|
21
|
+
* backups they cannot restore from while it holds — every one of which Velero
|
|
22
|
+
* still reports as Completed, accurately, because they did complete.
|
|
23
|
+
*/
|
|
24
|
+
describe('what a storage location holds', () => {
|
|
25
|
+
it('says what an unavailable location is holding back', () => {
|
|
26
|
+
const html = renderToString(
|
|
27
|
+
<VeleroBSLRenderer
|
|
28
|
+
data={bsl('Unavailable')}
|
|
29
|
+
storedBackups={[completed('dr-nightly', '2026-08-14T01:00:00Z')]}
|
|
30
|
+
/>,
|
|
31
|
+
)
|
|
32
|
+
expect(html).toContain('not something you can restore from')
|
|
33
|
+
expect(html).toContain('dr-nightly')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// The panel said "not something you can restore from" and then, four lines
|
|
37
|
+
// later, "Most recent restorable point" — two claims about the same backups
|
|
38
|
+
// that cannot both hold.
|
|
39
|
+
it('does not call a point restorable on a location it just said was not', () => {
|
|
40
|
+
const html = renderToString(
|
|
41
|
+
<VeleroBSLRenderer
|
|
42
|
+
data={bsl('Unavailable')}
|
|
43
|
+
storedBackups={[completed('dr-nightly', '2026-08-14T01:00:00Z')]}
|
|
44
|
+
/>,
|
|
45
|
+
)
|
|
46
|
+
expect(html).toContain('not something you can restore from')
|
|
47
|
+
expect(html).not.toContain('Most recent restorable point')
|
|
48
|
+
expect(html).toContain('once this location is reachable again')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('calls it a restorable point when the location can actually serve it', () => {
|
|
52
|
+
const html = renderToString(
|
|
53
|
+
<VeleroBSLRenderer
|
|
54
|
+
data={bsl('Available')}
|
|
55
|
+
storedBackups={[completed('nightly', '2026-08-14T01:00:00Z')]}
|
|
56
|
+
/>,
|
|
57
|
+
)
|
|
58
|
+
expect(html).toContain('Most recent restorable point')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('agrees in number', () => {
|
|
62
|
+
const html = renderToString(
|
|
63
|
+
<VeleroBSLRenderer
|
|
64
|
+
data={bsl('Unavailable')}
|
|
65
|
+
storedBackups={[
|
|
66
|
+
completed('a', '2026-08-14T01:00:00Z'),
|
|
67
|
+
completed('b', '2026-08-13T01:00:00Z'),
|
|
68
|
+
]}
|
|
69
|
+
/>,
|
|
70
|
+
)
|
|
71
|
+
// Counts what `restorable` counts: an expired Completed backup is stored
|
|
72
|
+
// here too, so "completed backups stored here" described a larger set.
|
|
73
|
+
expect(html).toContain('2 backups here are complete and still inside their retention')
|
|
74
|
+
expect(html).toContain('they are not something')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// The consequence belongs to a broken location. On a healthy one it would be
|
|
78
|
+
// a warning about nothing.
|
|
79
|
+
it('states no consequence when the location is available', () => {
|
|
80
|
+
const html = renderToString(
|
|
81
|
+
<VeleroBSLRenderer
|
|
82
|
+
data={bsl('Available')}
|
|
83
|
+
storedBackups={[completed('nightly', '2026-08-14T01:00:00Z')]}
|
|
84
|
+
/>,
|
|
85
|
+
)
|
|
86
|
+
expect(html).not.toContain('not something you can restore from')
|
|
87
|
+
expect(html).toContain('nightly')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Only a Completed backup is a restorable point; the rest are stored here but
|
|
91
|
+
// are not something to go back to.
|
|
92
|
+
it('does not count a failed backup as restorable', () => {
|
|
93
|
+
const html = renderToString(
|
|
94
|
+
<VeleroBSLRenderer
|
|
95
|
+
data={bsl('Unavailable')}
|
|
96
|
+
storedBackups={[
|
|
97
|
+
completed('good', '2026-08-14T01:00:00Z'),
|
|
98
|
+
{ namespace: 'velero', name: 'bad', phase: 'Failed' },
|
|
99
|
+
]}
|
|
100
|
+
/>,
|
|
101
|
+
)
|
|
102
|
+
expect(html).toContain('1 backup here is complete and still inside its retention')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// The second thing a phase cannot see. Velero deletes expired backups, so one
|
|
106
|
+
// still listed is either awaiting collection or waiting on a controller that
|
|
107
|
+
// is not running — and this cluster's controller is deliberately stopped.
|
|
108
|
+
it('does not offer an expired backup as a restore point', () => {
|
|
109
|
+
const past = new Date(Date.now() - 86_400_000).toISOString()
|
|
110
|
+
const html = renderToString(
|
|
111
|
+
<VeleroBSLRenderer
|
|
112
|
+
data={bsl('Available')}
|
|
113
|
+
storedBackups={[{ ...completed('stale', '2026-01-01T00:00:00Z'), expiration: past }]}
|
|
114
|
+
/>,
|
|
115
|
+
)
|
|
116
|
+
expect(html).toContain('passed its retention')
|
|
117
|
+
expect(html).not.toContain('Most recent restorable point')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('still counts a backup inside its retention', () => {
|
|
121
|
+
const future = new Date(Date.now() + 86_400_000).toISOString()
|
|
122
|
+
const html = renderToString(
|
|
123
|
+
<VeleroBSLRenderer
|
|
124
|
+
data={bsl('Unavailable')}
|
|
125
|
+
storedBackups={[{ ...completed('fresh', '2026-08-14T01:00:00Z'), expiration: future }]}
|
|
126
|
+
/>,
|
|
127
|
+
)
|
|
128
|
+
expect(html).toContain('1 backup here is complete and still inside its retention')
|
|
129
|
+
expect(html).not.toContain('passed its retention')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// "not counted above" pointed at a line that only renders for an unhealthy
|
|
133
|
+
// location, so on a healthy one it referred to nothing.
|
|
134
|
+
it('states the expired count without leaning on a line that may not be there', () => {
|
|
135
|
+
const past = new Date(Date.now() - 86_400_000).toISOString()
|
|
136
|
+
const html = renderToString(
|
|
137
|
+
<VeleroBSLRenderer
|
|
138
|
+
data={bsl('Available')}
|
|
139
|
+
storedBackups={[
|
|
140
|
+
{ ...completed('stale', '2026-01-01T00:00:00Z'), expiration: past },
|
|
141
|
+
completed('fresh', '2026-08-14T01:00:00Z'),
|
|
142
|
+
]}
|
|
143
|
+
/>,
|
|
144
|
+
)
|
|
145
|
+
expect(html).toContain('not something to plan a recovery around')
|
|
146
|
+
// Not "cannot be restored": Velero would still restore it until the
|
|
147
|
+
// collector removes it, so the panel states the intent, not a false absence.
|
|
148
|
+
expect(html).not.toContain('no longer a restore point')
|
|
149
|
+
expect(html).not.toContain('not counted above')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Every stored backup expired: the location holds things, and none of them
|
|
153
|
+
// are any use.
|
|
154
|
+
it('says so when nothing stored is restorable at all', () => {
|
|
155
|
+
const past = new Date(Date.now() - 86_400_000).toISOString()
|
|
156
|
+
const html = renderToString(
|
|
157
|
+
<VeleroBSLRenderer
|
|
158
|
+
data={bsl('Available')}
|
|
159
|
+
storedBackups={[{ ...completed('stale', '2026-01-01T00:00:00Z'), expiration: past }]}
|
|
160
|
+
/>,
|
|
161
|
+
)
|
|
162
|
+
expect(html).toContain('Nothing stored here is still inside its retention')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// The distinction the whole feature rests on: not having looked is not the
|
|
166
|
+
// same as having looked and found nothing.
|
|
167
|
+
it('claims nothing while the lookup is unresolved', () => {
|
|
168
|
+
const html = renderToString(<VeleroBSLRenderer data={bsl('Unavailable')} />)
|
|
169
|
+
expect(html).not.toContain('No backup in this namespace names this location')
|
|
170
|
+
expect(html).not.toContain('Stored Here')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('says a location genuinely holds nothing when it does', () => {
|
|
174
|
+
const html = renderToString(<VeleroBSLRenderer data={bsl('Available')} storedBackups={[]} />)
|
|
175
|
+
expect(html).toContain('No backup in this namespace names this location')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The list counts what is stored; the restorable point is derived from what
|
|
181
|
+
* completed. On a healthy location those are the only two numbers on the page,
|
|
182
|
+
* and when they disagree something has to say why — "Backups (2)" above a
|
|
183
|
+
* single restorable point otherwise reads as one of them being lost, rather
|
|
184
|
+
* than as one of them still running.
|
|
185
|
+
*
|
|
186
|
+
* The panel states how many ARE whole restore points rather than counting the
|
|
187
|
+
* rest and naming them: the phases it would have to name include `Deleting`,
|
|
188
|
+
* which already ran, and the *PartiallyFailed phases, which mean part of
|
|
189
|
+
* the data made it. "Has not completed" is false for both.
|
|
190
|
+
*/
|
|
191
|
+
describe('a location holding runs that are not restore points', () => {
|
|
192
|
+
const atPhase = (name: string, phase: string) => ({ namespace: 'velero', name, phase })
|
|
193
|
+
|
|
194
|
+
it('reconciles the stored count with the restorable one', () => {
|
|
195
|
+
const html = renderToString(
|
|
196
|
+
<VeleroBSLRenderer
|
|
197
|
+
data={bsl('Available')}
|
|
198
|
+
storedBackups={[
|
|
199
|
+
completed('nightly', '2026-08-14T01:00:00Z'),
|
|
200
|
+
atPhase('stalled', 'InProgress'),
|
|
201
|
+
]}
|
|
202
|
+
/>,
|
|
203
|
+
)
|
|
204
|
+
expect(html).toContain('1 of 2 backups stored here is a complete restore point')
|
|
205
|
+
// Both are stored, so both are listed — the sentence explains the list, it
|
|
206
|
+
// does not filter it.
|
|
207
|
+
expect(html).toContain('nightly')
|
|
208
|
+
expect(html).toContain('stalled')
|
|
209
|
+
// The completed one is still a restore point; the note must not read as
|
|
210
|
+
// though nothing here is usable.
|
|
211
|
+
expect(html).toContain('Most recent restorable point')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('says nothing when every stored backup is a restore point', () => {
|
|
215
|
+
const html = renderToString(
|
|
216
|
+
<VeleroBSLRenderer
|
|
217
|
+
data={bsl('Available')}
|
|
218
|
+
storedBackups={[completed('nightly', '2026-08-14T01:00:00Z')]}
|
|
219
|
+
/>,
|
|
220
|
+
)
|
|
221
|
+
expect(html).not.toContain('complete restore point')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// A Deleting backup already ran and is now on its way out, and a
|
|
225
|
+
// PartiallyFailed one finished with errors. Any wording that calls either
|
|
226
|
+
// "not completed" is false — which is why the sentence counts the whole
|
|
227
|
+
// restore points instead of characterising these.
|
|
228
|
+
it('does not claim a deleting or partially failed backup never completed', () => {
|
|
229
|
+
const html = renderToString(
|
|
230
|
+
<VeleroBSLRenderer
|
|
231
|
+
data={bsl('Available')}
|
|
232
|
+
storedBackups={[
|
|
233
|
+
atPhase('on-its-way-out', 'Deleting'),
|
|
234
|
+
atPhase('half-made-it', 'PartiallyFailed'),
|
|
235
|
+
]}
|
|
236
|
+
/>,
|
|
237
|
+
)
|
|
238
|
+
expect(html).not.toContain('not completed')
|
|
239
|
+
expect(html).toContain('None of the 2 backups stored here is a complete restore point')
|
|
240
|
+
expect(html).not.toContain('Most recent restorable point')
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A location Velero has not validated yet has no phase at all. Treating that as
|
|
246
|
+
* "not healthy, therefore unreachable" turns missing data into a claim about the
|
|
247
|
+
* bucket — and this panel is read to decide whether a restore is possible.
|
|
248
|
+
*/
|
|
249
|
+
describe('a location Velero has not reported on', () => {
|
|
250
|
+
const noPhase = {
|
|
251
|
+
metadata: { name: 'fresh', namespace: 'velero' },
|
|
252
|
+
spec: { provider: 'aws', objectStorage: { bucket: 'b' } },
|
|
253
|
+
status: {},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('does not say backups here cannot be restored from', () => {
|
|
257
|
+
const html = renderToString(
|
|
258
|
+
<VeleroBSLRenderer data={noPhase} storedBackups={[completed('nightly', '2026-08-14T01:00:00Z')]} />,
|
|
259
|
+
)
|
|
260
|
+
expect(html).not.toContain('not something you can restore from')
|
|
261
|
+
expect(html).toContain('has not reported a phase')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('does not imply the location is unreachable', () => {
|
|
265
|
+
const html = renderToString(
|
|
266
|
+
<VeleroBSLRenderer data={noPhase} storedBackups={[completed('nightly', '2026-08-14T01:00:00Z')]} />,
|
|
267
|
+
)
|
|
268
|
+
expect(html).not.toContain('reachable again')
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* These sentences take counts, and a count of one is the common case on a small
|
|
274
|
+
* cluster. Both read as broken English at n=1 — "whether these is restorable",
|
|
275
|
+
* "None of the 1 backups" — which is the shape of a sentence assembled from
|
|
276
|
+
* fragments without anyone reading the result.
|
|
277
|
+
*/
|
|
278
|
+
describe('the sentences at a count of one', () => {
|
|
279
|
+
const oneOf = (phase: string, backups: Array<{ namespace: string; name: string; phase: string }>) =>
|
|
280
|
+
renderToString(
|
|
281
|
+
<VeleroBSLRenderer
|
|
282
|
+
data={{ metadata: { name: 'loc', namespace: 'velero' }, spec: {}, status: { phase } }}
|
|
283
|
+
storedBackups={backups}
|
|
284
|
+
/>,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
it('reads correctly when a single backup is not a restore point', () => {
|
|
288
|
+
const html = oneOf('Available', [{ namespace: 'velero', name: 'only', phase: 'InProgress' }])
|
|
289
|
+
expect(html).toContain('The backup stored here is not a complete restore point')
|
|
290
|
+
expect(html).not.toContain('None of the 1')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('reads correctly when a single backup sits on an unvalidated location', () => {
|
|
294
|
+
const html = oneOf('', [{ namespace: 'velero', name: 'only', phase: 'Completed' }])
|
|
295
|
+
expect(html).toContain('whether the backup still inside its retention can be restored from')
|
|
296
|
+
// The plural form must not leak into the singular sentence.
|
|
297
|
+
expect(html).not.toContain('backups still inside their retention')
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Velero keeps every backup until its TTL expires, so an hourly schedule with a
|
|
303
|
+
* long retention leaves thousands in one location. The server sends a page; the
|
|
304
|
+
* panel has to say so, and its sentences have to count the location rather than
|
|
305
|
+
* the page — otherwise "1 of 200" describes a cap, not a cluster.
|
|
306
|
+
*/
|
|
307
|
+
describe('a location holding more backups than the list shows', () => {
|
|
308
|
+
const page = Array.from({ length: 200 }, (_, i) => ({
|
|
309
|
+
namespace: 'velero',
|
|
310
|
+
name: `nightly-${i}`,
|
|
311
|
+
phase: i === 0 ? 'Completed' : 'Failed',
|
|
312
|
+
completed: i === 0 ? '2026-08-14T01:00:00Z' : undefined,
|
|
313
|
+
}))
|
|
314
|
+
|
|
315
|
+
const html = () =>
|
|
316
|
+
renderToString(
|
|
317
|
+
<VeleroBSLRenderer
|
|
318
|
+
data={bsl('Available')}
|
|
319
|
+
storedBackups={page}
|
|
320
|
+
storedTotal={2000}
|
|
321
|
+
listTruncated
|
|
322
|
+
/>,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
it('says the list is a page', () => {
|
|
326
|
+
expect(html()).toContain('Showing the 200 most recent of 2000')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('counts the location, not the page', () => {
|
|
330
|
+
// 1 restorable out of 2000 held — not out of the 200 that fit.
|
|
331
|
+
expect(html()).toContain('1 of 2000 backups stored here')
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Velero keeps every backup until its TTL expires, so an hourly schedule with a
|
|
337
|
+
* long retention leaves thousands in one location and the server sends a page.
|
|
338
|
+
* The counts describe the location and the list is a page of it — deriving a
|
|
339
|
+
* count from the page and printing it beside the location's total makes the two
|
|
340
|
+
* numbers disagree on exactly the locations big enough to need this panel.
|
|
341
|
+
*/
|
|
342
|
+
describe('a location whose list is a page', () => {
|
|
343
|
+
const page = Array.from({ length: 3 }, (_, i) => ({
|
|
344
|
+
namespace: 'velero',
|
|
345
|
+
name: `nightly-${i}`,
|
|
346
|
+
phase: 'Failed',
|
|
347
|
+
}))
|
|
348
|
+
|
|
349
|
+
it('reports what the location holds, not what the page shows', () => {
|
|
350
|
+
const html = renderToString(
|
|
351
|
+
<VeleroBSLRenderer
|
|
352
|
+
data={bsl('Available')}
|
|
353
|
+
storedBackups={page}
|
|
354
|
+
storedTotal={700}
|
|
355
|
+
restorableTotal={412}
|
|
356
|
+
expiredTotal={285}
|
|
357
|
+
/>,
|
|
358
|
+
)
|
|
359
|
+
expect(html).toContain('412 of 700')
|
|
360
|
+
expect(html).toContain('285 stored')
|
|
361
|
+
// The page holds three failed backups. Counting it would have said none of
|
|
362
|
+
// the 700 is a restore point, on a location holding 412.
|
|
363
|
+
expect(html).not.toContain('None of the 700')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// A host that does not wire the lookup still gets a truthful panel from what
|
|
367
|
+
// it does have.
|
|
368
|
+
it('falls back to the page when the server counts are not wired', () => {
|
|
369
|
+
const html = renderToString(
|
|
370
|
+
<VeleroBSLRenderer data={bsl('Available')} storedBackups={[completed('only-one', '2026-08-16T01:00:00Z')]} />,
|
|
371
|
+
)
|
|
372
|
+
expect(html).toContain('only-one')
|
|
373
|
+
})
|
|
374
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { HardDrive, Clock } from 'lucide-react'
|
|
2
|
-
import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
|
|
1
|
+
import { HardDrive, Clock, Archive } from 'lucide-react'
|
|
2
|
+
import { Section, PropertyList, Property, ConditionsSection, RelationshipGroup } from '../../ui/drawer-components'
|
|
3
|
+
import { TimeValue } from '../../ui/ScheduleValue'
|
|
3
4
|
import {
|
|
4
5
|
getBSLStatus,
|
|
5
6
|
getBSLProvider,
|
|
@@ -13,15 +14,79 @@ import {
|
|
|
13
14
|
} from '../resource-utils-velero'
|
|
14
15
|
import { VeleroPhaseValue } from './velero-cells'
|
|
15
16
|
|
|
17
|
+
/** Past its TTL.
|
|
18
|
+
*
|
|
19
|
+
* Not the same as unrestorable: Velero's restore controller does not check
|
|
20
|
+
* expiration, so until the garbage collector creates the DeleteBackupRequest
|
|
21
|
+
* the data is still in the bucket and a restore from it would work. What is
|
|
22
|
+
* true is that Velero intends to delete it, on a schedule nothing here can
|
|
23
|
+
* see — so it is not something to plan a recovery around, and the wording
|
|
24
|
+
* below says that rather than claiming it is already gone. */
|
|
25
|
+
function veleroExpired(b: { expiration?: string }): boolean {
|
|
26
|
+
if (!b.expiration) return false
|
|
27
|
+
const at = Date.parse(b.expiration)
|
|
28
|
+
return Number.isFinite(at) && at < Date.now()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One Backup held in this location, from the stored-backups lookup. */
|
|
32
|
+
export interface VeleroStoredBackup {
|
|
33
|
+
namespace: string
|
|
34
|
+
name: string
|
|
35
|
+
phase?: string
|
|
36
|
+
expiration?: string
|
|
37
|
+
completed?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
interface VeleroBSLRendererProps {
|
|
17
41
|
data: any
|
|
42
|
+
/**
|
|
43
|
+
* What this location holds. Undefined means the lookup is unresolved — still
|
|
44
|
+
* loading, failed, or not wired by this host — which is not the same as a
|
|
45
|
+
* location holding nothing, and must not render as it.
|
|
46
|
+
*/
|
|
47
|
+
storedBackups?: VeleroStoredBackup[]
|
|
48
|
+
/** How many the location holds in total. Larger than the list when the server
|
|
49
|
+
* capped it — the counts describe the location, the list is a page of it. */
|
|
50
|
+
storedTotal?: number
|
|
51
|
+
/** How many of those are complete restore points, counted by the server over
|
|
52
|
+
* the whole location. Deriving this from the list instead would pair a page's
|
|
53
|
+
* count with the location's total and undercount a capped location. */
|
|
54
|
+
restorableTotal?: number
|
|
55
|
+
/** How many have aged out, on the same terms. */
|
|
56
|
+
expiredTotal?: number
|
|
57
|
+
/** The list is a page. Said out loud, because a capped list otherwise reads as
|
|
58
|
+
* everything this location holds. */
|
|
59
|
+
listTruncated?: boolean
|
|
60
|
+
/** Rendered by the host when the lookup failed, so a denial is not silence. */
|
|
61
|
+
lookupNote?: React.ReactNode
|
|
62
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
18
63
|
}
|
|
19
64
|
|
|
20
|
-
export function VeleroBSLRenderer({ data }: VeleroBSLRendererProps) {
|
|
65
|
+
export function VeleroBSLRenderer({ data, storedBackups, storedTotal, restorableTotal, expiredTotal, listTruncated, lookupNote, onNavigate }: VeleroBSLRendererProps) {
|
|
21
66
|
const status = data.status || {}
|
|
22
67
|
const conditions = status.conditions || []
|
|
23
68
|
const bslStatus = getBSLStatus(data)
|
|
24
69
|
const config = data.spec?.config || {}
|
|
70
|
+
// Two things falsify "restorable", and the phase sees neither. A backup that
|
|
71
|
+
// did complete is still not something to go back to once its TTL has passed —
|
|
72
|
+
// Velero deletes expired backups, and while its controller is down they sit
|
|
73
|
+
// here looking finished long after the data behind them went.
|
|
74
|
+
const usable = (storedBackups ?? []).filter((b) => b.phase === 'Completed' && !veleroExpired(b))
|
|
75
|
+
// The server's counts when the host wired them, because they are the
|
|
76
|
+
// location's; the page's own only as a fallback for a host that did not.
|
|
77
|
+
// Mixing the two reads worst on exactly the locations that need this panel —
|
|
78
|
+
// a location holding thousands is where a capped page and a full total sit in
|
|
79
|
+
// the same sentence.
|
|
80
|
+
const restorable = restorableTotal ?? usable.length
|
|
81
|
+
const expired = expiredTotal ?? (storedBackups ?? []).filter((b) => b.phase === 'Completed' && veleroExpired(b)).length
|
|
82
|
+
// The location's total, not the page's length: every sentence below counts
|
|
83
|
+
// what the location holds, and the list may be capped.
|
|
84
|
+
const stored = storedTotal ?? storedBackups?.length ?? 0
|
|
85
|
+
const newest = usable
|
|
86
|
+
.map((b) => b.completed)
|
|
87
|
+
.filter((c): c is string => !!c)
|
|
88
|
+
.sort()
|
|
89
|
+
.pop()
|
|
25
90
|
|
|
26
91
|
return (
|
|
27
92
|
<>
|
|
@@ -65,6 +130,117 @@ export function VeleroBSLRenderer({ data }: VeleroBSLRendererProps) {
|
|
|
65
130
|
)}
|
|
66
131
|
</Section>
|
|
67
132
|
|
|
133
|
+
|
|
134
|
+
{/* What depends on this location.
|
|
135
|
+
"Unavailable" is a fact about the bucket; the reason anyone opens this
|
|
136
|
+
page is what it costs them, and that is the list of backups they cannot
|
|
137
|
+
restore from while it stays that way. Velero keeps reporting those
|
|
138
|
+
backups as Completed — accurately, they did complete — so the phase
|
|
139
|
+
alone never shows the cost. */}
|
|
140
|
+
{(storedBackups !== undefined || lookupNote) && (
|
|
141
|
+
<Section title="Stored Here" icon={Archive} defaultExpanded={bslStatus.level !== 'healthy'}>
|
|
142
|
+
{lookupNote}
|
|
143
|
+
{storedBackups !== undefined && storedBackups.length === 0 && !lookupNote && (
|
|
144
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
145
|
+
No backup in this namespace names this location.
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
{storedBackups !== undefined && storedBackups.length > 0 && (
|
|
149
|
+
<>
|
|
150
|
+
{/* Only when Velero actually said the location is unavailable.
|
|
151
|
+
`unknown` is the shape of a location Velero has not validated
|
|
152
|
+
yet — no phase at all — and reading that as "unreachable" turns
|
|
153
|
+
missing data into a statement about the bucket. */}
|
|
154
|
+
{bslStatus.level === 'unhealthy' && restorable > 0 && (
|
|
155
|
+
<div className="text-xs text-warning-text mb-2">
|
|
156
|
+
{/* Counts what `restorable` counts. "completed backups stored
|
|
157
|
+
here" described a larger set — expired backups completed
|
|
158
|
+
too — so the sentence and the number disagreed whenever a
|
|
159
|
+
location held both. */}
|
|
160
|
+
{`${restorable} ${restorable === 1 ? 'backup' : 'backups'} here ${restorable === 1 ? 'is' : 'are'} complete and still inside ${restorable === 1 ? 'its' : 'their'} retention. While this location is ${bslStatus.text}, ${restorable === 1 ? 'it is' : 'they are'} not something you can restore from.`}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
{bslStatus.level === 'unknown' && restorable > 0 && (
|
|
164
|
+
<div className="text-xs text-theme-text-secondary mb-2">
|
|
165
|
+
{`Velero has not reported a phase for this location, so whether ${restorable === 1 ? 'the backup still inside its retention' : `the ${restorable} backups still inside their retention`} can be restored from right now is not established here.`}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
{/* Stands on its own. "Not counted above" referred to a line that
|
|
169
|
+
only renders for an unhealthy location, so on a healthy one —
|
|
170
|
+
and whenever every stored backup has expired — it pointed at
|
|
171
|
+
nothing. */}
|
|
172
|
+
{expired > 0 && (
|
|
173
|
+
<div className="text-xs text-theme-text-secondary mb-2">
|
|
174
|
+
{`${expired} stored ${expired === 1 ? 'backup has' : 'backups have'} passed ${expired === 1 ? 'its' : 'their'} retention. Velero deletes ${expired === 1 ? 'it' : 'them'} when its garbage collector next runs, so ${expired === 1 ? 'it is' : 'they are'} not something to plan a recovery around.`}
|
|
175
|
+
{restorable === 0 && ' Nothing stored here is still inside its retention.'}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
{/* The list below is everything stored here, and its count is the
|
|
179
|
+
only number on a healthy location — so "Backups (2)" sat next
|
|
180
|
+
to a restorable point derived from one of them with nothing
|
|
181
|
+
reconciling the two.
|
|
182
|
+
|
|
183
|
+
Stated as the positive on purpose. The alternative — counting
|
|
184
|
+
the others and saying what they are — has to be right about
|
|
185
|
+
every phase in the enum, and the obvious phrasing is wrong
|
|
186
|
+
twice: a `Deleting` backup already ran, and the
|
|
187
|
+
*PartiallyFailed phases mean part of the data made it (see
|
|
188
|
+
BACKUP_PARTIAL_PHASES). Neither is "has not completed". How
|
|
189
|
+
many are whole restore points is true regardless. */}
|
|
190
|
+
{restorable !== stored && (
|
|
191
|
+
<div className="text-xs text-theme-text-secondary mb-2">
|
|
192
|
+
{restorable === 0
|
|
193
|
+
? `${stored === 1 ? 'The backup stored here is not' : `None of the ${stored} backups stored here is`} a complete restore point.`
|
|
194
|
+
: `${restorable} of ${stored} backups stored here ${restorable === 1 ? 'is a complete restore point' : 'are complete restore points'}.`}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
{listTruncated && (
|
|
198
|
+
<div className="text-xs text-theme-text-tertiary mb-2">
|
|
199
|
+
{`Showing the ${storedBackups.length} most recent of ${stored}.`}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
<RelationshipGroup
|
|
203
|
+
label="Backups"
|
|
204
|
+
refs={storedBackups.map((b) => ({
|
|
205
|
+
kind: 'Backup',
|
|
206
|
+
namespace: b.namespace,
|
|
207
|
+
name: b.name,
|
|
208
|
+
group: 'velero.io',
|
|
209
|
+
}))}
|
|
210
|
+
onNavigate={onNavigate}
|
|
211
|
+
/>
|
|
212
|
+
{newest && (
|
|
213
|
+
<div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
|
|
214
|
+
{/* The question this page is opened to answer — worded for the
|
|
215
|
+
state the location is actually in. Calling this a
|
|
216
|
+
"restorable point" on an unavailable location contradicts
|
|
217
|
+
the sentence directly above it, which has just said these
|
|
218
|
+
are not something you can restore from. The date is still
|
|
219
|
+
worth having: it is what you get back when the location
|
|
220
|
+
recovers. */}
|
|
221
|
+
{bslStatus.level === 'healthy' ? (
|
|
222
|
+
<>
|
|
223
|
+
Most recent restorable point: <TimeValue timestamp={newest} /> ago
|
|
224
|
+
</>
|
|
225
|
+
) : bslStatus.level === 'unhealthy' ? (
|
|
226
|
+
<>
|
|
227
|
+
Most recent point held here, once this location is reachable again:{' '}
|
|
228
|
+
<TimeValue timestamp={newest} /> ago
|
|
229
|
+
</>
|
|
230
|
+
) : (
|
|
231
|
+
// Unknown: do not imply it is unreachable, and do not call it
|
|
232
|
+
// restorable either. Say what the timestamp is.
|
|
233
|
+
<>
|
|
234
|
+
Most recent point held here: <TimeValue timestamp={newest} /> ago
|
|
235
|
+
</>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
</Section>
|
|
242
|
+
)}
|
|
243
|
+
|
|
68
244
|
<ConditionsSection conditions={conditions} />
|
|
69
245
|
</>
|
|
70
246
|
)
|