@skyhook-io/k8s-ui 1.10.4 → 1.11.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 (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getCellFilterValue } from './resource-utils'
3
+ import {
4
+ getBackupStatus,
5
+ getRestoreStatus,
6
+ getScheduleStatus,
7
+ isBackupActivePhase,
8
+ isBackupPartialFailurePhase,
9
+ isVeleroResource,
10
+ getScheduleCronInfo,
11
+ getBackupRepositoryStatus,
12
+ } from './resource-utils-velero'
13
+
14
+ const backup = (phase: string, extra: Record<string, unknown> = {}) => ({
15
+ apiVersion: 'velero.io/v1',
16
+ kind: 'Backup',
17
+ status: { phase, ...extra },
18
+ })
19
+
20
+ const schedule = (spec: Record<string, unknown>, status: Record<string, unknown> = {}) => ({
21
+ apiVersion: 'velero.io/v1',
22
+ kind: 'Schedule',
23
+ spec,
24
+ status,
25
+ })
26
+
27
+ // The status column's filter dropdown reads getCellFilterValue, which is keyed
28
+ // on normalizeKindToPlural's output. Velero's Restore/Schedule keys are
29
+ // group-qualified, so the unqualified plurals no longer reach it — if these
30
+ // branches aren't kept in step, the filter silently falls through to raw
31
+ // status.phase while the cell still renders the curated badge, and the two
32
+ // disagree.
33
+ describe('getCellFilterValue — Velero status column', () => {
34
+ it('filters a paused Schedule as Paused, matching what the cell renders', () => {
35
+ const paused = schedule({ paused: true }, { phase: 'Enabled' })
36
+ expect(getScheduleStatus(paused).text).toBe('Paused')
37
+ expect(getCellFilterValue(paused, 'status', 'veleroschedules')).toBe('Paused')
38
+ })
39
+
40
+ it('offers the BackupRepository dropdown the same string the badge shows', () => {
41
+ // Asserted as literals rather than against the reader: comparing the filter
42
+ // to getBackupRepositoryStatus().text passes even when both are wrong. The
43
+ // contract is that the dropdown offers "Not ready" — the label — and never
44
+ // the raw NotReady phase the generic fallback would have produced.
45
+ const repo = {
46
+ apiVersion: 'velero.io/v1',
47
+ kind: 'BackupRepository',
48
+ status: { phase: 'NotReady' },
49
+ }
50
+ expect(getBackupRepositoryStatus(repo).text).toBe('Not ready')
51
+ expect(getCellFilterValue(repo, 'status', 'backuprepositories')).toBe('Not ready')
52
+ expect(getCellFilterValue(repo, 'status', 'backuprepositories')).not.toBe('NotReady')
53
+ })
54
+
55
+ it('filters a validation-error-only Schedule as FailedValidation', () => {
56
+ // Velero leaves the phase empty on some validation failures. Falling
57
+ // through to status.phase would make the row unfilterable — the exact
58
+ // failure this integration exists to surface.
59
+ const invalid = schedule({ schedule: 'not-a-cron' }, { validationErrors: ['invalid schedule'] })
60
+ expect(getScheduleStatus(invalid).text).toBe('Rejected')
61
+ expect(getCellFilterValue(invalid, 'status', 'veleroschedules')).toBe('Rejected')
62
+ })
63
+
64
+ it('filters Restore phases through the curated reader', () => {
65
+ const restore = { apiVersion: 'velero.io/v1', kind: 'Restore', status: { phase: 'PartiallyFailed' } }
66
+ expect(getCellFilterValue(restore, 'status', 'velerorestores')).toBe('Partially failed')
67
+ })
68
+
69
+ it('lets a real failure outrank paused, so neither fact is lost', () => {
70
+ // Previously paused won outright and a paused+invalid schedule showed only
71
+ // "Paused" — the table gave no sign it was also broken. The badge now
72
+ // carries the failure (the fact to act on) and the cell adds a paused
73
+ // indicator alongside.
74
+ const pausedOnly = schedule({ paused: true }, { phase: 'Enabled' })
75
+ expect(getScheduleStatus(pausedOnly).text).toBe('Paused')
76
+
77
+ const pausedAndInvalid = schedule({ paused: true }, { phase: 'FailedValidation', validationErrors: ['bad cron'] })
78
+ expect(getScheduleStatus(pausedAndInvalid).text).toBe('Rejected')
79
+ // and it filters as the failure, so it is reachable from the status filter
80
+ expect(getCellFilterValue(pausedAndInvalid, 'status', 'veleroschedules')).toBe('Rejected')
81
+ })
82
+
83
+ it('leaves a foreign CRD sharing the plural on the generic reader', () => {
84
+ // rancher/backup-restore-operator ships restores.resources.cattle.io. It
85
+ // resolves to the unqualified key, which must NOT hit Velero's reader.
86
+ const rancher = { apiVersion: 'resources.cattle.io/v1', kind: 'Restore', status: { phase: 'Completed' } }
87
+ expect(getCellFilterValue(rancher, 'status', 'restores')).toBe('Completed')
88
+ const rancherPaused = { apiVersion: 'resources.cattle.io/v1', kind: 'Restore', spec: { paused: true }, status: {} }
89
+ expect(getCellFilterValue(rancherPaused, 'status', 'restores')).toBe('')
90
+ })
91
+
92
+ it('still filters BackupStorageLocation, whose key is not group-qualified', () => {
93
+ const bsl = { apiVersion: 'velero.io/v1', kind: 'BackupStorageLocation', status: { phase: 'Unavailable' } }
94
+ expect(getCellFilterValue(bsl, 'status', 'backupstoragelocations')).toBe('Unavailable')
95
+ })
96
+ })
97
+
98
+ describe('Velero Backup phase vocabulary', () => {
99
+ // The table label rule: sentence-case the upstream phase, diverge only where
100
+ // it does not fit. Each row below is (phase, label, level) — the label column
101
+ // is the contract, and the three departures from upstream are called out.
102
+ it.each([
103
+ ['Completed', 'Completed', 'healthy'],
104
+ ['New', 'New', 'unknown'],
105
+ ['Queued', 'Queued', 'neutral'],
106
+ ['Failed', 'Failed', 'unhealthy'],
107
+ ['Deleting', 'Deleting', 'degraded'],
108
+ ['Finalizing', 'Finalizing', 'neutral'],
109
+ // sentence-cased, not renamed
110
+ ['InProgress', 'In progress', 'neutral'],
111
+ ['ReadyToStart', 'Ready to start', 'neutral'],
112
+ ['PartiallyFailed', 'Partially failed', 'alert'],
113
+ // the three genuine departures
114
+ ['WaitingForPluginOperations', 'Plugin work', 'neutral'],
115
+ ['FailedValidation', 'Rejected', 'unhealthy'],
116
+ ['FinalizingPartiallyFailed', 'Partially failed', 'alert'],
117
+ ['WaitingForPluginOperationsPartiallyFailed', 'Partially failed', 'alert'],
118
+ ])('%s renders as "%s" (%s)', (phase, label, level) => {
119
+ const status = getBackupStatus(backup(phase))
120
+ expect(status.text).toBe(label)
121
+ expect(status.level).toBe(level)
122
+ })
123
+
124
+ // The collapse is deliberate and lossy: three distinct phases share one
125
+ // label, so the column cannot tell you whether a partially-failed run has
126
+ // finished. Pinned so the loss is a decision rather than a drift.
127
+ it('collapses all three partial-failure phases onto one label', () => {
128
+ const labels = ['PartiallyFailed', 'FinalizingPartiallyFailed', 'WaitingForPluginOperationsPartiallyFailed']
129
+ .map(p => getBackupStatus(backup(p)).text)
130
+ expect(new Set(labels)).toEqual(new Set(['Partially failed']))
131
+ })
132
+
133
+ // Two labels a reader cannot tell apart fail the way an exact duplicate
134
+ // does. Every other phase must stay distinguishable.
135
+ it('gives no two non-partial phases the same label', () => {
136
+ const phases = ['New','Queued','ReadyToStart','InProgress','WaitingForPluginOperations','Finalizing','Completed','Failed','FailedValidation','Deleting']
137
+ const labels = phases.map(p => getBackupStatus(backup(p)).text)
138
+ expect(new Set(labels).size).toBe(phases.length)
139
+ })
140
+
141
+ it('no longer knows the fictional Uploading phase', () => {
142
+ // "Uploading" is not in Velero's enum and never has been; it must not be
143
+ // special-cased back in.
144
+ expect(getBackupStatus(backup('Uploading')).level).toBe('unknown')
145
+ })
146
+
147
+ it('treats the partial phases as both active and partially failed', () => {
148
+ // Both are true at once: plugin operations are still running AND some items
149
+ // already failed. The renderer shows a progress bar and a warning banner.
150
+ expect(isBackupActivePhase('WaitingForPluginOperationsPartiallyFailed')).toBe(true)
151
+ expect(isBackupPartialFailurePhase('WaitingForPluginOperationsPartiallyFailed')).toBe(true)
152
+ expect(isBackupActivePhase('Failed')).toBe(false)
153
+ expect(isBackupPartialFailurePhase('Failed')).toBe(false)
154
+ })
155
+
156
+ it('gives Restore the same partial/validation vocabulary', () => {
157
+ expect(getRestoreStatus({ status: { phase: 'FailedValidation' } }).level).toBe('unhealthy')
158
+ expect(getRestoreStatus({ status: { phase: 'FinalizingPartiallyFailed' } }).level).toBe('alert')
159
+ })
160
+ })
161
+
162
+ describe('isVeleroResource', () => {
163
+ it('selects only the velero.io group', () => {
164
+ expect(isVeleroResource({ apiVersion: 'velero.io/v1' })).toBe(true)
165
+ expect(isVeleroResource({ apiVersion: 'resources.cattle.io/v1' })).toBe(false)
166
+ expect(isVeleroResource({ apiVersion: 'postgresql.cnpg.io/v1' })).toBe(false)
167
+ // A group that merely ends in velero.io must not match.
168
+ expect(isVeleroResource({ apiVersion: 'notvelero.io/v1' })).toBe(false)
169
+ expect(isVeleroResource({})).toBe(false)
170
+ expect(isVeleroResource(null)).toBe(false)
171
+ })
172
+ })
173
+
174
+ describe('getScheduleCronInfo', () => {
175
+ const sched = (schedule: string, status?: any) => ({ spec: { schedule }, status })
176
+
177
+ it('reads a recognised cron as raw plus plain English', () => {
178
+ const r = getScheduleCronInfo(sched('0 1 * * *'))
179
+ expect(r).toEqual({ cron: '0 1 * * *', readable: 'Daily at 1:00', malformed: false })
180
+ })
181
+
182
+ it('leaves readable empty when cronToHuman has no phrasing for the expression', () => {
183
+ // cronToHuman returns its input unchanged for shapes it does not recognise.
184
+ // Rendering that would print the same string on both lines.
185
+ const r = getScheduleCronInfo(sched('0 3 * * 0'))
186
+ expect(r.malformed).toBe(false)
187
+ expect(r.readable).toBe('')
188
+ })
189
+
190
+ it.each(['not-a-cron', 'bad-cron', '0 1 * *', '0 1 * * * *'])('flags %s as malformed', (c) => {
191
+ const r = getScheduleCronInfo(sched(c))
192
+ expect(r.malformed).toBe(true)
193
+ expect(r.readable).toBe('')
194
+ expect(r.cron).toBe(c)
195
+ })
196
+
197
+ it('does not flag timezone-prefixed crons, which Velero documents', () => {
198
+ // From Velero's own CLI docs:
199
+ // velero schedule create x --schedule="CRON_TZ=America/New_York 0 3 * * *"
200
+ const r = getScheduleCronInfo(sched('CRON_TZ=America/New_York 0 3 * * *'))
201
+ expect(r.malformed).toBe(false)
202
+ expect(r.cron).toBe('CRON_TZ=America/New_York 0 3 * * *')
203
+ // No phrasing: "Daily at 3:00" would read as cluster time, which is exactly
204
+ // what the prefix says it is not.
205
+ expect(r.readable).toBe('')
206
+ })
207
+
208
+ it('accepts the legacy TZ= spelling and still catches a bad zoned expression', () => {
209
+ expect(getScheduleCronInfo(sched('TZ=UTC 0 3 * * *')).malformed).toBe(false)
210
+ expect(getScheduleCronInfo(sched('CRON_TZ=Europe/Berlin not-a-cron')).malformed).toBe(true)
211
+ })
212
+
213
+ it('does not flag @-descriptors, which the field count would reject', () => {
214
+ expect(getScheduleCronInfo(sched('@daily')).malformed).toBe(false)
215
+ expect(getScheduleCronInfo(sched('@every 1h')).malformed).toBe(false)
216
+ })
217
+
218
+ it('does not call a valid cron malformed just because the Schedule was rejected', () => {
219
+ // Observed live: a schedule can fail validation for a missing storage
220
+ // location while its cron is fine. Reddening the cron there accuses the
221
+ // wrong field, so malformed must not be derived from phase.
222
+ const r = getScheduleCronInfo(
223
+ sched('0 4 * * *', { phase: 'FailedValidation', validationErrors: ['storage location "gone" not found'] })
224
+ )
225
+ expect(r.malformed).toBe(false)
226
+ expect(r.readable).toBe('Daily at 4:00')
227
+ })
228
+
229
+ it('tolerates padded whitespace and trims the raw value', () => {
230
+ const r = getScheduleCronInfo(sched(' 0 1 * * * '))
231
+ expect(r.malformed).toBe(false)
232
+ })
233
+
234
+ it('renders a missing schedule as a dash, not as malformed', () => {
235
+ expect(getScheduleCronInfo({ spec: {} })).toEqual({ cron: '-', readable: '', malformed: false })
236
+ })
237
+ })
@@ -1,35 +1,136 @@
1
1
  // Velero CRD utility functions
2
2
 
3
3
  import type { StatusBadge } from './resource-utils'
4
- import { healthColors, formatAge, formatDuration } from './resource-utils'
4
+ import { healthColors, formatAge, formatDuration, cronToHuman } from './resource-utils'
5
+
6
+ // Several Velero plurals are shared with other operators —
7
+ // rancher/backup-restore-operator ships backups/restores.resources.cattle.io,
8
+ // CNPG ships backups.postgresql.cnpg.io, and `schedules` is a common CRD name.
9
+ // Renderers, status readers and cells must therefore select on the API group,
10
+ // not on the plural alone, or a foreign resource renders through Velero's UI.
11
+ export function isVeleroResource(resource: any): boolean {
12
+ return typeof resource?.apiVersion === 'string' && resource.apiVersion.startsWith('velero.io/')
13
+ }
14
+
15
+ // ============================================================================
16
+ // TABLE LABELS
17
+ // ============================================================================
18
+
19
+ // The rule: sentence-case the upstream phase; diverge only where it does not
20
+ // fit. Most of the status column is therefore Velero's own vocabulary as
21
+ // typography, not a rename — which is what keeps it recognisable next to
22
+ // `velero backup describe`. The exact phase stays reachable in the detail
23
+ // drawer and in the badge tooltip.
24
+ //
25
+ // The budget is 96px: a w-36 column less 32px of cell padding and 16px of badge
26
+ // padding. Labels are held to at least 8px of headroom — sub-pixel shortfalls
27
+ // still render an ellipsis, and the ellipsis glyph costs two more characters.
28
+ const VELERO_LABEL_EXCEPTIONS: Record<string, string> = {
29
+ // 175px, so no defensible column width. "Finishing up" was the obvious
30
+ // alternative and is worse: a reader cannot tell it apart from the real
31
+ // `Finalizing` phase, and two labels that look like the same state fail the
32
+ // way an exact duplicate does, only more slowly.
33
+ WaitingForPluginOperations: 'Plugin work',
34
+ // "Validation failed" measures 91px — inside the noise floor at a 96px
35
+ // budget — and the Schedule cell needs room for a paused indicator beside it.
36
+ // "Rejected" also states the actionable fact: Velero refused this before it
37
+ // ran, so nothing happened at all.
38
+ FailedValidation: 'Rejected',
39
+ // 237px and 147px. Both collapse onto the plain partial-failure label, which
40
+ // makes the column non-injective: from the table you cannot tell whether a
41
+ // partially-failed run has finished. That is acceptable only because the
42
+ // drawer prints the raw `.status.phase` next to the badge — see
43
+ // `VeleroPhaseValue`. Remove that and this stops being a display choice and
44
+ // becomes lost information.
45
+ WaitingForPluginOperationsPartiallyFailed: 'Partially failed',
46
+ FinalizingPartiallyFailed: 'Partially failed',
47
+ }
48
+
49
+ // PartiallyFailed -> "Partially failed", ReadyToStart -> "Ready to start".
50
+ function sentenceCasePhase(phase: string): string {
51
+ return phase
52
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
53
+ .split(' ')
54
+ .map((word, i) => (i === 0 ? word : word.toLowerCase()))
55
+ .join(' ')
56
+ }
57
+
58
+ // The table label for a Velero phase. Display only — the issues engine and MCP
59
+ // keep the raw phase, because those are API surfaces read by agents rather than
60
+ // a column read by a human.
61
+ export function veleroPhaseLabel(phase: string): string {
62
+ if (!phase) return 'Unknown'
63
+ return VELERO_LABEL_EXCEPTIONS[phase] ?? sentenceCasePhase(phase)
64
+ }
5
65
 
6
66
  // ============================================================================
7
67
  // BACKUP UTILITIES
8
68
  // ============================================================================
9
69
 
70
+ // Phases in which a Backup or Restore is still executing. Velero has no
71
+ // single "running" flag — the set below is the v1.18 enum minus the terminal
72
+ // and pre-start phases. `Finalizing`/`WaitingForPluginOperations` and their
73
+ // *PartiallyFailed twins are still doing work, so the progress bar applies.
74
+ const BACKUP_ACTIVE_PHASES = new Set([
75
+ 'InProgress',
76
+ 'WaitingForPluginOperations',
77
+ 'WaitingForPluginOperationsPartiallyFailed',
78
+ 'Finalizing',
79
+ 'FinalizingPartiallyFailed',
80
+ ])
81
+
82
+ // Phases meaning "some of the backup made it, some didn't". Distinct from a
83
+ // plain failure: the data is partially recoverable.
84
+ const BACKUP_PARTIAL_PHASES = new Set([
85
+ 'PartiallyFailed',
86
+ 'FinalizingPartiallyFailed',
87
+ 'WaitingForPluginOperationsPartiallyFailed',
88
+ ])
89
+
90
+ export function isBackupActivePhase(phase: string): boolean {
91
+ return BACKUP_ACTIVE_PHASES.has(phase)
92
+ }
93
+
94
+ export function isBackupPartialFailurePhase(phase: string): boolean {
95
+ return BACKUP_PARTIAL_PHASES.has(phase)
96
+ }
97
+
10
98
  export function getBackupStatus(resource: any): StatusBadge {
11
99
  const phase = resource.status?.phase || ''
12
100
 
13
101
  switch (phase) {
14
102
  case 'Completed':
15
- return { text: 'Completed', color: healthColors.healthy, level: 'healthy' }
103
+ return { text: veleroPhaseLabel('Completed'), color: healthColors.healthy, level: 'healthy' }
16
104
  case 'InProgress':
17
- return { text: 'InProgress', color: healthColors.neutral, level: 'neutral' }
18
- case 'Uploading':
19
- return { text: 'Uploading', color: healthColors.neutral, level: 'neutral' }
105
+ case 'WaitingForPluginOperations':
106
+ case 'Finalizing':
107
+ case 'Queued':
108
+ case 'ReadyToStart':
109
+ return { text: veleroPhaseLabel(phase), color: healthColors.neutral, level: 'neutral' }
110
+ // Partial failure is worse than "degraded" (some data is already lost) but
111
+ // not a total loss — the orange `alert` tier separates the two.
20
112
  case 'PartiallyFailed':
21
- return { text: 'PartiallyFailed', color: healthColors.degraded, level: 'degraded' }
113
+ case 'FinalizingPartiallyFailed':
114
+ case 'WaitingForPluginOperationsPartiallyFailed':
115
+ return { text: veleroPhaseLabel(phase), color: healthColors.alert, level: 'alert' }
22
116
  case 'Failed':
23
- return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
117
+ case 'FailedValidation':
118
+ return { text: veleroPhaseLabel(phase), color: healthColors.unhealthy, level: 'unhealthy' }
24
119
  case 'Deleting':
25
- return { text: 'Deleting', color: healthColors.degraded, level: 'degraded' }
120
+ return { text: veleroPhaseLabel('Deleting'), color: healthColors.degraded, level: 'degraded' }
26
121
  case 'New':
27
- return { text: 'New', color: healthColors.unknown, level: 'unknown' }
122
+ return { text: veleroPhaseLabel('New'), color: healthColors.unknown, level: 'unknown' }
28
123
  default:
29
- return { text: phase || 'Unknown', color: healthColors.unknown, level: 'unknown' }
124
+ return { text: veleroPhaseLabel(phase), color: healthColors.unknown, level: 'unknown' }
30
125
  }
31
126
  }
32
127
 
128
+ // Backups queued behind the concurrency limit (v1.18+) report their position.
129
+ export function getBackupQueuePosition(resource: any): number | null {
130
+ const pos = resource.status?.queuePosition
131
+ return typeof pos === 'number' ? pos : null
132
+ }
133
+
33
134
  export function getBackupStorageLocation(resource: any): string {
34
135
  return resource.spec?.storageLocation || 'default'
35
136
  }
@@ -115,25 +216,36 @@ export function getBackupVolumeSnapshotLocations(resource: any): string[] {
115
216
  // RESTORE UTILITIES
116
217
  // ============================================================================
117
218
 
219
+ // Restore shares Backup's phase vocabulary except for the pre-start
220
+ // (`Queued`/`ReadyToStart`) and `Deleting` phases, which only Backup has.
118
221
  export function getRestoreStatus(resource: any): StatusBadge {
119
222
  const phase = resource.status?.phase || ''
120
223
 
121
224
  switch (phase) {
122
225
  case 'Completed':
123
- return { text: 'Completed', color: healthColors.healthy, level: 'healthy' }
226
+ return { text: veleroPhaseLabel('Completed'), color: healthColors.healthy, level: 'healthy' }
124
227
  case 'InProgress':
125
- return { text: 'InProgress', color: healthColors.neutral, level: 'neutral' }
228
+ case 'WaitingForPluginOperations':
229
+ case 'Finalizing':
230
+ return { text: veleroPhaseLabel(phase), color: healthColors.neutral, level: 'neutral' }
126
231
  case 'PartiallyFailed':
127
- return { text: 'PartiallyFailed', color: healthColors.degraded, level: 'degraded' }
232
+ case 'FinalizingPartiallyFailed':
233
+ case 'WaitingForPluginOperationsPartiallyFailed':
234
+ return { text: veleroPhaseLabel(phase), color: healthColors.alert, level: 'alert' }
128
235
  case 'Failed':
129
- return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
236
+ case 'FailedValidation':
237
+ return { text: veleroPhaseLabel(phase), color: healthColors.unhealthy, level: 'unhealthy' }
130
238
  case 'New':
131
- return { text: 'New', color: healthColors.unknown, level: 'unknown' }
239
+ return { text: veleroPhaseLabel('New'), color: healthColors.unknown, level: 'unknown' }
132
240
  default:
133
- return { text: phase || 'Unknown', color: healthColors.unknown, level: 'unknown' }
241
+ return { text: veleroPhaseLabel(phase), color: healthColors.unknown, level: 'unknown' }
134
242
  }
135
243
  }
136
244
 
245
+ export function getRestoreValidationErrors(resource: any): string[] {
246
+ return resource.status?.validationErrors || []
247
+ }
248
+
137
249
  export function getRestoreBackupName(resource: any): string {
138
250
  return resource.spec?.backupName || '-'
139
251
  }
@@ -191,24 +303,78 @@ export function getScheduleStatus(resource: any): StatusBadge {
191
303
  const phase = resource.status?.phase || ''
192
304
  const isPaused = resource.spec?.paused === true
193
305
 
306
+ // Velero leaves the phase empty on some validation failures and only records
307
+ // the reason in status.validationErrors, so the array is the authoritative
308
+ // signal — a schedule with errors is not producing backups whatever the phase.
309
+ const isRejected = phase === 'FailedValidation' || getScheduleValidationErrors(resource).length > 0
310
+
311
+ // A real failure outranks paused. Pausing is a state the operator chose; being
312
+ // rejected is one they need to fix, and it survives resuming. When both are
313
+ // true the cell renders this badge plus a separate paused indicator, so
314
+ // neither fact is lost — previously paused won outright and the table showed
315
+ // no sign the schedule was also invalid.
316
+ if (isRejected) {
317
+ return { text: veleroPhaseLabel('FailedValidation'), color: healthColors.unhealthy, level: 'unhealthy' }
318
+ }
319
+
194
320
  if (isPaused) {
195
321
  return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
196
322
  }
197
323
 
198
324
  switch (phase) {
199
325
  case 'Enabled':
200
- return { text: 'Enabled', color: healthColors.healthy, level: 'healthy' }
326
+ return { text: veleroPhaseLabel('Enabled'), color: healthColors.healthy, level: 'healthy' }
201
327
  case 'FailedValidation':
202
- return { text: 'FailedValidation', color: healthColors.unhealthy, level: 'unhealthy' }
328
+ return { text: veleroPhaseLabel('FailedValidation'), color: healthColors.unhealthy, level: 'unhealthy' }
203
329
  default:
204
- return { text: phase || 'Unknown', color: healthColors.unknown, level: 'unknown' }
330
+ return { text: veleroPhaseLabel(phase), color: healthColors.unknown, level: 'unknown' }
205
331
  }
206
332
  }
207
333
 
334
+ export function getScheduleValidationErrors(resource: any): string[] {
335
+ return resource.status?.validationErrors || []
336
+ }
337
+
208
338
  export function getScheduleCron(resource: any): string {
209
339
  return resource.spec?.schedule || '-'
210
340
  }
211
341
 
342
+ // The schedule cell shows the raw cron with its plain-English reading beneath,
343
+ // matching CronJob. Two departures from that cell, both deliberate:
344
+ //
345
+ // `readable` is empty when cronToHuman gives the expression back unchanged —
346
+ // it recognises a handful of shapes and returns the input for the rest, so
347
+ // rendering unconditionally prints the same string twice (which CronJob does).
348
+ //
349
+ // `malformed` is a field-count check, the same thing Velero's own parser
350
+ // complains about first ("expected exactly 5 fields"). It is deliberately not
351
+ // derived from FailedValidation: a schedule can be rejected for a missing
352
+ // storage location while its cron is perfectly good, and reddening the cron
353
+ // there would accuse the wrong field. `@`-descriptors pass unflagged, and a
354
+ // 5-field expression with an out-of-range number is not caught — the status
355
+ // badge carries Velero's verdict, so under-marking beats a false accusation.
356
+ export function getScheduleCronInfo(resource: any): {
357
+ cron: string
358
+ readable: string
359
+ malformed: boolean
360
+ } {
361
+ const cron = (resource.spec?.schedule || '').trim()
362
+ if (!cron) return { cron: '-', readable: '', malformed: false }
363
+
364
+ // Velero documents `CRON_TZ=America/New_York 0 3 * * *`, and robfig/cron also
365
+ // accepts the legacy `TZ=` spelling. Both put a sixth token in front of the
366
+ // expression, so the prefix comes off before anything is counted.
367
+ const zoned = /^(CRON_TZ|TZ)=(\S+)\s+/i.exec(cron)
368
+ const bare = zoned ? cron.slice(zoned[0].length) : cron
369
+
370
+ const malformed = !bare.startsWith('@') && bare.split(/\s+/).length !== 5
371
+ // No plain-English line for a zoned expression: "Daily at 3:00" reads as
372
+ // cluster time, and the whole point of the prefix is that it isn't. The raw
373
+ // value already names the zone.
374
+ const readable = malformed || zoned ? '' : cronToHuman(bare)
375
+ return { cron, readable: readable === cron ? '' : readable, malformed }
376
+ }
377
+
212
378
  export function getScheduleLastBackup(resource: any): string {
213
379
  const lastBackup = resource.status?.lastBackup
214
380
  if (!lastBackup) return 'Never'
@@ -236,11 +402,11 @@ export function getBSLStatus(resource: any): StatusBadge {
236
402
 
237
403
  switch (phase) {
238
404
  case 'Available':
239
- return { text: 'Available', color: healthColors.healthy, level: 'healthy' }
405
+ return { text: veleroPhaseLabel('Available'), color: healthColors.healthy, level: 'healthy' }
240
406
  case 'Unavailable':
241
- return { text: 'Unavailable', color: healthColors.unhealthy, level: 'unhealthy' }
407
+ return { text: veleroPhaseLabel('Unavailable'), color: healthColors.unhealthy, level: 'unhealthy' }
242
408
  default:
243
- return { text: phase || 'Unknown', color: healthColors.unknown, level: 'unknown' }
409
+ return { text: veleroPhaseLabel(phase), color: healthColors.unknown, level: 'unknown' }
244
410
  }
245
411
  }
246
412
 
@@ -291,3 +457,29 @@ export function getVSLProvider(resource: any): string {
291
457
  export function getVSLConfig(resource: any): Record<string, string> {
292
458
  return resource.spec?.config || {}
293
459
  }
460
+
461
+ // ============================================================================
462
+ // BACKUP REPOSITORY UTILITIES
463
+ // ============================================================================
464
+
465
+ export function getBackupRepositoryStatus(resource: any): StatusBadge {
466
+ const phase = resource.status?.phase || ''
467
+
468
+ switch (phase) {
469
+ case 'Ready':
470
+ return { text: veleroPhaseLabel('Ready'), color: healthColors.healthy, level: 'healthy' }
471
+ case 'NotReady':
472
+ return { text: veleroPhaseLabel('NotReady'), color: healthColors.unhealthy, level: 'unhealthy' }
473
+ case 'New':
474
+ return { text: veleroPhaseLabel('New'), color: healthColors.unknown, level: 'unknown' }
475
+ default:
476
+ return { text: veleroPhaseLabel(phase), color: healthColors.unknown, level: 'unknown' }
477
+ }
478
+ }
479
+
480
+ // kopia | restic. Worth scanning for: restic is being retired — v1.17 stopped
481
+ // creating new restic backups and v1.19 drops restic restore — so a restic
482
+ // repository is a migration liability, not just a configuration detail.
483
+ export function getBackupRepositoryType(resource: any): string {
484
+ return resource.spec?.repositoryType || '-'
485
+ }