@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.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +259 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
- package/src/components/resources/resource-utils-cnpg.ts +373 -56
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +11 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +517 -0
- package/src/components/trace/ReachabilityView.tsx +1173 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1423 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +892 -0
- package/src/components/trace/reachInspector.ts +797 -0
- package/src/components/trace/reachMarks.test.ts +698 -0
- package/src/components/trace/reachMarks.ts +623 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +449 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +414 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +64 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
- package/src/utils/resource-icons.test.ts +33 -0
- 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
|
-
|
|
18
|
-
case '
|
|
19
|
-
|
|
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
|
-
|
|
113
|
+
case 'FinalizingPartiallyFailed':
|
|
114
|
+
case 'WaitingForPluginOperationsPartiallyFailed':
|
|
115
|
+
return { text: veleroPhaseLabel(phase), color: healthColors.alert, level: 'alert' }
|
|
22
116
|
case 'Failed':
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
228
|
+
case 'WaitingForPluginOperations':
|
|
229
|
+
case 'Finalizing':
|
|
230
|
+
return { text: veleroPhaseLabel(phase), color: healthColors.neutral, level: 'neutral' }
|
|
126
231
|
case 'PartiallyFailed':
|
|
127
|
-
|
|
232
|
+
case 'FinalizingPartiallyFailed':
|
|
233
|
+
case 'WaitingForPluginOperationsPartiallyFailed':
|
|
234
|
+
return { text: veleroPhaseLabel(phase), color: healthColors.alert, level: 'alert' }
|
|
128
235
|
case 'Failed':
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|