@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
@@ -3,6 +3,33 @@ export * from './resource-utils-hpa'
3
3
  export * from './resource-utils-argo'
4
4
  export * from './resource-utils-certmanager'
5
5
  export * from './resource-utils-cnpg'
6
+ export {
7
+ CALICO_GROUPS,
8
+ type CalicoApiGroup,
9
+ isCalicoApiGroup,
10
+ isCalicoApiVersion,
11
+ isCalicoPolicyKind,
12
+ isCalicoNetworkPolicyKind,
13
+ isCalicoStagedKubernetesNetworkPolicyKind,
14
+ formatKubernetesLabelSelector,
15
+ isCalicoPolicyResource,
16
+ isCoreNetworkPolicyApiVersion,
17
+ isCoreNetworkPolicyResource,
18
+ isCoreNetworkPolicyKind,
19
+ getCalicoPolicyKindLabel,
20
+ getCalicoPolicyTypes,
21
+ getCalicoPolicyRuleCount,
22
+ getCalicoPolicySelector,
23
+ getCalicoIPPoolAllowedUses,
24
+ getCalicoIPPoolBlockSize,
25
+ getCalicoIPPoolEncapsulation,
26
+ getCalicoStagedAction,
27
+ isCalicoStagedDeletion,
28
+ isCalicoStagedIgnored,
29
+ CALICO_SELECTOR_NOT_APPLICABLE,
30
+ getCalicoPolicyNamespaceSelector,
31
+ getCalicoPolicyServiceAccountSelector,
32
+ } from './resource-utils-calico'
6
33
  export * from './resource-utils-crossplane'
7
34
  export * from './resource-utils-eso'
8
35
  export * from './resource-utils-flux'
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getKyvernoPolicyAction } from './resource-utils-kyverno'
3
+
4
+ function cpol(spec: any) {
5
+ return { apiVersion: 'kyverno.io/v1', kind: 'ClusterPolicy', metadata: { name: 'p' }, spec }
6
+ }
7
+
8
+ /**
9
+ * The Enforce cases here were confirmed against a live Kyverno 1.18 admission
10
+ * controller: each one creates a pod the apiserver actually rejects, while
11
+ * `spec.validationFailureAction` on the same policy reads Audit.
12
+ */
13
+ describe('getKyvernoPolicyAction', () => {
14
+ it('lets a rule-level failureAction override the spec-level one', () => {
15
+ // Live result: pod REJECTED by validate.kyverno.svc-fail.
16
+ expect(
17
+ getKyvernoPolicyAction(
18
+ cpol({
19
+ validationFailureAction: 'Audit',
20
+ rules: [{ name: 'r', validate: { failureAction: 'Enforce' } }],
21
+ }),
22
+ ),
23
+ ).toBe('Enforce')
24
+ })
25
+
26
+ it('reads the failureAction on an image-verification rule', () => {
27
+ // Live result: pod REJECTED by mutate.kyverno.svc-fail. Kyverno had
28
+ // defaulted spec.validationFailureAction to Audit on its own.
29
+ expect(
30
+ getKyvernoPolicyAction(
31
+ cpol({
32
+ validationFailureAction: 'Audit',
33
+ rules: [{ name: 'r', verifyImages: [{ failureAction: 'Enforce' }] }],
34
+ }),
35
+ ),
36
+ ).toBe('Enforce')
37
+ })
38
+
39
+ it('does not claim Enforce when every rule overrides it away', () => {
40
+ expect(
41
+ getKyvernoPolicyAction(
42
+ cpol({
43
+ validationFailureAction: 'Enforce',
44
+ rules: [{ name: 'r', validate: { failureAction: 'Audit' } }],
45
+ }),
46
+ ),
47
+ ).toBe('Audit')
48
+ })
49
+
50
+ it('applies the spec-level action to rules that declare none', () => {
51
+ expect(
52
+ getKyvernoPolicyAction(
53
+ cpol({
54
+ validationFailureAction: 'Enforce',
55
+ rules: [{ name: 'r', validate: { pattern: {} } }],
56
+ }),
57
+ ),
58
+ ).toBe('Enforce')
59
+ })
60
+
61
+ // Only validate and verifyImages rules can reject, so only they inherit the
62
+ // spec-level action. A mutate rule alongside them would otherwise report a
63
+ // policy as blocking when its one validating rule audits.
64
+ it('does not let a mutate rule carry the spec-level Enforce', () => {
65
+ expect(
66
+ getKyvernoPolicyAction(
67
+ cpol({
68
+ validationFailureAction: 'Enforce',
69
+ rules: [{ name: 'v', validate: { failureAction: 'Audit' } }, { name: 'm', mutate: {} }],
70
+ }),
71
+ ),
72
+ ).toBe('Audit')
73
+ })
74
+
75
+ it('reports Audit for a policy whose rules cannot reject at all', () => {
76
+ expect(
77
+ getKyvernoPolicyAction(
78
+ cpol({ validationFailureAction: 'Enforce', rules: [{ name: 'g', generate: {} }] }),
79
+ ),
80
+ ).toBe('Audit')
81
+ })
82
+
83
+ it('still inherits the spec action for a validating rule that declares none', () => {
84
+ expect(
85
+ getKyvernoPolicyAction(
86
+ cpol({
87
+ validationFailureAction: 'Enforce',
88
+ rules: [{ name: 'm', mutate: {} }, { name: 'v', validate: { pattern: {} } }],
89
+ }),
90
+ ),
91
+ ).toBe('Enforce')
92
+ })
93
+
94
+ it('falls back to Audit when nothing declares an action', () => {
95
+ expect(getKyvernoPolicyAction(cpol({ rules: [{ name: 'r', validate: {} }] }))).toBe('Audit')
96
+ expect(getKyvernoPolicyAction(cpol({}))).toBe('Audit')
97
+ })
98
+ })
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { AnalysisRunRenderer } from './AnalysisRunRenderer'
4
+ import { getAnalysisRunStatus, summarizeAnalysisMetrics } from '../resource-utils'
5
+
6
+ // SSR splits interpolated text with <!-- --> markers; strip so assertions read as prose.
7
+ function render(element: Parameters<typeof renderToString>[0]): string {
8
+ return renderToString(element).replace(/<!-- -->/g, '')
9
+ }
10
+
11
+ function analysisRun(overrides: {
12
+ phase?: string
13
+ message?: string
14
+ labels?: Record<string, string>
15
+ metricResults?: any[]
16
+ runSummary?: Record<string, number>
17
+ metrics?: any[]
18
+ }) {
19
+ return {
20
+ metadata: {
21
+ name: 'web-6c4f-2',
22
+ namespace: 'prod',
23
+ labels: overrides.labels,
24
+ ownerReferences: [{ kind: 'Rollout', name: 'web' }],
25
+ },
26
+ spec: { metrics: overrides.metrics ?? [] },
27
+ status: {
28
+ phase: overrides.phase,
29
+ message: overrides.message,
30
+ metricResults: overrides.metricResults,
31
+ runSummary: overrides.runSummary,
32
+ },
33
+ }
34
+ }
35
+
36
+ describe('AnalysisRunRenderer verdict banner', () => {
37
+ it('escalates Failed to an error banner naming the abort consequence', () => {
38
+ const html = render(
39
+ <AnalysisRunRenderer
40
+ data={analysisRun({
41
+ phase: 'Failed',
42
+ metricResults: [{ name: 'success-rate', phase: 'Failed', message: 'result 0 breached fail if result == 0' }],
43
+ })}
44
+ />,
45
+ )
46
+ expect(html).toContain('the rollout will be aborted')
47
+ expect(html).toContain('result 0 breached fail if result == 0')
48
+ })
49
+
50
+ it('distinguishes Error (could not run) from Failed (metric breached)', () => {
51
+ const html = render(<AnalysisRunRenderer data={analysisRun({ phase: 'Error' })} />)
52
+ expect(html).toContain('Analysis could not run')
53
+ expect(html).not.toContain('the rollout will be aborted')
54
+ })
55
+
56
+ it('frames Inconclusive as awaiting a human decision, not a failure', () => {
57
+ const html = render(
58
+ <AnalysisRunRenderer
59
+ data={analysisRun({
60
+ phase: 'Inconclusive',
61
+ metricResults: [{ name: 'success-rate', phase: 'Inconclusive', message: 'result 2 met neither condition' }],
62
+ })}
63
+ />,
64
+ )
65
+ expect(html).toContain('paused for a human decision')
66
+ expect(html).toContain('result 2 met neither condition')
67
+ })
68
+
69
+ it('falls back to a generic reason when no metric carried a message', () => {
70
+ const html = render(
71
+ <AnalysisRunRenderer data={analysisRun({ phase: 'Inconclusive', metricResults: [{ name: 'm', phase: 'Inconclusive' }] })} />,
72
+ )
73
+ expect(html).toContain('none met its success condition either')
74
+ })
75
+
76
+ it('renders no banner for a run still in flight', () => {
77
+ for (const phase of ['Pending', 'Running', 'Successful']) {
78
+ const html = render(<AnalysisRunRenderer data={analysisRun({ phase })} />)
79
+ expect(html).not.toContain('paused for a human decision')
80
+ expect(html).not.toContain('the rollout will be aborted')
81
+ expect(html).not.toContain('Analysis could not run')
82
+ }
83
+ })
84
+ })
85
+
86
+ describe('AnalysisRunRenderer detail', () => {
87
+ it('surfaces the trigger and canary step the controller labelled the run with', () => {
88
+ const html = render(
89
+ <AnalysisRunRenderer
90
+ data={analysisRun({ phase: 'Running', labels: { 'rollout-type': 'Step', 'step-index': '3' } })}
91
+ />,
92
+ )
93
+ expect(html).toContain('Triggered as')
94
+ expect(html).toContain('Step')
95
+ expect(html).toContain('Canary step')
96
+ })
97
+
98
+ it('pairs each metric result with its condition from the spec', () => {
99
+ const html = render(
100
+ <AnalysisRunRenderer
101
+ data={analysisRun({
102
+ phase: 'Inconclusive',
103
+ metrics: [
104
+ { name: 'success-rate', successCondition: 'result == 1', failureCondition: 'result == 0', failureLimit: 2 },
105
+ ],
106
+ metricResults: [
107
+ {
108
+ name: 'success-rate',
109
+ phase: 'Inconclusive',
110
+ count: 3,
111
+ inconclusive: 3,
112
+ measurements: [{ value: '2', phase: 'Inconclusive' }],
113
+ },
114
+ ],
115
+ })}
116
+ />,
117
+ )
118
+ expect(html).toContain('success if: result == 1')
119
+ expect(html).toContain('fail if: result == 0')
120
+ expect(html).toContain('failure limit 2')
121
+ expect(html).toContain('latest: 2')
122
+ expect(html).toContain('3 measured')
123
+ expect(html).toContain('3 inconclusive')
124
+ })
125
+
126
+ it('flags a dry-run metric so its phase is not read as load-bearing', () => {
127
+ const html = render(
128
+ <AnalysisRunRenderer
129
+ data={analysisRun({ phase: 'Successful', metricResults: [{ name: 'canary-error-rate', phase: 'Failed', dryRun: true }] })}
130
+ />,
131
+ )
132
+ expect(html).toContain('(dry-run)')
133
+ })
134
+
135
+ it('renders the tally and metric sections only when populated', () => {
136
+ const bare = render(<AnalysisRunRenderer data={analysisRun({ phase: 'Pending' })} />)
137
+ expect(bare).not.toContain('Measurement Tally')
138
+ expect(bare).not.toContain('Metrics (')
139
+
140
+ const full = render(
141
+ <AnalysisRunRenderer
142
+ data={analysisRun({ phase: 'Successful', runSummary: { count: 4, successful: 4 }, metricResults: [{ name: 'm', phase: 'Successful' }] })}
143
+ />,
144
+ )
145
+ expect(full).toContain('Measurement Tally')
146
+ expect(full).toContain('Metrics (1)')
147
+ })
148
+
149
+ it('renders without a status, spec, or owner', () => {
150
+ expect(() => render(<AnalysisRunRenderer data={{ metadata: { name: 'ar' } }} />)).not.toThrow()
151
+ })
152
+ })
153
+
154
+ describe('getAnalysisRunStatus', () => {
155
+ // Inconclusive is the alert tier: worse than Running, not as bad as Failed.
156
+ it('maps each phase to the right health tier', () => {
157
+ const cases: Array<[string | undefined, string]> = [
158
+ ['Successful', 'healthy'],
159
+ ['Running', 'degraded'],
160
+ ['Pending', 'degraded'],
161
+ ['Inconclusive', 'alert'],
162
+ ['Failed', 'unhealthy'],
163
+ ['Error', 'unhealthy'],
164
+ [undefined, 'unknown'],
165
+ ['Bogus', 'unknown'],
166
+ ]
167
+ for (const [phase, level] of cases) {
168
+ expect(getAnalysisRunStatus({ status: { phase } }).level).toBe(level)
169
+ }
170
+ })
171
+
172
+ it('labels an absent phase Unknown rather than blank', () => {
173
+ expect(getAnalysisRunStatus({}).text).toBe('Unknown')
174
+ })
175
+ })
176
+
177
+ describe('summarizeAnalysisMetrics', () => {
178
+ const run = (...phases: string[]) => ({
179
+ status: { metricResults: phases.map((phase, i) => ({ name: `m${i}`, phase })) },
180
+ })
181
+
182
+ it('does not count in-flight metrics as passing', () => {
183
+ expect(summarizeAnalysisMetrics(run('Successful', 'Running', 'Pending'))).toEqual({
184
+ total: 3,
185
+ passing: 1,
186
+ notPassing: 0,
187
+ dryRun: 0,
188
+ })
189
+ })
190
+
191
+ it('counts every failure phase as not passing', () => {
192
+ expect(summarizeAnalysisMetrics(run('Failed', 'Error', 'Inconclusive', 'Successful'))).toEqual({
193
+ total: 4,
194
+ passing: 1,
195
+ notPassing: 3,
196
+ dryRun: 0,
197
+ })
198
+ })
199
+
200
+ it('reports all passing only once every metric is Successful', () => {
201
+ const { total, passing } = summarizeAnalysisMetrics(run('Successful', 'Successful'))
202
+ expect(passing).toBe(total)
203
+ })
204
+
205
+ it('handles a run with no metric results', () => {
206
+ expect(summarizeAnalysisMetrics({})).toEqual({ total: 0, passing: 0, notPassing: 0, dryRun: 0 })
207
+ })
208
+
209
+ // Argo excludes dryRun metrics from worstStatus, so a failed dry-run must not
210
+ // paint the row amber while the run itself reports Successful.
211
+ it('excludes dry-run metrics from the verdict counts', () => {
212
+ const run = {
213
+ status: {
214
+ phase: 'Successful',
215
+ metricResults: [
216
+ { name: 'real', phase: 'Successful' },
217
+ { name: 'canary-only', phase: 'Failed', dryRun: true },
218
+ ],
219
+ },
220
+ }
221
+ expect(summarizeAnalysisMetrics(run)).toEqual({
222
+ total: 1,
223
+ passing: 1,
224
+ notPassing: 0,
225
+ dryRun: 1,
226
+ })
227
+ })
228
+
229
+ it('still counts a non-dry-run failure alongside a dry-run one', () => {
230
+ const run = {
231
+ status: {
232
+ metricResults: [
233
+ { name: 'real', phase: 'Failed' },
234
+ { name: 'shadow', phase: 'Failed', dryRun: true },
235
+ ],
236
+ },
237
+ }
238
+ const { total, notPassing, dryRun } = summarizeAnalysisMetrics(run)
239
+ expect({ total, notPassing, dryRun }).toEqual({ total: 1, notPassing: 1, dryRun: 1 })
240
+ })
241
+ })
@@ -0,0 +1,166 @@
1
+ import { Activity, BarChart3, Server } from 'lucide-react'
2
+ import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
3
+ import { Badge, type BadgeSeverity } from '../../ui/Badge'
4
+ import { formatAge } from '../resource-utils'
5
+
6
+ interface AnalysisRunRendererProps {
7
+ data: any
8
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
9
+ }
10
+
11
+ const PHASE_SEVERITY: Record<string, BadgeSeverity> = {
12
+ Successful: 'success',
13
+ Running: 'info',
14
+ Pending: 'neutral',
15
+ Inconclusive: 'warning',
16
+ Failed: 'error',
17
+ Error: 'error',
18
+ }
19
+
20
+ function phaseSeverity(phase: string): BadgeSeverity {
21
+ return PHASE_SEVERITY[phase] ?? 'neutral'
22
+ }
23
+
24
+ // The Rollout's own pause reason is only "InconclusiveAnalysisRun" — the metric
25
+ // that actually decided it lives here, in metricResults[].
26
+ export function AnalysisRunRenderer({ data, onNavigate }: AnalysisRunRendererProps) {
27
+ const spec = data.spec || {}
28
+ const status = data.status || {}
29
+ const phase = status.phase || 'Unknown'
30
+ const summary = status.runSummary || {}
31
+ const metricResults: any[] = status.metricResults || []
32
+ const labels = data.metadata?.labels || {}
33
+ const ownerRollout = (data.metadata?.ownerReferences || []).find((ref: any) => ref.kind === 'Rollout')
34
+
35
+ const failing = metricResults.filter((m) => m.phase === 'Failed' || m.phase === 'Error')
36
+ const inconclusive = metricResults.filter((m) => m.phase === 'Inconclusive')
37
+
38
+ const verdictMessage = status.message || failing[0]?.message || inconclusive[0]?.message
39
+
40
+ return (
41
+ <>
42
+ {(phase === 'Failed' || phase === 'Error') && (
43
+ <AlertBanner
44
+ variant="error"
45
+ title={phase === 'Error' ? 'Analysis could not run' : 'Analysis failed — the rollout will be aborted'}
46
+ message={verdictMessage || 'One or more metrics breached their failure condition.'}
47
+ />
48
+ )}
49
+
50
+ {phase === 'Inconclusive' && (
51
+ <AlertBanner
52
+ variant="warning"
53
+ title="Analysis was inconclusive — the rollout is paused for a human decision"
54
+ message={verdictMessage || 'No metric failed outright, but none met its success condition either.'}
55
+ />
56
+ )}
57
+
58
+ <Section title="Status" icon={Activity}>
59
+ <PropertyList>
60
+ <Property label="Phase" value={<Badge severity={phaseSeverity(phase)}>{phase}</Badge>} />
61
+ {labels['rollout-type'] && <Property label="Triggered as" value={labels['rollout-type']} />}
62
+ {labels['step-index'] && <Property label="Canary step" value={labels['step-index']} />}
63
+ <Property label="Started" value={status.startedAt ? formatAge(status.startedAt) : undefined} />
64
+ <Property label="Completed" value={status.completedAt ? formatAge(status.completedAt) : undefined} />
65
+ <Property label="Message" value={status.message} />
66
+ {ownerRollout && (
67
+ <Property
68
+ label="Rollout"
69
+ value={
70
+ onNavigate ? (
71
+ <button
72
+ onClick={() =>
73
+ onNavigate({ kind: 'Rollout', namespace: data.metadata?.namespace, name: ownerRollout.name })
74
+ }
75
+ className="text-brand hover:underline"
76
+ >
77
+ {ownerRollout.name}
78
+ </button>
79
+ ) : (
80
+ ownerRollout.name
81
+ )
82
+ }
83
+ />
84
+ )}
85
+ </PropertyList>
86
+ </Section>
87
+
88
+ {Object.keys(summary).length > 0 && (
89
+ <Section title="Measurement Tally" icon={BarChart3}>
90
+ <PropertyList>
91
+ <Property label="Measurements" value={summary.count} />
92
+ <Property label="Successful" value={summary.successful} />
93
+ <Property label="Failed" value={summary.failed} />
94
+ <Property label="Inconclusive" value={summary.inconclusive} />
95
+ <Property label="Errored" value={summary.error} />
96
+ </PropertyList>
97
+ </Section>
98
+ )}
99
+
100
+ {metricResults.length > 0 && (
101
+ <Section title={`Metrics (${metricResults.length})`} defaultExpanded>
102
+ <div className="space-y-2">
103
+ {metricResults.map((metric: any) => {
104
+ const definition = (spec.metrics || []).find((m: any) => m.name === metric.name)
105
+ const measurements: any[] = metric.measurements || []
106
+ const latest = measurements[measurements.length - 1]
107
+ return (
108
+ <div key={metric.name} className="card-inner space-y-1.5">
109
+ <div className="flex items-center justify-between gap-2">
110
+ <span className="font-mono text-sm text-theme-text-primary">{metric.name}</span>
111
+ <Badge severity={phaseSeverity(metric.phase)} size="sm">
112
+ {metric.phase || 'Unknown'}
113
+ {metric.dryRun ? ' (dry-run)' : ''}
114
+ </Badge>
115
+ </div>
116
+ {latest?.value && (
117
+ <div className="font-mono text-xs text-theme-text-secondary">latest: {latest.value}</div>
118
+ )}
119
+ {definition?.successCondition && (
120
+ <div className="font-mono text-xs text-theme-text-tertiary">
121
+ success if: {definition.successCondition}
122
+ </div>
123
+ )}
124
+ {definition?.failureCondition && (
125
+ <div className="font-mono text-xs text-theme-text-tertiary">
126
+ fail if: {definition.failureCondition}
127
+ </div>
128
+ )}
129
+ <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-theme-text-secondary">
130
+ {metric.count != null && <span>{metric.count} measured</span>}
131
+ {metric.successful ? <span>{metric.successful} ok</span> : null}
132
+ {metric.failed ? <span>{metric.failed} failed</span> : null}
133
+ {metric.inconclusive ? <span>{metric.inconclusive} inconclusive</span> : null}
134
+ {metric.error ? <span>{metric.error} errored</span> : null}
135
+ {metric.consecutiveError ? <span>{metric.consecutiveError} consecutive errors</span> : null}
136
+ {definition?.failureLimit != null && <span>failure limit {definition.failureLimit}</span>}
137
+ </div>
138
+ {metric.message && (
139
+ <div className="text-xs text-theme-text-secondary">{metric.message}</div>
140
+ )}
141
+ {latest?.message && latest.message !== metric.message && (
142
+ <div className="text-xs text-theme-text-secondary">{latest.message}</div>
143
+ )}
144
+ </div>
145
+ )
146
+ })}
147
+ </div>
148
+ </Section>
149
+ )}
150
+
151
+ {(spec.args || []).length > 0 && (
152
+ <Section title={`Arguments (${spec.args.length})`} icon={Server} defaultExpanded={false}>
153
+ <PropertyList>
154
+ {spec.args.map((arg: any) => (
155
+ <Property
156
+ key={arg.name}
157
+ label={arg.name}
158
+ value={arg.value ?? (arg.valueFrom ? 'from reference' : undefined)}
159
+ />
160
+ ))}
161
+ </PropertyList>
162
+ </Section>
163
+ )}
164
+ </>
165
+ )
166
+ }
@@ -4,6 +4,7 @@ import {
4
4
  getCNPGBackupStatus,
5
5
  getCNPGBackupCluster,
6
6
  getCNPGBackupMethod,
7
+ getCNPGBackupPlugin,
7
8
  getCNPGBackupPhase,
8
9
  getCNPGBackupDuration,
9
10
  getCNPGBackupName,
@@ -11,6 +12,7 @@ import {
11
12
  getCNPGBackupServerName,
12
13
  getCNPGBackupError,
13
14
  getCNPGBackupTarget,
15
+ CNPG_BARMAN_OBJECTSTORE_GROUP,
14
16
  } from '../resource-utils-cnpg'
15
17
 
16
18
  interface CNPGBackupRendererProps {
@@ -24,6 +26,7 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
24
26
  const phase = getCNPGBackupPhase(data)
25
27
  const target = getCNPGBackupTarget(data)
26
28
  const clusterName = getCNPGBackupCluster(data)
29
+ const backupPlugin = getCNPGBackupPlugin(data)
27
30
 
28
31
  // WAL range fields
29
32
  const beginWal = data.status?.beginWal
@@ -48,6 +51,24 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
48
51
  <PropertyList>
49
52
  <Property label="Phase" value={phase} />
50
53
  <Property label="Method" value={getCNPGBackupMethod(data)} />
54
+ {/* A plugin-taken backup lands in an ObjectStore rather than in the
55
+ Cluster's own barmanObjectStore, so naming the plugin is what
56
+ tells an operator where to go looking. */}
57
+ {backupPlugin && <Property label="Plugin" value={backupPlugin.name} />}
58
+ {backupPlugin?.parameters?.barmanObjectName && (
59
+ <Property
60
+ label="Object Store"
61
+ value={
62
+ <ResourceLink
63
+ name={backupPlugin.parameters.barmanObjectName}
64
+ kind="objectstores"
65
+ group={CNPG_BARMAN_OBJECTSTORE_GROUP}
66
+ namespace={data.metadata?.namespace || ''}
67
+ onNavigate={onNavigate}
68
+ />
69
+ }
70
+ />
71
+ )}
51
72
  <Property label="Duration" value={getCNPGBackupDuration(data)} />
52
73
  <Property label="Backup Name" value={getCNPGBackupName(data)} />
53
74
  {data.status?.instanceID?.podName && (
@@ -81,8 +102,16 @@ export function CNPGBackupRenderer({ data, onNavigate }: CNPGBackupRendererProps
81
102
  }
82
103
  return clusterName
83
104
  })()} />
84
- <Property label="Destination" value={getCNPGBackupDestinationPath(data)} />
85
- <Property label="Server Name" value={getCNPGBackupServerName(data)} />
105
+ {/* Destination and server name are in-tree barmanObjectStore fields.
106
+ Under the plugin method they are never populated because both live
107
+ on the ObjectStore, and rendering them as "-" reads as "not
108
+ configured" rather than "recorded elsewhere". */}
109
+ {!backupPlugin && (
110
+ <>
111
+ <Property label="Destination" value={getCNPGBackupDestinationPath(data)} />
112
+ <Property label="Server Name" value={getCNPGBackupServerName(data)} />
113
+ </>
114
+ )}
86
115
  </PropertyList>
87
116
  </Section>
88
117
 
@@ -83,3 +83,36 @@ describe('the WAL banner must not claim nothing else looks wrong when something
83
83
  expect(html(walFailing(phase, 2))).not.toContain(CLAIM)
84
84
  })
85
85
  })
86
+
87
+ describe('recovery points must be readable as ages, not raw machine timestamps', () => {
88
+ const withBackup = (lastSuccessful: string) => ({
89
+ apiVersion: 'postgresql.cnpg.io/v1',
90
+ kind: 'Cluster',
91
+ metadata: { name: 'pg', namespace: 'db' },
92
+ spec: { instances: 2, backup: { barmanObjectStore: { destinationPath: 's3://bucket' } } },
93
+ status: {
94
+ phase: 'Cluster in healthy state',
95
+ readyInstances: 2,
96
+ lastSuccessfulBackup: lastSuccessful,
97
+ firstRecoverabilityPoint: lastSuccessful,
98
+ },
99
+ })
100
+
101
+ it('states how old the last successful backup is', () => {
102
+ const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString()
103
+ const html = renderToString(<CNPGClusterRenderer data={withBackup(old)} />)
104
+ // The age is the whole point: a raw ISO string requires the reader to do
105
+ // date arithmetic to notice their newest backup is 200 days old.
106
+ expect(html).toContain('200d ago')
107
+ // And the ISO form with its milliseconds must not survive as the display value.
108
+ expect(html).not.toContain(old)
109
+ })
110
+
111
+ it('keeps the absolute timestamp beside the age', () => {
112
+ // Age alone is the wrong unit here: "1d ago" next to "1d ago" is the same
113
+ // string for events a week apart in a failure history.
114
+ const at = '2026-01-30T09:40:37.000Z'
115
+ const html = renderToString(<CNPGClusterRenderer data={withBackup(at)} />)
116
+ expect(html).toContain('2026-01-30 09:40:37 UTC')
117
+ })
118
+ })