@skyhook-io/k8s-ui 1.10.5 → 1.12.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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
  3. package/src/components/gitops/GitOpsTableView.tsx +22 -2
  4. package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
  5. package/src/components/gitops/detail-helpers.ts +4 -1
  6. package/src/components/issues/IssuesView.tsx +9 -0
  7. package/src/components/issues/diagnostic.ts +3 -0
  8. package/src/components/issues/issues.test.ts +11 -0
  9. package/src/components/issues/severity.ts +3 -0
  10. package/src/components/issues/types.ts +3 -0
  11. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  12. package/src/components/resources/ResourcesView.tsx +261 -30
  13. package/src/components/resources/index.ts +2 -0
  14. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  15. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  16. package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
  17. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  18. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +133 -9
  19. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +19 -4
  20. package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
  21. package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
  22. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  23. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  24. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
  25. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
  26. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
  27. package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
  28. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  29. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  30. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  31. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
  32. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  33. package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
  34. package/src/components/resources/renderers/PolicySection.tsx +170 -0
  35. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  36. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  37. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  38. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  39. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
  40. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  41. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  42. package/src/components/resources/renderers/index.ts +5 -0
  43. package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
  44. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  45. package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
  46. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  47. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  48. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  49. package/src/components/resources/resource-utils-cnpg.test.ts +590 -1
  50. package/src/components/resources/resource-utils-cnpg.ts +433 -56
  51. package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
  52. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  53. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  54. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  55. package/src/components/resources/resource-utils-velero.ts +214 -22
  56. package/src/components/resources/resource-utils.ts +70 -3
  57. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  58. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  59. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  60. package/src/components/topology/K8sResourceNode.tsx +6 -0
  61. package/src/components/trace/ReachabilityGraph.tsx +20 -6
  62. package/src/components/trace/ReachabilityView.tsx +253 -52
  63. package/src/components/trace/problemRows.test.ts +107 -0
  64. package/src/components/trace/reachGraphModel.test.ts +16 -0
  65. package/src/components/trace/reachInspector.test.ts +233 -10
  66. package/src/components/trace/reachInspector.ts +166 -36
  67. package/src/components/trace/reachMarks.test.ts +32 -1
  68. package/src/components/trace/reachMarks.ts +44 -0
  69. package/src/components/trace/reachOrigins.ts +12 -0
  70. package/src/components/trace/types.ts +25 -0
  71. package/src/components/ui/Badge.tsx +36 -0
  72. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  73. package/src/theme/components.css +26 -0
  74. package/src/types/gitops-flux-status.test.ts +163 -0
  75. package/src/types/gitops.ts +80 -24
  76. package/src/types/index.ts +1 -0
  77. package/src/types/policy.ts +42 -0
  78. package/src/utils/api-resources.ts +2 -0
  79. package/src/utils/resource-icons.test.ts +33 -0
  80. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ getKyvernoEnforcementPosture,
4
+ getKyvernoPolicyFamily,
5
+ getKyvernoValidationActions,
6
+ getKyvernoMatchSummary,
7
+ getModernKyvernoPolicyStatus,
8
+ isKyvernoAdmissionEnabled,
9
+ isModernKyvernoPolicy,
10
+ } from './resource-utils-kyverno-modern'
11
+
12
+ function vpol(spec: any = {}, extra: any = {}) {
13
+ return {
14
+ apiVersion: 'policies.kyverno.io/v1',
15
+ kind: 'ValidatingPolicy',
16
+ metadata: { name: 'p' },
17
+ spec,
18
+ ...extra,
19
+ }
20
+ }
21
+
22
+ describe('validationActions defaulting', () => {
23
+ // Verified against Kyverno 1.18.2: a ValidatingPolicy with no
24
+ // validationActions rejected a violating request. The legacy family
25
+ // defaults the analogous field to Audit, so this is the assumption most
26
+ // likely to be carried over wrongly — and it would under-report the
27
+ // strictest possible state.
28
+ it('treats an absent validationActions as Deny', () => {
29
+ expect(getKyvernoValidationActions(vpol({}))).toEqual(['Deny'])
30
+ expect(getKyvernoEnforcementPosture(vpol({}), 'validating').blocks).toBe(true)
31
+ })
32
+
33
+ it('treats an empty validationActions array as Deny', () => {
34
+ expect(getKyvernoValidationActions(vpol({ validationActions: [] }))).toEqual(['Deny'])
35
+ })
36
+
37
+ it('reads explicit actions verbatim', () => {
38
+ expect(getKyvernoValidationActions(vpol({ validationActions: ['Audit', 'Warn'] }))).toEqual(['Audit', 'Warn'])
39
+ })
40
+ })
41
+
42
+ describe('effective enforcement posture', () => {
43
+ it('reports Deny as blocking when admission evaluation is on', () => {
44
+ const posture = getKyvernoEnforcementPosture(
45
+ vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: true } } }),
46
+ 'validating'
47
+ )
48
+ expect(posture.blocks).toBe(true)
49
+ expect(posture.label).toBe('Deny')
50
+ expect(posture.level).toBe('unhealthy')
51
+ })
52
+
53
+ // The case the whole posture computation exists for. Verified on Kyverno
54
+ // 1.18.2: this exact spec did NOT block a violating pod, while the same
55
+ // policy with admission enabled did. Rendering it as a red "Deny" would
56
+ // tell an operator the cluster is protected when it is not.
57
+ it('does not report blocking when admission evaluation is disabled, even for Deny', () => {
58
+ const posture = getKyvernoEnforcementPosture(
59
+ vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: false } } }),
60
+ 'validating'
61
+ )
62
+ expect(posture.blocks).toBe(false)
63
+ expect(posture.label).toBe('Background only')
64
+ expect(posture.note).toMatch(/never blocked/i)
65
+ })
66
+
67
+ // Tone marks the discrepancy between declared and effective, not the
68
+ // severity of the declared action. `alert` rather than `degraded` because
69
+ // `degraded` is already the deliberate Warn posture — "I meant to audit" and
70
+ // "I meant to block and don't" must not share a tone.
71
+ it('flags a Deny-declaring background-only policy as a discrepancy', () => {
72
+ const p = getKyvernoEnforcementPosture(
73
+ vpol({ validationActions: ['Deny'], evaluation: { admission: { enabled: false } } }),
74
+ 'validating'
75
+ )
76
+ expect(p.label).toBe('Background only')
77
+ expect(p.level).toBe('alert')
78
+ })
79
+
80
+ // No Deny declared means no protection gap — reporting-only either way.
81
+ it('leaves a non-blocking background-only policy calm', () => {
82
+ const p = getKyvernoEnforcementPosture(
83
+ vpol({ validationActions: ['Audit'], evaluation: { admission: { enabled: false } } }),
84
+ 'validating'
85
+ )
86
+ expect(p.label).toBe('Background only')
87
+ expect(p.level).toBe('neutral')
88
+ })
89
+
90
+ // Deliberate postures keep their own tones and must stay distinct from the
91
+ // discrepancy tier above.
92
+ it('keeps deliberate Audit and Audit + Warn visually distinct from a discrepancy', () => {
93
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'] }), 'validating').level).toBe('neutral')
94
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit', 'Warn'] }), 'validating').level).toBe('degraded')
95
+ })
96
+
97
+ it('flags JSON mode as a discrepancy only when the policy declares Deny', () => {
98
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny'], evaluation: { mode: 'JSON' } }), 'validating').level).toBe('alert')
99
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'], evaluation: { mode: 'JSON' } }), 'validating').level).toBe('neutral')
100
+ })
101
+
102
+ // A policy that evaluates nothing at all declared *something* and delivers
103
+ // none of it, whatever that something was.
104
+ it('flags a fully inactive policy as a discrepancy regardless of declared action', () => {
105
+ const ev = { admission: { enabled: false }, background: { enabled: false } }
106
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny'], evaluation: ev }), 'validating').level).toBe('alert')
107
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'], evaluation: ev }), 'validating').level).toBe('alert')
108
+ })
109
+
110
+ it('flags an inactive mutating policy as a discrepancy', () => {
111
+ const p = getKyvernoEnforcementPosture(vpol({ evaluation: { admission: { enabled: false } } }), 'mutating')
112
+ expect(p.label).toBe('Inactive')
113
+ expect(p.level).toBe('alert')
114
+ })
115
+
116
+ it('says the policy does nothing when both admission and background are off', () => {
117
+ const posture = getKyvernoEnforcementPosture(
118
+ vpol({
119
+ validationActions: ['Deny'],
120
+ evaluation: { admission: { enabled: false }, background: { enabled: false } },
121
+ }),
122
+ 'validating'
123
+ )
124
+ expect(posture.blocks).toBe(false)
125
+ expect(posture.label).toBe('Inactive')
126
+ expect(posture.note).toMatch(/does nothing/i)
127
+ })
128
+
129
+ // JSON mode evaluates against payloads outside the cluster, so it never
130
+ // participates in admission regardless of declared actions.
131
+ it('does not report blocking in JSON evaluation mode', () => {
132
+ const posture = getKyvernoEnforcementPosture(
133
+ vpol({ validationActions: ['Deny'], evaluation: { mode: 'JSON' } }),
134
+ 'validating'
135
+ )
136
+ expect(posture.blocks).toBe(false)
137
+ expect(posture.label).toBe('JSON mode')
138
+ })
139
+
140
+ it('treats Audit and Warn as non-blocking', () => {
141
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Audit'] }), 'validating').blocks).toBe(false)
142
+ expect(getKyvernoEnforcementPosture(vpol({ validationActions: ['Warn'] }), 'validating').blocks).toBe(false)
143
+ })
144
+
145
+ it('reports Deny as blocking when combined with other actions', () => {
146
+ const posture = getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny', 'Audit'] }), 'validating')
147
+ expect(posture.blocks).toBe(true)
148
+ expect(posture.label).toBe('Deny + Audit')
149
+ })
150
+
151
+ // failurePolicy governs what happens when the ENGINE errors, not when the
152
+ // policy fails, so it must not move the blocking verdict either way.
153
+ it('ignores failurePolicy when deciding whether the policy blocks', () => {
154
+ const ignore = getKyvernoEnforcementPosture(
155
+ vpol({ validationActions: ['Deny'], failurePolicy: 'Ignore' }),
156
+ 'validating'
157
+ )
158
+ const fail = getKyvernoEnforcementPosture(vpol({ validationActions: ['Deny'], failurePolicy: 'Fail' }), 'validating')
159
+ expect(ignore.blocks).toBe(true)
160
+ expect(fail.blocks).toBe(true)
161
+ })
162
+ })
163
+
164
+ describe('non-validating families', () => {
165
+ it('describes mutating policies as mutating, never blocking', () => {
166
+ const posture = getKyvernoEnforcementPosture(vpol({ mutations: [] }), 'mutating')
167
+ expect(posture.blocks).toBe(false)
168
+ expect(posture.label).toBe('Mutates on admission')
169
+ })
170
+
171
+ it('marks a mutating policy inactive when admission is disabled and it mutates nothing existing', () => {
172
+ const posture = getKyvernoEnforcementPosture(
173
+ vpol({ evaluation: { admission: { enabled: false } } }),
174
+ 'mutating'
175
+ )
176
+ expect(posture.label).toBe('Inactive')
177
+ })
178
+
179
+ // Codex #7: mutateExisting rewrites resources that already exist,
180
+ // independently of the admission path. Calling such a policy "Inactive"
181
+ // tells an operator nothing is changing while it actively rewrites their
182
+ // cluster. Verified against the upstream CRD: MutatingPolicy's
183
+ // spec.evaluation.mutateExisting.enabled defaults to false, the opposite of
184
+ // admission/background.
185
+ it('does not call a mutating policy inactive when mutateExisting is on', () => {
186
+ const posture = getKyvernoEnforcementPosture(
187
+ vpol({ evaluation: { admission: { enabled: false }, mutateExisting: { enabled: true } } }),
188
+ 'mutating'
189
+ )
190
+ expect(posture.label).toBe('Mutates existing')
191
+ expect(posture.label).not.toBe('Inactive')
192
+ expect(posture.summary).toMatch(/rewritten/i)
193
+ expect(posture.note).toMatch(/mutateExisting is on/i)
194
+ })
195
+
196
+ it('mentions existing-resource rewriting even when admission is enabled', () => {
197
+ const posture = getKyvernoEnforcementPosture(
198
+ vpol({ evaluation: { admission: { enabled: true }, mutateExisting: { enabled: true } } }),
199
+ 'mutating'
200
+ )
201
+ expect(posture.label).toBe('Mutates on admission')
202
+ expect(posture.summary).toMatch(/existing/i)
203
+ })
204
+
205
+ // A deleting policy has no "violations" — the generic non-blocking sentence
206
+ // must not leak into a family it does not describe.
207
+ it('describes deletion in its own terms, not violation terms', () => {
208
+ const posture = getKyvernoEnforcementPosture(vpol({ schedule: '0 2 * * *' }), 'deleting')
209
+ expect(posture.summary).toMatch(/deleted/i)
210
+ expect(posture.summary).not.toMatch(/violation/i)
211
+ })
212
+
213
+ it('describes generation in its own terms', () => {
214
+ const posture = getKyvernoEnforcementPosture(vpol({ generate: [] }), 'generating')
215
+ expect(posture.summary).not.toMatch(/violation/i)
216
+ })
217
+
218
+ it('surfaces the cron in a deleting policy posture', () => {
219
+ const posture = getKyvernoEnforcementPosture(vpol({ schedule: '0 */6 * * *' }), 'deleting')
220
+ expect(posture.blocks).toBe(false)
221
+ expect(posture.label).toContain('0 */6 * * *')
222
+ })
223
+
224
+ it('flags a deleting policy with no schedule as never running', () => {
225
+ const posture = getKyvernoEnforcementPosture(vpol({}), 'deleting')
226
+ expect(posture.note).toMatch(/will not run/i)
227
+ })
228
+ })
229
+
230
+ describe('status badge', () => {
231
+ // A policy that failed to compile enforces nothing whatever its spec says,
232
+ // so readiness has to outrank the declared posture.
233
+ it('reports Not Ready ahead of the enforcement posture', () => {
234
+ const badge = getModernKyvernoPolicyStatus(
235
+ vpol({ validationActions: ['Deny'] }, { status: { conditionStatus: { ready: false } } })
236
+ )
237
+ expect(badge.text).toBe('Not Ready')
238
+ expect(badge.level).toBe('unhealthy')
239
+ })
240
+
241
+ it('falls back to the posture when ready', () => {
242
+ const badge = getModernKyvernoPolicyStatus(
243
+ vpol({ validationActions: ['Audit'] }, { status: { conditionStatus: { ready: true } } })
244
+ )
245
+ expect(badge.text).toBe('Audit')
246
+ })
247
+ })
248
+
249
+ describe('family + group detection', () => {
250
+ it('collapses namespaced twins onto their cluster-scoped family', () => {
251
+ expect(getKyvernoPolicyFamily('NamespacedValidatingPolicy')).toBe('validating')
252
+ expect(getKyvernoPolicyFamily('ValidatingPolicy')).toBe('validating')
253
+ expect(getKyvernoPolicyFamily('NamespacedImageValidatingPolicy')).toBe('imageValidating')
254
+ expect(getKyvernoPolicyFamily('DeletingPolicy')).toBe('deleting')
255
+ expect(getKyvernoPolicyFamily('SomethingElse')).toBeUndefined()
256
+ })
257
+
258
+ // The guard that stops a foreign CRD sharing a generic plural like
259
+ // `validatingpolicies` from being described in Kyverno's vocabulary.
260
+ it('only matches the policies.kyverno.io group', () => {
261
+ expect(isModernKyvernoPolicy({ apiVersion: 'policies.kyverno.io/v1' })).toBe(true)
262
+ expect(isModernKyvernoPolicy({ apiVersion: 'kyverno.io/v1' })).toBe(false)
263
+ expect(isModernKyvernoPolicy({ apiVersion: 'example.com/v1' })).toBe(false)
264
+ expect(isModernKyvernoPolicy({})).toBe(false)
265
+ })
266
+
267
+ it('defaults admission evaluation to enabled when unset', () => {
268
+ expect(isKyvernoAdmissionEnabled(vpol({}))).toBe(true)
269
+ expect(isKyvernoAdmissionEnabled(vpol({ evaluation: {} }))).toBe(true)
270
+ expect(isKyvernoAdmissionEnabled(vpol({ evaluation: { admission: { enabled: false } } }))).toBe(false)
271
+ })
272
+ })
273
+
274
+ describe('match summary', () => {
275
+ it('summarizes matched resources and truncates long lists', () => {
276
+ const spec = {
277
+ matchConstraints: {
278
+ resourceRules: [
279
+ { apiGroups: [''], apiVersions: ['v1'], resources: ['pods', 'configmaps'], operations: ['CREATE'] },
280
+ { apiGroups: ['apps'], apiVersions: ['v1'], resources: ['deployments', 'statefulsets'], operations: ['CREATE'] },
281
+ ],
282
+ },
283
+ }
284
+ expect(getKyvernoMatchSummary(vpol(spec))).toBe('pods, configmaps, deployments +1')
285
+ })
286
+
287
+ it('returns a dash when nothing is matched', () => {
288
+ expect(getKyvernoMatchSummary(vpol({}))).toBe('-')
289
+ })
290
+ })
@@ -14,7 +14,10 @@ export function AlertRenderer({ data }: AlertRendererProps) {
14
14
  const conditions = (status.conditions || []) as FluxCondition[]
15
15
 
16
16
  // Convert to unified GitOps status
17
- const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true)
17
+ const gitOpsStatus = fluxConditionsToGitOpsStatus(conditions, spec.suspend === true, {
18
+ generation: data.metadata?.generation,
19
+ observedGeneration: status.observedGeneration,
20
+ })
18
21
 
19
22
  // Problem detection
20
23
  const problems: Array<{ color: 'red' | 'yellow'; message: string }> = []
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { CNPGClusterRenderer } from './CNPGClusterRenderer'
4
+ import { getCNPGClusterStatus } from '../resource-utils-cnpg'
5
+
6
+ const cluster = (phase: string, readyInstances: number, extra: any = {}) => ({
7
+ apiVersion: 'postgresql.cnpg.io/v1',
8
+ kind: 'Cluster',
9
+ metadata: { name: 'pg', namespace: 'db' },
10
+ spec: { instances: 3 },
11
+ status: { phase, readyInstances, ...extra },
12
+ })
13
+
14
+ const html = (resource: any) => renderToString(<CNPGClusterRenderer data={resource} />)
15
+
16
+ describe('CNPGClusterRenderer — the drawer must not contradict the badge', () => {
17
+ it('does not raise Degraded for a shortfall the phase already explains', () => {
18
+ // Mid-switchover at 2/3 is the operation, not a fault. The badge says
19
+ // "Switchover"; the drawer used to say "Degraded Cluster" beside it.
20
+ const switching = cluster('Switchover in progress', 2)
21
+ expect(getCNPGClusterStatus(switching).text).toBe('Switchover')
22
+ expect(html(switching)).not.toContain('Degraded Cluster')
23
+ })
24
+
25
+ it.each([
26
+ ['Upgrading cluster', 'transient'],
27
+ ['Waiting for user action', 'attention'],
28
+ ['Failing over', 'failing'],
29
+ ['Cluster is unrecoverable and needs manual intervention', 'terminal'],
30
+ ])('suppresses the Degraded banner during %s (%s)', (phase) => {
31
+ expect(html(cluster(phase, 2))).not.toContain('Degraded Cluster')
32
+ })
33
+
34
+ it('still raises Degraded when the phase does NOT explain the shortfall', () => {
35
+ // The regression guard for the fix: a settled cluster missing an instance
36
+ // is a genuine problem, and both surfaces must say so.
37
+ const settled = cluster('Cluster in healthy state', 2)
38
+ expect(getCNPGClusterStatus(settled).text).toBe('Degraded')
39
+ expect(html(settled)).toContain('Degraded Cluster')
40
+ })
41
+
42
+ it('still raises Cluster Down at zero ready, whatever the phase', () => {
43
+ // No phase excuses a database with nothing serving.
44
+ expect(html(cluster('Upgrading cluster', 0))).toContain('Cluster Down')
45
+ })
46
+
47
+ it('renders a failed last backup at the same tier as the badge and detector', () => {
48
+ // Three surfaces, one severity: badge `degraded`, Go SeverityWarning, and
49
+ // the banner. An error banner made the drawer the loudest of the three.
50
+ const failed = cluster('Cluster in healthy state', 3, {
51
+ conditions: [{ type: 'LastBackupSucceeded', status: 'False', reason: 'BackupFailed', message: 'upload rejected' }],
52
+ })
53
+ const out = html(failed)
54
+ expect(out).toContain('Last backup failed')
55
+ expect(getCNPGClusterStatus(failed).level).toBe('degraded')
56
+ // AlertBanner keys its tone off the variant; the error tone must be absent
57
+ // for this banner while the warning tone is present.
58
+ const banner = out.slice(out.indexOf('Last backup failed') - 400, out.indexOf('Last backup failed'))
59
+ expect(banner).not.toMatch(/status-unhealthy|text-red-/)
60
+ })
61
+ })
62
+
63
+ describe('the WAL banner must not claim nothing else looks wrong when something does', () => {
64
+ const walFailing = (phase: string, ready: number) => cluster(phase, ready, {
65
+ conditions: [{ type: 'ContinuousArchiving', status: 'False', reason: 'ContinuousArchivingFailing' }],
66
+ })
67
+ const CLAIM = 'otherwise serving normally'
68
+
69
+ it('makes the claim on a genuinely healthy cluster', () => {
70
+ expect(html(walFailing('Cluster in healthy state', 3))).toContain(CLAIM)
71
+ })
72
+
73
+ it('drops it mid-switchover, where a Switchover banner sits right above', () => {
74
+ // Regression guard for a sibling the phase gate itself introduced: this
75
+ // clause used to be suppressed by isDegraded, which a phase-explained
76
+ // shortfall no longer sets.
77
+ const out = html(walFailing('Switchover in progress', 2))
78
+ expect(out).toContain('Switchover in Progress')
79
+ expect(out).not.toContain(CLAIM)
80
+ })
81
+
82
+ it.each(['Waiting for user action', 'Upgrading cluster', 'Failing over'])('drops it during %s', (phase) => {
83
+ expect(html(walFailing(phase, 2))).not.toContain(CLAIM)
84
+ })
85
+ })
@@ -7,6 +7,7 @@ import {
7
7
  getCNPGClusterImage,
8
8
  getCNPGClusterStorage,
9
9
  getCNPGClusterStorageClass,
10
+ getCNPGVolumeHealth,
10
11
  getCNPGClusterWALStorage,
11
12
  getCNPGClusterBootstrapMethod,
12
13
  getCNPGClusterUpdateStrategy,
@@ -18,7 +19,11 @@ import {
18
19
  getCNPGClusterPostgresParams,
19
20
  getCNPGClusterInstancesReportedState,
20
21
  getCNPGClusterCertificateExpirations,
22
+ getCNPGWALArchivingFailure,
23
+ getCNPGLastBackupFailure,
24
+ classifyCNPGClusterPhase,
21
25
  } from '../resource-utils-cnpg'
26
+ import { formatAge } from '../resource-utils'
22
27
 
23
28
  interface CNPGClusterRendererProps {
24
29
  data: any
@@ -28,12 +33,18 @@ interface CNPGClusterRendererProps {
28
33
  export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererProps) {
29
34
  const conditions = data.status?.conditions || []
30
35
  const instances = data.spec?.instances ?? 0
31
- const readyInstances = data.status?.readyInstances ?? 0
36
+ // Presence matters — see getCNPGClusterStatus. A cluster whose status has no
37
+ // readyInstances yet is unknown, not zero; defaulting to 0 raised a "Cluster
38
+ // Down" banner on a cluster that had simply not reported yet.
39
+ const readyKnown = typeof data.status?.readyInstances === 'number'
40
+ const readyInstances: number = readyKnown ? data.status.readyInstances : 0
41
+ const countsKnown = instances > 0 && readyKnown
32
42
  const phase = getCNPGClusterPhase(data)
33
43
  const backupConfig = getCNPGClusterBackupConfig(data)
34
44
  const monitoring = getCNPGClusterMonitoring(data)
35
45
  const isReplica = getCNPGClusterIsReplica(data)
36
46
  const walStorage = getCNPGClusterWALStorage(data)
47
+ const volumeHealth = getCNPGVolumeHealth(data)
37
48
  const postgresParams = getCNPGClusterPostgresParams(data)
38
49
  const instanceNames = getCNPGClusterInstanceNames(data)
39
50
  const bootstrapMethod = getCNPGClusterBootstrapMethod(data)
@@ -57,15 +68,46 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
57
68
  // Last failed backup
58
69
  const lastFailedBackup = data.status?.lastFailedBackup
59
70
 
71
+ // WAL archiving / backup health, straight from the operator's own conditions.
72
+ const walArchivingFailure = getCNPGWALArchivingFailure(data)
73
+ const lastBackupFailure = getCNPGLastBackupFailure(data)
74
+ const phaseBucket = classifyCNPGClusterPhase(phase)
75
+
60
76
  // Problem detection
61
- const isDown = instances > 0 && readyInstances === 0
62
- const isDegraded = instances > 0 && readyInstances < instances && readyInstances > 0
63
- const isFailover = phase.toLowerCase().includes('failing over')
64
- const isSwitchover = phase.toLowerCase().includes('switchover')
77
+ const isDown = countsKnown && readyInstances === 0
78
+ // A shortfall the PHASE already explains is not a separate problem. Mirrors
79
+ // source_cnpg.go's phaseExplained gate and the badge's ordering, both of
80
+ // which check the phase before the instance counts: mid-switchover or waiting
81
+ // on a supervised update, running below the desired count is the operation,
82
+ // not a fault. Without this the drawer raised "Degraded Cluster" while the
83
+ // badge deliberately said "Switchover" — the same two-surfaces-one-cluster
84
+ // disagreement this integration exists to remove, inverted.
85
+ const phaseExplainsShortfall =
86
+ phaseBucket === 'terminal' || phaseBucket === 'failing'
87
+ || phaseBucket === 'attention' || phaseBucket === 'transient'
88
+ const isDegraded = countsKnown && readyInstances < instances && readyInstances > 0 && !phaseExplainsShortfall
89
+ const isFailover = phaseBucket === 'failing'
90
+ const isSwitchover = phase === 'Switchover in progress'
91
+ const isTerminal = phaseBucket === 'terminal'
92
+ // "The cluster is otherwise serving normally, so nothing else here will look
93
+ // wrong" is a claim about the REST OF THIS DRAWER, so any other banner
94
+ // falsifies it. It can no longer lean on isDegraded now that a
95
+ // phase-explained shortfall doesn't set it: a cluster mid-switchover at 2/3
96
+ // would assert nothing else looks wrong directly beneath a Switchover banner.
97
+ const hasOtherProblemBanner =
98
+ isDown || isDegraded || primaryMismatch || hasSplitBrain
99
+ || (phaseBucket !== 'healthy' && phaseBucket !== 'unknown')
65
100
 
66
101
  return (
67
102
  <>
68
103
  {/* Problem alerts */}
104
+ {isTerminal && (
105
+ <AlertBanner
106
+ variant="error"
107
+ title="Reconciliation has stopped"
108
+ message={`${phase}. This state does not resolve on its own — the operator has stopped reconciling this cluster and it needs manual intervention.`}
109
+ />
110
+ )}
69
111
  {hasSplitBrain && (
70
112
  <AlertBanner
71
113
  variant="error"
@@ -88,8 +130,13 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
88
130
  />
89
131
  )}
90
132
  {isFailover && (
133
+ // Warning, not error: the badge renders failover at the alert tier and
134
+ // the issue detector reports it as a warning. A failover that has also
135
+ // taken every instance down still gets the red "Cluster Down" banner
136
+ // above, so severity is not lost — this just stops one surface shouting
137
+ // louder than the other two about the same event.
91
138
  <AlertBanner
92
- variant="error"
139
+ variant="warning"
93
140
  title="Failover in Progress"
94
141
  message={`Cluster is performing a failover. Current phase: ${phase}`}
95
142
  />
@@ -108,11 +155,39 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
108
155
  message={`Target primary is ${targetPrimary} but current primary is ${currentPrimary}.`}
109
156
  />
110
157
  )}
111
- {lastFailedBackup && (
158
+ {walArchivingFailure && (
112
159
  <AlertBanner
113
160
  variant="error"
114
- title="Last Backup Failed"
115
- message={`Last backup failed at ${lastFailedBackup}. WAL archiving may be impacted and RPO is growing.`}
161
+ title="WAL archiving is failing"
162
+ message={
163
+ // Precision matters here. The condition proves the last archival
164
+ // attempt did not complete and has stayed False since its
165
+ // transition — it does not prove a continuous run of failures, and
166
+ // it says nothing about an exact RPO. The "still serving" framing
167
+ // is gated on the cluster actually being up: asserting it next to a
168
+ // Cluster Down banner would be plainly false.
169
+ `CNPG reports that the last WAL archival did not complete${walArchivingFailure.lastTransitionTime ? `, and archiving has been in this state for ${formatAge(walArchivingFailure.lastTransitionTime)}` : ''}. ` +
170
+ 'Recovery-point advancement is uncertain — check the backup destination before relying on point-in-time recovery.' +
171
+ (hasOtherProblemBanner
172
+ ? ''
173
+ : ' The cluster is otherwise serving normally, so nothing else here will look wrong.') +
174
+ (walArchivingFailure.message ? ` Operator reported: ${walArchivingFailure.message}` : '')
175
+ }
176
+ />
177
+ )}
178
+ {lastBackupFailure && (
179
+ // Warning, not error: the badge renders this at the degraded tier and
180
+ // the issue detector reports SeverityWarning. One failed attempt puts
181
+ // the recovery window at risk and CNPG will retry — unlike the
182
+ // archiving failure above, which is continuous and stays red on all
183
+ // three surfaces.
184
+ <AlertBanner
185
+ variant="warning"
186
+ title="Last backup failed"
187
+ message={
188
+ `The most recent backup attempt failed${lastBackupFailure.lastTransitionTime ? ` ${formatAge(lastBackupFailure.lastTransitionTime)} ago` : ''}.` +
189
+ (lastBackupFailure.message ? ` Operator reported: ${lastBackupFailure.message}` : '')
190
+ }
116
191
  />
117
192
  )}
118
193
  {expiredCerts.length > 0 && (
@@ -137,6 +212,14 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
137
212
  />
138
213
  )}
139
214
 
215
+ {volumeHealth && volumeHealth.unusable.length > 0 && (
216
+ <AlertBanner
217
+ variant="error"
218
+ title="Unusable storage volume"
219
+ message={`${volumeHealth.unusable.join(', ')} cannot be used because a paired volume is missing. An instance backed by it will not start, and the cluster cannot return to full size until the volume is restored or removed.`}
220
+ />
221
+ )}
222
+
140
223
  {/* Cluster Overview */}
141
224
  <Section title="Cluster Overview" icon={Database} defaultExpanded>
142
225
  <PropertyList>
@@ -217,6 +300,20 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
217
300
  <PropertyList>
218
301
  <Property label="Data Size" value={getCNPGClusterStorage(data)} />
219
302
  <Property label="Storage Class" value={getCNPGClusterStorageClass(data)} />
303
+ {/* Requested size and class are spec-side intentions. These are what the
304
+ operator reports about the volumes that actually exist. */}
305
+ {volumeHealth?.total !== undefined && (
306
+ <Property label="Volumes" value={`${volumeHealth.healthy.length}/${volumeHealth.total} healthy`} />
307
+ )}
308
+ {volumeHealth && volumeHealth.unusable.length > 0 && (
309
+ <Property label="Unusable" value={volumeHealth.unusable.join(', ')} />
310
+ )}
311
+ {volumeHealth && volumeHealth.resizing.length > 0 && (
312
+ <Property label="Resizing" value={volumeHealth.resizing.join(', ')} />
313
+ )}
314
+ {volumeHealth && volumeHealth.dangling.length > 0 && (
315
+ <Property label="Detached" value={volumeHealth.dangling.join(', ')} />
316
+ )}
220
317
  </PropertyList>
221
318
  {walStorage && (
222
319
  <div className="mt-2 pt-2 border-t border-theme-border">
@@ -242,6 +339,22 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
242
339
  {backupConfig.configured && (
243
340
  <Section title="Backup" icon={Clock} defaultExpanded>
244
341
  <PropertyList>
342
+ {backupConfig.plugin && (
343
+ <Property label="Managed By" value="barman-cloud plugin" />
344
+ )}
345
+ {backupConfig.plugin?.barmanObjectName && (
346
+ <Property label="Object Store" value={
347
+ <ResourceLink
348
+ name={backupConfig.plugin.barmanObjectName}
349
+ kind="objectstores"
350
+ namespace={data.metadata?.namespace || ''}
351
+ onNavigate={onNavigate}
352
+ />
353
+ } />
354
+ )}
355
+ {backupConfig.plugin?.isWALArchiver && (
356
+ <Property label="WAL Archiver" value="This plugin" />
357
+ )}
245
358
  {backupConfig.destinationPath && (
246
359
  <Property label="Destination" value={backupConfig.destinationPath} />
247
360
  )}
@@ -258,6 +371,17 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
258
371
  <Property label="First Recoverability" value={backupConfig.firstRecoverabilityPoint} />
259
372
  )}
260
373
  </PropertyList>
374
+ {/* Without this note the section renders empty on plugin-migrated
375
+ clusters, which is indistinguishable from "no backups configured" —
376
+ the worst possible ambiguity for a recovery-point display. */}
377
+ {backupConfig.rpoTrackedOnObjectStore && (
378
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
379
+ Recovery-point fields are not published on the Cluster when backups run through the
380
+ barman-cloud plugin. The recovery window is tracked on the ObjectStore
381
+ {backupConfig.plugin?.barmanObjectName ? ` "${backupConfig.plugin.barmanObjectName}"` : ''}
382
+ {backupConfig.plugin?.serverName ? `, under server "${backupConfig.plugin.serverName}"` : ''}.
383
+ </div>
384
+ )}
261
385
  </Section>
262
386
  )}
263
387
 
@@ -8,6 +8,7 @@ import {
8
8
  getCNPGPoolerParameters,
9
9
  getCNPGPoolerAuthQuery,
10
10
  getCNPGPoolerAuthQuerySecret,
11
+ isCNPGPoolerPaused,
11
12
  } from '../resource-utils-cnpg'
12
13
 
13
14
  interface CNPGPoolerRendererProps {
@@ -17,8 +18,13 @@ interface CNPGPoolerRendererProps {
17
18
 
18
19
  export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps) {
19
20
  const desired = data.spec?.instances ?? 0
20
- const ready = data.status?.instances ?? 0
21
- const isDegraded = desired > 0 && ready < desired
21
+ // status.instances is the number of pods trying to be SCHEDULED, not ready
22
+ // pods — a Pooler whose PgBouncer pods are all Pending still reports the full
23
+ // count. Naming it `ready` here is what let the banner claim a readiness the
24
+ // CR never establishes.
25
+ const scheduled = data.status?.instances ?? 0
26
+ const isDegraded = desired > 0 && scheduled < desired
27
+ const isPaused = isCNPGPoolerPaused(data)
22
28
  const clusterName = getCNPGPoolerCluster(data)
23
29
  const parameters = getCNPGPoolerParameters(data)
24
30
  const authQuery = getCNPGPoolerAuthQuery(data)
@@ -31,7 +37,15 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
31
37
  <AlertBanner
32
38
  variant="warning"
33
39
  title="Pooler Degraded"
34
- message={`Only ${ready} of ${desired} pooler instances are ready.`}
40
+ message={`Only ${scheduled} of ${desired} pooler instances are scheduled. Readiness is tracked on the "${data.metadata?.name}" Deployment, not on the Pooler.`}
41
+ />
42
+ )}
43
+
44
+ {isPaused && (
45
+ <AlertBanner
46
+ variant="warning"
47
+ title="Pooler Paused"
48
+ message="PgBouncer is paused, so client connections are held rather than served. The pods stay scheduled and Ready throughout, so nothing else about this Pooler will look wrong."
35
49
  />
36
50
  )}
37
51
 
@@ -40,7 +54,8 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
40
54
  <PropertyList>
41
55
  <Property label="Type" value={getCNPGPoolerType(data)} />
42
56
  <Property label="Pool Mode" value={getCNPGPoolerMode(data)} />
43
- <Property label="Instances" value={getCNPGPoolerInstances(data)} />
57
+ <Property label="Instances Scheduled" value={getCNPGPoolerInstances(data)} />
58
+ {isPaused && <Property label="PgBouncer" value="Paused" />}
44
59
  </PropertyList>
45
60
  </Section>
46
61