@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,221 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { VeleroBackupRenderer } from './VeleroBackupRenderer'
4
+
5
+ const backup = (location: string) => ({
6
+ metadata: { name: 'nightly', namespace: 'velero' },
7
+ spec: { storageLocation: location, ttl: '720h0m0s' },
8
+ status: { phase: 'Completed', startTimestamp: '2026-08-14T01:00:00Z' },
9
+ })
10
+
11
+ /**
12
+ * A backup cannot see the bucket behind it. Its own status says Completed and
13
+ * keeps saying it after the storage location goes Unavailable, which is exactly
14
+ * when someone opens this page to ask whether they can restore to this point.
15
+ */
16
+ describe('a backup whose storage location is unreachable', () => {
17
+ it('says so on the backup, not one screen away', () => {
18
+ const html = renderToString(
19
+ <VeleroBackupRenderer data={backup('dr-replica')} storageLocationPhase="Unavailable" />,
20
+ )
21
+ expect(html).toContain('nothing here to restore from')
22
+ expect(html).toContain('Unavailable')
23
+ })
24
+
25
+ // On a healthy location this would be a warning about nothing, on every backup
26
+ // in the cluster.
27
+ it('stays quiet when the location is fine', () => {
28
+ const html = renderToString(
29
+ <VeleroBackupRenderer data={backup('default')} storageLocationPhase="Available" />,
30
+ )
31
+ expect(html).not.toContain('nothing here to restore from')
32
+ })
33
+
34
+ // The distinction that matters: a location we have not read is not a healthy
35
+ // one, and must not render as one.
36
+ it('claims nothing while the lookup is unresolved', () => {
37
+ const html = renderToString(<VeleroBackupRenderer data={backup('default')} />)
38
+ expect(html).not.toContain('nothing here to restore from')
39
+ expect(html).not.toContain('Unavailable')
40
+ })
41
+ })
42
+
43
+ /**
44
+ * FailedValidation is the one outcome where Velero usually explains itself, in
45
+ * status.failureReason. Radar's own sentence is the fallback for when it does
46
+ * not — a run rejected before it started leaves no errors, no warnings and no
47
+ * item counts, so without either the page is an empty red banner.
48
+ */
49
+ describe('a run Velero refused to start', () => {
50
+ const rejected = (failureReason?: string) => ({
51
+ metadata: { name: 'live-rejected', namespace: 'velero' },
52
+ spec: { storageLocation: 'dr-replica' },
53
+ status: {
54
+ phase: 'FailedValidation',
55
+ ...(failureReason ? { failureReason } : {}),
56
+ validationErrors: ['Backup storage location "dr-replica" is unavailable'],
57
+ },
58
+ })
59
+
60
+ // Velero's own words beat ours whenever it supplies them.
61
+ it('leads with the reason Velero gave', () => {
62
+ const html = renderToString(
63
+ <VeleroBackupRenderer data={rejected('backup storage location is in read-only mode')} />,
64
+ )
65
+ expect(html).toContain('backup storage location is in read-only mode')
66
+ expect(html).not.toContain('Velero rejected this backup before it started')
67
+ })
68
+
69
+ it('says nothing was backed up when Velero gave no reason', () => {
70
+ const html = renderToString(<VeleroBackupRenderer data={rejected()} />)
71
+ expect(html).toContain('nothing was backed up')
72
+ // The validation errors are the actionable half either way.
73
+ expect(html).toContain('dr-replica&quot; is unavailable')
74
+ })
75
+ })
76
+
77
+ /**
78
+ * Velero resolves an unset spec.storageLocation to whichever location carries
79
+ * spec.default, which is a flag and not a name. An install that renamed its
80
+ * default location has no object called "default" at all, so resolving by name
81
+ * finds nothing: the unavailable-location warning never appears and the link
82
+ * points at something that does not exist.
83
+ */
84
+ describe('a backup that names no location', () => {
85
+ const unset = {
86
+ metadata: { name: 'nightly', namespace: 'velero' },
87
+ spec: { ttl: '720h0m0s' },
88
+ status: { phase: 'Completed' },
89
+ }
90
+
91
+ it('links the location that actually holds it', () => {
92
+ const html = renderToString(
93
+ <VeleroBackupRenderer data={unset} storageLocationName="primary" onNavigate={() => {}} />,
94
+ )
95
+ expect(html).toContain('primary')
96
+ })
97
+
98
+ it('still warns when that location is the unavailable one', () => {
99
+ const html = renderToString(
100
+ <VeleroBackupRenderer data={unset} storageLocationName="primary" storageLocationPhase="Unavailable" />,
101
+ )
102
+ expect(html).toContain('nothing here to restore from')
103
+ })
104
+
105
+ // Replaced the by-name fallback assertion: it checked for the substring
106
+ // "default", which the "Velero's default location, not yet resolved" sentence
107
+ // also contains — so it passed while documenting the opposite of what the
108
+ // renderer now does. A host that does not resolve gets no asserted name.
109
+ it('asserts no location name when the host does not resolve one', () => {
110
+ const html = renderToString(<VeleroBackupRenderer data={unset} onNavigate={() => {}} />)
111
+ expect(html).toContain('not yet resolved')
112
+ })
113
+ })
114
+
115
+ /**
116
+ * Three states, not two. The lookup can be unanswered, answered-and-found, or
117
+ * answered-and-the-location-is-gone — and the last one has no phase to report,
118
+ * so without saying it the backup renders exactly as it would on a healthy
119
+ * bucket.
120
+ */
121
+ describe('what the page claims before the location lookup answers', () => {
122
+ const unset = {
123
+ metadata: { name: 'nightly', namespace: 'velero' },
124
+ spec: {},
125
+ status: { phase: 'Completed' },
126
+ }
127
+ const named = {
128
+ metadata: { name: 'nightly', namespace: 'velero' },
129
+ spec: { storageLocation: 'dr-replica' },
130
+ status: { phase: 'Completed' },
131
+ }
132
+
133
+ // Scoped to the Storage Location row: "default" is also the rendered value of
134
+ // two unset fields in Options, so a whole-document match proves nothing.
135
+ const storageCell = (html: string) => {
136
+ const i = html.indexOf('Storage Location')
137
+ if (i < 0) return ''
138
+ // Up to the end of this property row only. A fixed-width window runs into
139
+ // the next Section's header, which is itself a <button>, so every case
140
+ // matched a link whether or not one was rendered.
141
+ const end = html.indexOf('</div>', i)
142
+ return html.slice(i, end < 0 ? undefined : end)
143
+ }
144
+
145
+ // An unset location is only knowable from the list. Printing "default" before
146
+ // it answers is a guess that is wrong on any install that renamed it.
147
+ it('does not name a location it has not resolved', () => {
148
+ const cell = storageCell(renderToString(<VeleroBackupRenderer data={unset} onNavigate={() => {}} />))
149
+ expect(cell).toContain('not yet resolved')
150
+ expect(cell).not.toContain('>default<')
151
+ })
152
+
153
+ // A backup that names its own location is known regardless of the lookup.
154
+ it('still shows a location the backup names itself', () => {
155
+ const html = renderToString(<VeleroBackupRenderer data={named} onNavigate={() => {}} />)
156
+ expect(html).toContain('dr-replica')
157
+ expect(html).not.toContain('not yet resolved')
158
+ })
159
+
160
+ it('says so when the location it names is gone', () => {
161
+ const html = renderToString(
162
+ <VeleroBackupRenderer data={named} storageLocationName="dr-replica" storageLocationMissing onNavigate={() => {}} />,
163
+ )
164
+ expect(html).toContain('Not found')
165
+ expect(html).toContain('no longer exists')
166
+ })
167
+
168
+ // A link to an object that is not there is worse than plain text. Scoped to
169
+ // the cell: every collapsible Section header is itself a <button>, so the
170
+ // whole document always matches.
171
+ it('does not link a location that is gone', () => {
172
+ const missing = storageCell(renderToString(
173
+ <VeleroBackupRenderer data={named} storageLocationName="dr-replica" storageLocationMissing onNavigate={() => {}} />,
174
+ ))
175
+ const present = storageCell(renderToString(
176
+ <VeleroBackupRenderer data={named} storageLocationName="dr-replica" onNavigate={() => {}} />,
177
+ ))
178
+ expect(present).toContain('<button')
179
+ expect(missing).not.toContain('<button')
180
+ })
181
+ })
182
+
183
+ /**
184
+ * Where the name came from changes what can be said about it. A backup that
185
+ * names its own location and cannot find it has named something missing. A
186
+ * backup that names nothing has not — the name was produced by Velero's
187
+ * fallback rule, and blaming the object for it invents a claim.
188
+ */
189
+ describe('a location that cannot be found', () => {
190
+ const named = {
191
+ metadata: { name: 'nightly', namespace: 'velero' },
192
+ spec: { storageLocation: 'dr-replica' },
193
+ status: { phase: 'Completed' },
194
+ }
195
+ const unset = {
196
+ metadata: { name: 'nightly', namespace: 'velero' },
197
+ spec: {},
198
+ status: { phase: 'Completed' },
199
+ }
200
+
201
+ it('blames the backup only when the backup named it', () => {
202
+ const html = renderToString(
203
+ <VeleroBackupRenderer data={named} storageLocationName="dr-replica" storageLocationMissing />,
204
+ )
205
+ expect(html).toContain('names a storage location that no longer exists')
206
+ expect(html).toContain('Not found')
207
+ })
208
+
209
+ // The fallback resolved to the literal "default" because nothing carries
210
+ // spec.default. Saying the backup "names" that is attributing an invented
211
+ // name to the object.
212
+ it('says the namespace has no default when the backup named nothing', () => {
213
+ const html = renderToString(
214
+ <VeleroBackupRenderer data={unset} storageLocationName="default" storageLocationMissing />,
215
+ )
216
+ expect(html).toContain('names no storage location')
217
+ expect(html).toContain('this namespace has none')
218
+ expect(html).toContain('No default location')
219
+ expect(html).not.toContain('names a storage location that no longer exists')
220
+ })
221
+ })
@@ -1,8 +1,10 @@
1
+ import { VeleroRunMessages, type VeleroRunMessagesFetch } from './VeleroRunMessages'
1
2
  import { Archive, Clock, HardDrive, Filter } from 'lucide-react'
2
- import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
3
+ import { clsx } from 'clsx'
4
+ import { HEALTH_BADGE_COLORS } from '../../../utils/badge-colors'
5
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
6
  import {
4
7
  getBackupStatus,
5
- getBackupStorageLocation,
6
8
  getBackupIncludedNamespaces,
7
9
  getBackupExcludedNamespaces,
8
10
  getBackupIncludedResources,
@@ -14,6 +16,7 @@ import {
14
16
  getBackupWarnings,
15
17
  getBackupValidationErrors,
16
18
  getBackupTTL,
19
+ getBackupItemOperationTimeout,
17
20
  getBackupSnapshotVolumes,
18
21
  getBackupDefaultVolumesToFsBackup,
19
22
  getBackupVolumeSnapshotLocations,
@@ -22,13 +25,48 @@ import {
22
25
  isBackupPartialFailurePhase,
23
26
  } from '../resource-utils-velero'
24
27
  import { VeleroPhaseValue } from './velero-cells'
28
+ import { veleroPhaseLabel } from '../resource-utils-velero'
25
29
  import { formatAge } from '../resource-utils'
26
30
 
27
31
  interface VeleroBackupRendererProps {
28
32
  data: any
33
+ /**
34
+ * The phase of the storage location this backup was written to, when the host
35
+ * looked it up. Undefined means unresolved — not "healthy".
36
+ *
37
+ * A backup cannot observe the health of the bucket holding it. Its own status
38
+ * keeps saying Completed, accurately, while the location goes Unavailable and
39
+ * there is nothing to restore from. This page is where someone checks whether
40
+ * they can go back to this point, so the fact belongs here rather than one
41
+ * screen away.
42
+ */
43
+ storageLocationPhase?: string
44
+ /**
45
+ * The location this backup is actually stored in, resolved by the host
46
+ * against the location list. An unset `spec.storageLocation` means "whichever
47
+ * location carries spec.default", which this component cannot see.
48
+ *
49
+ * Undefined means the lookup has not answered. A backup that names its own
50
+ * location is still known by name in that state; one that does not is not
51
+ * known at all, and this page says so rather than printing the conventional
52
+ * name as though it had been resolved.
53
+ */
54
+ storageLocationName?: string
55
+ /**
56
+ * The lookup answered and no location by that name exists. Distinct from an
57
+ * unresolved lookup: a backup whose storage location has been deleted cannot
58
+ * be restored from, and without this it renders exactly like a healthy one.
59
+ */
60
+ storageLocationMissing?: boolean
61
+ /** Wired by the host, which owns the fetch. Optional: a consumer that does
62
+ * not wire it gets the counts on their own, with no button. */
63
+ messages?: VeleroRunMessagesFetch
64
+ /** Without it the location renders as plain text: the page names a location as
65
+ * the reason nothing can be restored and gives no way to go look at it. */
66
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
29
67
  }
30
68
 
31
- export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
69
+ export function VeleroBackupRenderer({ data, storageLocationPhase, storageLocationName, storageLocationMissing, messages, onNavigate }: VeleroBackupRendererProps) {
32
70
  const status = data.status || {}
33
71
  const conditions = status.conditions || []
34
72
 
@@ -86,7 +124,9 @@ export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
86
124
  <AlertBanner
87
125
  variant="warning"
88
126
  title={`${warnings} Warning(s)`}
89
- message={`Backup completed with ${warnings} warning(s).`}
127
+ message={phase === 'Completed'
128
+ ? `This backup completed, with ${warnings} warning(s).`
129
+ : `This backup has reported ${warnings} warning(s) so far. It is ${veleroPhaseLabel(phase).toLowerCase()}, so this is not a final count.`}
90
130
  />
91
131
  )}
92
132
 
@@ -118,6 +158,7 @@ export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
118
158
  : '0'
119
159
  } />
120
160
  </PropertyList>
161
+ {messages && <VeleroRunMessages {...messages} errors={errors} warnings={warnings} />}
121
162
  </Section>
122
163
 
123
164
  {/* Progress section (if in progress) */}
@@ -196,12 +237,83 @@ export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
196
237
  {/* Storage section */}
197
238
  <Section title="Storage" icon={HardDrive} defaultExpanded>
198
239
  <PropertyList>
199
- <Property label="Storage Location" value={getBackupStorageLocation(data)} />
240
+ {/* A backup that names its location is known by name whatever the
241
+ lookup is doing. One that does not is only known once the list
242
+ answers, because "unset" means whichever location carries
243
+ spec.default — so until then this says what the field says and
244
+ claims nothing further. */}
245
+ <Property
246
+ label="Storage Location"
247
+ value={(() => {
248
+ const declared = data?.spec?.storageLocation as string | undefined
249
+ const name = storageLocationName || declared
250
+ if (!name) {
251
+ return (
252
+ <span className="text-sm text-theme-text-tertiary">
253
+ Velero&rsquo;s default location, not yet resolved
254
+ </span>
255
+ )
256
+ }
257
+ return (
258
+ <span className="flex items-center gap-2">
259
+ {/* Not a link when the object is not there: a link to nothing
260
+ is worse than a name. */}
261
+ {storageLocationMissing ? (
262
+ <span className="text-sm font-mono text-theme-text-primary">{name}</span>
263
+ ) : (
264
+ <ResourceLink
265
+ name={name}
266
+ kind="BackupStorageLocation"
267
+ namespace={data?.metadata?.namespace ?? ''}
268
+ group="velero.io"
269
+ onNavigate={onNavigate}
270
+ />
271
+ )}
272
+ {storageLocationMissing && (
273
+ <span className={clsx('badge', HEALTH_BADGE_COLORS.unhealthy)}>
274
+ {declared ? 'Not found' : 'No default location'}
275
+ </span>
276
+ )}
277
+ {storageLocationPhase && storageLocationPhase !== 'Available' && (
278
+ <span className={clsx('badge', HEALTH_BADGE_COLORS.unhealthy)}>{storageLocationPhase}</span>
279
+ )}
280
+ </span>
281
+ )
282
+ })()}
283
+ />
284
+ {/* A location that is gone is not a location that is unhealthy, and
285
+ Velero writes no phase for one that does not exist — so without
286
+ this the backup renders exactly as it would on a healthy bucket.
287
+ Worded from where the name came from: a backup that names nothing
288
+ has not "named a location that no longer exists", and saying so
289
+ would attribute to the object a name the fallback invented. */}
290
+ {storageLocationMissing && (
291
+ <div className="text-xs text-warning-text">
292
+ {data?.spec?.storageLocation
293
+ ? 'This backup names a storage location that no longer exists in this namespace. Velero restores from the location recorded on the backup, so there is nothing here to restore from until it is recreated.'
294
+ : 'This backup names no storage location, which Velero resolves to the one marked default — and this namespace has none. Which location holds it cannot be determined here.'}
295
+ </div>
296
+ )}
297
+ {/* Only when it changes the answer. On a healthy location this would be
298
+ a warning about nothing, on every backup in the cluster. */}
299
+ {storageLocationPhase && storageLocationPhase !== 'Available' && (
300
+ <div className="text-xs text-warning-text">
301
+ {`This backup is stored in a location Velero reports as ${storageLocationPhase}. Its own phase does not change when that happens — until the location recovers, there is nothing here to restore from.`}
302
+ </div>
303
+ )}
200
304
  {vslLocations.length > 0 && (
201
305
  <Property label="Volume Snapshot Locations" value={
202
306
  <div className="flex flex-wrap gap-1">
203
307
  {vslLocations.map((loc: string) => (
204
- <span key={loc} className="badge-sm bg-theme-hover text-theme-text-secondary">{loc}</span>
308
+ <span key={loc} className="badge-sm bg-theme-hover text-theme-text-secondary">
309
+ <ResourceLink
310
+ name={loc}
311
+ kind="VolumeSnapshotLocation"
312
+ namespace={data?.metadata?.namespace ?? ''}
313
+ group="velero.io"
314
+ onNavigate={onNavigate}
315
+ />
316
+ </span>
205
317
  ))}
206
318
  </div>
207
319
  } />
@@ -213,6 +325,7 @@ export function VeleroBackupRenderer({ data }: VeleroBackupRendererProps) {
213
325
  <Section title="Options" icon={Clock}>
214
326
  <PropertyList>
215
327
  <Property label="TTL" value={getBackupTTL(data)} />
328
+ <Property label="Item Operation Timeout" value={getBackupItemOperationTimeout(data)} />
216
329
  <Property label="Snapshot Volumes" value={getBackupSnapshotVolumes(data)} />
217
330
  <Property label="Default FS Backup" value={getBackupDefaultVolumesToFsBackup(data)} />
218
331
  </PropertyList>
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { VeleroBackupRepositoryRenderer } from './VeleroBackupRepositoryRenderer'
4
+
5
+ const repo = (over: { phase?: string; message?: string } = {}) => ({
6
+ metadata: { name: 'repo-notready', namespace: 'velero' },
7
+ spec: {
8
+ repositoryType: 'kopia',
9
+ volumeNamespace: 'legacy-app',
10
+ backupStorageLocation: 'dr-replica',
11
+ maintenanceFrequency: '1h0m0s',
12
+ },
13
+ status: {
14
+ phase: over.phase ?? 'NotReady',
15
+ ...(over.message !== undefined ? { message: over.message } : {}),
16
+ },
17
+ })
18
+
19
+ /**
20
+ * A BackupRepository raises `BackupRepositoryNotReady`, and `status.message` is
21
+ * the only place Velero records why. Without a renderer of its own the issue
22
+ * pointed at a generic drawer, so the one fact worth arriving for was the one
23
+ * the page could not show.
24
+ */
25
+ describe('a repository Velero reports as not ready', () => {
26
+ it('leads with the reason Velero gave', () => {
27
+ const html = renderToString(
28
+ <VeleroBackupRepositoryRenderer
29
+ data={repo({ message: 'error running maintenance: unable to connect to repository' })}
30
+ />,
31
+ )
32
+ expect(html).toContain('unable to connect to repository')
33
+ })
34
+
35
+ // Velero can set NotReady with no message. Saying nothing would leave a red
36
+ // badge and no consequence, so the page states what not-ready costs.
37
+ it('names the consequence when Velero gave no reason', () => {
38
+ const html = renderToString(<VeleroBackupRepositoryRenderer data={repo()} />)
39
+ expect(html).toContain('legacy-app')
40
+ expect(html).toContain('cannot be written')
41
+ })
42
+
43
+ it('says nothing alarming when the repository is ready', () => {
44
+ const html = renderToString(
45
+ <VeleroBackupRepositoryRenderer data={repo({ phase: 'Ready' })} />,
46
+ )
47
+ expect(html).not.toContain('Repository Not Ready')
48
+ expect(html).not.toContain('cannot be written')
49
+ })
50
+
51
+ // The same dead end this integration closed on Backup and Restore: naming a
52
+ // Velero object the reader cannot reach.
53
+ it('lets the reader reach the location it writes into', () => {
54
+ const html = renderToString(
55
+ <VeleroBackupRepositoryRenderer data={repo()} onNavigate={() => {}} />,
56
+ )
57
+ expect(html).toContain('dr-replica')
58
+ expect(html).toMatch(/role="button"|<button|cursor-pointer/)
59
+ })
60
+
61
+ // Maintenance frequency alone does not say whether maintenance is happening.
62
+ // The pair does, which is why both are on the page.
63
+ it('shows the maintenance schedule beside the last run', () => {
64
+ const html = renderToString(<VeleroBackupRepositoryRenderer data={repo({ phase: 'Ready' })} />)
65
+ expect(html).toContain('Maintenance Frequency')
66
+ expect(html).toContain('1h0m0s')
67
+ expect(html).toContain('Last Maintenance')
68
+ })
69
+ })
@@ -0,0 +1,96 @@
1
+ import { Database, Clock } from 'lucide-react'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
+ import {
4
+ getBackupRepositoryStatus,
5
+ getBackupRepositoryType,
6
+ getBackupRepositoryVolumeNamespace,
7
+ getBackupRepositoryLocation,
8
+ getBackupRepositoryMaintenanceFrequency,
9
+ getBackupRepositoryLastMaintenance,
10
+ getBackupRepositoryMessage,
11
+ } from '../resource-utils-velero'
12
+ import { VeleroPhaseValue } from './velero-cells'
13
+
14
+ interface VeleroBackupRepositoryRendererProps {
15
+ data: any
16
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
17
+ }
18
+
19
+ /**
20
+ * A BackupRepository is where Velero's file-level backups of a namespace's
21
+ * volumes are written. It raises its own issue when it goes `NotReady`, and
22
+ * `status.message` is the only place the reason for that exists.
23
+ */
24
+ export function VeleroBackupRepositoryRenderer({ data, onNavigate }: VeleroBackupRepositoryRendererProps) {
25
+ const status = data.status || {}
26
+ const repoStatus = getBackupRepositoryStatus(data)
27
+ const message = getBackupRepositoryMessage(data)
28
+ const volumeNamespace = getBackupRepositoryVolumeNamespace(data)
29
+ const location = getBackupRepositoryLocation(data)
30
+
31
+ return (
32
+ <>
33
+ {/* The reason, not just the state. A repository that is not ready means
34
+ volume backups for its namespace are not being written, and Velero puts
35
+ that consequence nowhere on the object. */}
36
+ {repoStatus.level === 'unhealthy' && (
37
+ <AlertBanner
38
+ variant="error"
39
+ title="Repository Not Ready"
40
+ message={message
41
+ ? message
42
+ : `Velero reported this repository as not ready and gave no reason. Volume backups for ${volumeNamespace === '-' ? 'its namespace' : volumeNamespace} cannot be written while it stays that way.`}
43
+ />
44
+ )}
45
+
46
+ <Section title="Status" icon={Clock} defaultExpanded>
47
+ <PropertyList>
48
+ <Property label="Phase" value={
49
+ <VeleroPhaseValue status={repoStatus} phase={status.phase || ''} />
50
+ } />
51
+ {/* Velero runs maintenance on its own schedule; a repository whose
52
+ last run is far older than its frequency is the shape of a
53
+ controller that has stopped. The two are shown together so that
54
+ comparison does not need a second screen. */}
55
+ <Property label="Last Maintenance" value={getBackupRepositoryLastMaintenance(data)} />
56
+ <Property label="Maintenance Frequency" value={getBackupRepositoryMaintenanceFrequency(data)} />
57
+ </PropertyList>
58
+ </Section>
59
+
60
+ <Section title="Repository" icon={Database} defaultExpanded>
61
+ <PropertyList>
62
+ <Property label="Type" value={getBackupRepositoryType(data)} />
63
+ {/* What this repository is for. Velero partitions repositories by
64
+ namespace, so this is the namespace whose volumes it holds, not
65
+ the namespace the object lives in. */}
66
+ <Property label="Volume Namespace" value={
67
+ volumeNamespace === '-' ? '-' : (
68
+ <ResourceLink
69
+ name={volumeNamespace}
70
+ kind="Namespace"
71
+ namespace=""
72
+ onNavigate={onNavigate}
73
+ />
74
+ )
75
+ } />
76
+ {/* Reachable, like every other location reference in this integration.
77
+ A repository is written into a storage location, and its own status
78
+ says nothing about that location's health. */}
79
+ <Property label="Storage Location" value={
80
+ location === '-' ? '-' : (
81
+ <ResourceLink
82
+ name={location}
83
+ kind="BackupStorageLocation"
84
+ namespace={data?.metadata?.namespace ?? ''}
85
+ group="velero.io"
86
+ onNavigate={onNavigate}
87
+ />
88
+ )
89
+ } />
90
+ </PropertyList>
91
+ </Section>
92
+
93
+ <ConditionsSection conditions={status.conditions || []} />
94
+ </>
95
+ )
96
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { VeleroRestoreRenderer } from './VeleroRestoreRenderer'
4
+
5
+ const restore = (status: Record<string, unknown>) => ({
6
+ metadata: { name: 'live-restore', namespace: 'velero' },
7
+ spec: { backupName: 'live-completed' },
8
+ status,
9
+ })
10
+
11
+ /**
12
+ * The restore side carries the same claims as the backup side and its own
13
+ * wording for them, so pinning one does not pin the other. Both banners below
14
+ * were wrong in the same way at the same time.
15
+ */
16
+ describe('what a restore claims about itself', () => {
17
+ // A count taken mid-run is not a final count. Velero raises warnings as it
18
+ // goes, so a restore still working can already have several.
19
+ it('does not say a restore in flight completed', () => {
20
+ const html = renderToString(
21
+ <VeleroRestoreRenderer data={restore({ phase: 'InProgress', warnings: 2 })} />,
22
+ )
23
+ expect(html).not.toMatch(/completed, with/)
24
+ expect(html).toContain('not a final count')
25
+ })
26
+
27
+ it('still says completed when it did', () => {
28
+ const html = renderToString(
29
+ <VeleroRestoreRenderer data={restore({ phase: 'Completed', warnings: 2 })} />,
30
+ )
31
+ expect(html).toContain('completed, with 2 warning(s)')
32
+ expect(html).not.toContain('not a final count')
33
+ })
34
+
35
+ // Velero's own words beat ours whenever it supplies them.
36
+ it('leads with the reason Velero gave for refusing to start', () => {
37
+ const html = renderToString(
38
+ <VeleroRestoreRenderer
39
+ data={restore({ phase: 'FailedValidation', failureReason: 'backup not found' })}
40
+ />,
41
+ )
42
+ expect(html).toContain('backup not found')
43
+ expect(html).not.toContain('Velero rejected this restore before it started')
44
+ })
45
+
46
+ it('says nothing was restored when Velero gave no reason', () => {
47
+ const html = renderToString(
48
+ <VeleroRestoreRenderer
49
+ data={restore({ phase: 'FailedValidation', validationErrors: ['Backup live-completed not found'] })}
50
+ />,
51
+ )
52
+ expect(html).toContain('nothing was restored')
53
+ expect(html).toContain('Backup live-completed not found')
54
+ })
55
+ })