@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.
Files changed (103) hide show
  1. package/package.json +4 -4
  2. package/src/components/issues/severity.ts +1 -0
  3. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  4. package/src/components/resources/ResourcesView.tsx +410 -5
  5. package/src/components/resources/index.ts +27 -0
  6. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  7. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  8. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  9. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  10. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  12. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  13. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  14. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  15. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  16. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  17. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  18. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  19. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  20. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  23. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  24. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  25. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  26. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  27. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  28. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  29. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  30. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  31. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  34. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  35. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  36. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  37. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  38. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  39. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  40. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  41. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  42. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  43. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  44. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  45. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  47. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  48. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  49. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  50. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  52. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  53. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  54. package/src/components/resources/renderers/index.ts +16 -1
  55. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  56. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  57. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  58. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  59. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  60. package/src/components/resources/resource-utils-calico.ts +261 -0
  61. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  62. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  63. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  64. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  65. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  66. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  67. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  68. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  69. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  70. package/src/components/resources/resource-utils-velero.ts +68 -0
  71. package/src/components/resources/resource-utils.ts +98 -0
  72. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  73. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  74. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  75. package/src/components/shared/index.ts +1 -1
  76. package/src/components/shared/revision-role-badges.test.ts +120 -0
  77. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  78. package/src/components/topology/K8sResourceNode.tsx +22 -0
  79. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  80. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  81. package/src/components/topology/TopologyGraph.tsx +6 -6
  82. package/src/components/topology/TopologySearch.tsx +7 -0
  83. package/src/components/topology/layout.ts +3 -0
  84. package/src/components/trace/ReachabilityView.tsx +1 -0
  85. package/src/components/trace/TracePanel.tsx +12 -2
  86. package/src/components/trace/reachFixtures.ts +26 -1
  87. package/src/components/trace/reachGraphModel.test.ts +20 -0
  88. package/src/components/trace/reachGraphModel.ts +8 -1
  89. package/src/components/trace/reachOrigins.test.ts +31 -0
  90. package/src/components/trace/reachOrigins.ts +11 -1
  91. package/src/components/ui/Badge.tsx +10 -0
  92. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  93. package/src/components/ui/ScheduleValue.tsx +85 -0
  94. package/src/components/ui/drawer-components.test.tsx +17 -1
  95. package/src/components/ui/drawer-components.tsx +9 -2
  96. package/src/components/ui/index.ts +2 -0
  97. package/src/components/workload/WorkloadView.tsx +3 -0
  98. package/src/types/core.ts +29 -0
  99. package/src/types/policy.ts +153 -2
  100. package/src/utils/navigation.test.ts +5 -0
  101. package/src/utils/navigation.ts +5 -0
  102. package/src/utils/resource-icons.test.ts +49 -2
  103. 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
  )