@skyhook-io/k8s-ui 1.10.5 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +1 -1
  2. package/src/components/issues/IssuesView.tsx +9 -0
  3. package/src/components/issues/diagnostic.ts +3 -0
  4. package/src/components/issues/issues.test.ts +11 -0
  5. package/src/components/issues/severity.ts +3 -0
  6. package/src/components/issues/types.ts +3 -0
  7. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  8. package/src/components/resources/ResourcesView.tsx +259 -30
  9. package/src/components/resources/index.ts +2 -0
  10. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  11. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  14. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  15. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  16. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  17. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  18. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  19. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  20. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  21. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  22. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  23. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  24. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  25. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  26. package/src/components/resources/renderers/index.ts +4 -0
  27. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  28. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  29. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  30. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  31. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  32. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  33. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  34. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  35. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  36. package/src/components/resources/resource-utils-velero.ts +214 -22
  37. package/src/components/resources/resource-utils.ts +70 -3
  38. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  39. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  40. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  41. package/src/components/topology/K8sResourceNode.tsx +6 -0
  42. package/src/components/trace/ReachabilityGraph.tsx +20 -6
  43. package/src/components/trace/ReachabilityView.tsx +253 -52
  44. package/src/components/trace/problemRows.test.ts +107 -0
  45. package/src/components/trace/reachGraphModel.test.ts +16 -0
  46. package/src/components/trace/reachInspector.test.ts +233 -10
  47. package/src/components/trace/reachInspector.ts +166 -36
  48. package/src/components/trace/reachMarks.test.ts +32 -1
  49. package/src/components/trace/reachMarks.ts +44 -0
  50. package/src/components/trace/reachOrigins.ts +12 -0
  51. package/src/components/trace/types.ts +25 -0
  52. package/src/components/ui/Badge.tsx +36 -0
  53. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  54. package/src/theme/components.css +26 -0
  55. package/src/utils/api-resources.ts +2 -0
  56. package/src/utils/resource-icons.test.ts +33 -0
  57. 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
+ })
@@ -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
+ })
@@ -18,7 +18,11 @@ import {
18
18
  getCNPGClusterPostgresParams,
19
19
  getCNPGClusterInstancesReportedState,
20
20
  getCNPGClusterCertificateExpirations,
21
+ getCNPGWALArchivingFailure,
22
+ getCNPGLastBackupFailure,
23
+ classifyCNPGClusterPhase,
21
24
  } from '../resource-utils-cnpg'
25
+ import { formatAge } from '../resource-utils'
22
26
 
23
27
  interface CNPGClusterRendererProps {
24
28
  data: any
@@ -28,7 +32,12 @@ interface CNPGClusterRendererProps {
28
32
  export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererProps) {
29
33
  const conditions = data.status?.conditions || []
30
34
  const instances = data.spec?.instances ?? 0
31
- const readyInstances = data.status?.readyInstances ?? 0
35
+ // Presence matters — see getCNPGClusterStatus. A cluster whose status has no
36
+ // readyInstances yet is unknown, not zero; defaulting to 0 raised a "Cluster
37
+ // Down" banner on a cluster that had simply not reported yet.
38
+ const readyKnown = typeof data.status?.readyInstances === 'number'
39
+ const readyInstances: number = readyKnown ? data.status.readyInstances : 0
40
+ const countsKnown = instances > 0 && readyKnown
32
41
  const phase = getCNPGClusterPhase(data)
33
42
  const backupConfig = getCNPGClusterBackupConfig(data)
34
43
  const monitoring = getCNPGClusterMonitoring(data)
@@ -57,15 +66,46 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
57
66
  // Last failed backup
58
67
  const lastFailedBackup = data.status?.lastFailedBackup
59
68
 
69
+ // WAL archiving / backup health, straight from the operator's own conditions.
70
+ const walArchivingFailure = getCNPGWALArchivingFailure(data)
71
+ const lastBackupFailure = getCNPGLastBackupFailure(data)
72
+ const phaseBucket = classifyCNPGClusterPhase(phase)
73
+
60
74
  // 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')
75
+ const isDown = countsKnown && readyInstances === 0
76
+ // A shortfall the PHASE already explains is not a separate problem. Mirrors
77
+ // source_cnpg.go's phaseExplained gate and the badge's ordering, both of
78
+ // which check the phase before the instance counts: mid-switchover or waiting
79
+ // on a supervised update, running below the desired count is the operation,
80
+ // not a fault. Without this the drawer raised "Degraded Cluster" while the
81
+ // badge deliberately said "Switchover" — the same two-surfaces-one-cluster
82
+ // disagreement this integration exists to remove, inverted.
83
+ const phaseExplainsShortfall =
84
+ phaseBucket === 'terminal' || phaseBucket === 'failing'
85
+ || phaseBucket === 'attention' || phaseBucket === 'transient'
86
+ const isDegraded = countsKnown && readyInstances < instances && readyInstances > 0 && !phaseExplainsShortfall
87
+ const isFailover = phaseBucket === 'failing'
88
+ const isSwitchover = phase === 'Switchover in progress'
89
+ const isTerminal = phaseBucket === 'terminal'
90
+ // "The cluster is otherwise serving normally, so nothing else here will look
91
+ // wrong" is a claim about the REST OF THIS DRAWER, so any other banner
92
+ // falsifies it. It can no longer lean on isDegraded now that a
93
+ // phase-explained shortfall doesn't set it: a cluster mid-switchover at 2/3
94
+ // would assert nothing else looks wrong directly beneath a Switchover banner.
95
+ const hasOtherProblemBanner =
96
+ isDown || isDegraded || primaryMismatch || hasSplitBrain
97
+ || (phaseBucket !== 'healthy' && phaseBucket !== 'unknown')
65
98
 
66
99
  return (
67
100
  <>
68
101
  {/* Problem alerts */}
102
+ {isTerminal && (
103
+ <AlertBanner
104
+ variant="error"
105
+ title="Reconciliation has stopped"
106
+ message={`${phase}. This state does not resolve on its own — the operator has stopped reconciling this cluster and it needs manual intervention.`}
107
+ />
108
+ )}
69
109
  {hasSplitBrain && (
70
110
  <AlertBanner
71
111
  variant="error"
@@ -88,8 +128,13 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
88
128
  />
89
129
  )}
90
130
  {isFailover && (
131
+ // Warning, not error: the badge renders failover at the alert tier and
132
+ // the issue detector reports it as a warning. A failover that has also
133
+ // taken every instance down still gets the red "Cluster Down" banner
134
+ // above, so severity is not lost — this just stops one surface shouting
135
+ // louder than the other two about the same event.
91
136
  <AlertBanner
92
- variant="error"
137
+ variant="warning"
93
138
  title="Failover in Progress"
94
139
  message={`Cluster is performing a failover. Current phase: ${phase}`}
95
140
  />
@@ -108,11 +153,39 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
108
153
  message={`Target primary is ${targetPrimary} but current primary is ${currentPrimary}.`}
109
154
  />
110
155
  )}
111
- {lastFailedBackup && (
156
+ {walArchivingFailure && (
112
157
  <AlertBanner
113
158
  variant="error"
114
- title="Last Backup Failed"
115
- message={`Last backup failed at ${lastFailedBackup}. WAL archiving may be impacted and RPO is growing.`}
159
+ title="WAL archiving is failing"
160
+ message={
161
+ // Precision matters here. The condition proves the last archival
162
+ // attempt did not complete and has stayed False since its
163
+ // transition — it does not prove a continuous run of failures, and
164
+ // it says nothing about an exact RPO. The "still serving" framing
165
+ // is gated on the cluster actually being up: asserting it next to a
166
+ // Cluster Down banner would be plainly false.
167
+ `CNPG reports that the last WAL archival did not complete${walArchivingFailure.lastTransitionTime ? `, and archiving has been in this state for ${formatAge(walArchivingFailure.lastTransitionTime)}` : ''}. ` +
168
+ 'Recovery-point advancement is uncertain — check the backup destination before relying on point-in-time recovery.' +
169
+ (hasOtherProblemBanner
170
+ ? ''
171
+ : ' The cluster is otherwise serving normally, so nothing else here will look wrong.') +
172
+ (walArchivingFailure.message ? ` Operator reported: ${walArchivingFailure.message}` : '')
173
+ }
174
+ />
175
+ )}
176
+ {lastBackupFailure && (
177
+ // Warning, not error: the badge renders this at the degraded tier and
178
+ // the issue detector reports SeverityWarning. One failed attempt puts
179
+ // the recovery window at risk and CNPG will retry — unlike the
180
+ // archiving failure above, which is continuous and stays red on all
181
+ // three surfaces.
182
+ <AlertBanner
183
+ variant="warning"
184
+ title="Last backup failed"
185
+ message={
186
+ `The most recent backup attempt failed${lastBackupFailure.lastTransitionTime ? ` ${formatAge(lastBackupFailure.lastTransitionTime)} ago` : ''}.` +
187
+ (lastBackupFailure.message ? ` Operator reported: ${lastBackupFailure.message}` : '')
188
+ }
116
189
  />
117
190
  )}
118
191
  {expiredCerts.length > 0 && (
@@ -242,6 +315,22 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
242
315
  {backupConfig.configured && (
243
316
  <Section title="Backup" icon={Clock} defaultExpanded>
244
317
  <PropertyList>
318
+ {backupConfig.plugin && (
319
+ <Property label="Managed By" value="barman-cloud plugin" />
320
+ )}
321
+ {backupConfig.plugin?.barmanObjectName && (
322
+ <Property label="Object Store" value={
323
+ <ResourceLink
324
+ name={backupConfig.plugin.barmanObjectName}
325
+ kind="objectstores"
326
+ namespace={data.metadata?.namespace || ''}
327
+ onNavigate={onNavigate}
328
+ />
329
+ } />
330
+ )}
331
+ {backupConfig.plugin?.isWALArchiver && (
332
+ <Property label="WAL Archiver" value="This plugin" />
333
+ )}
245
334
  {backupConfig.destinationPath && (
246
335
  <Property label="Destination" value={backupConfig.destinationPath} />
247
336
  )}
@@ -258,6 +347,17 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
258
347
  <Property label="First Recoverability" value={backupConfig.firstRecoverabilityPoint} />
259
348
  )}
260
349
  </PropertyList>
350
+ {/* Without this note the section renders empty on plugin-migrated
351
+ clusters, which is indistinguishable from "no backups configured" —
352
+ the worst possible ambiguity for a recovery-point display. */}
353
+ {backupConfig.rpoTrackedOnObjectStore && (
354
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
355
+ Recovery-point fields are not published on the Cluster when backups run through the
356
+ barman-cloud plugin. The recovery window is tracked on the ObjectStore
357
+ {backupConfig.plugin?.barmanObjectName ? ` "${backupConfig.plugin.barmanObjectName}"` : ''}
358
+ {backupConfig.plugin?.serverName ? `, under server "${backupConfig.plugin.serverName}"` : ''}.
359
+ </div>
360
+ )}
261
361
  </Section>
262
362
  )}
263
363
 
@@ -17,8 +17,12 @@ interface CNPGPoolerRendererProps {
17
17
 
18
18
  export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps) {
19
19
  const desired = data.spec?.instances ?? 0
20
- const ready = data.status?.instances ?? 0
21
- const isDegraded = desired > 0 && ready < desired
20
+ // status.instances is the number of pods trying to be SCHEDULED, not ready
21
+ // pods — a Pooler whose PgBouncer pods are all Pending still reports the full
22
+ // count. Naming it `ready` here is what let the banner claim a readiness the
23
+ // CR never establishes.
24
+ const scheduled = data.status?.instances ?? 0
25
+ const isDegraded = desired > 0 && scheduled < desired
22
26
  const clusterName = getCNPGPoolerCluster(data)
23
27
  const parameters = getCNPGPoolerParameters(data)
24
28
  const authQuery = getCNPGPoolerAuthQuery(data)
@@ -31,7 +35,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
31
35
  <AlertBanner
32
36
  variant="warning"
33
37
  title="Pooler Degraded"
34
- message={`Only ${ready} of ${desired} pooler instances are ready.`}
38
+ message={`Only ${scheduled} of ${desired} pooler instances are scheduled. Readiness is tracked on the "${data.metadata?.name}" Deployment, not on the Pooler.`}
35
39
  />
36
40
  )}
37
41
 
@@ -40,7 +44,7 @@ export function CNPGPoolerRenderer({ data, onNavigate }: CNPGPoolerRendererProps
40
44
  <PropertyList>
41
45
  <Property label="Type" value={getCNPGPoolerType(data)} />
42
46
  <Property label="Pool Mode" value={getCNPGPoolerMode(data)} />
43
- <Property label="Instances" value={getCNPGPoolerInstances(data)} />
47
+ <Property label="Instances Scheduled" value={getCNPGPoolerInstances(data)} />
44
48
  </PropertyList>
45
49
  </Section>
46
50
 
@@ -0,0 +1,23 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { EventRenderer } from './EventRenderer'
5
+
6
+ describe('EventRenderer', () => {
7
+ it.each([
8
+ ['Warning', 'status-degraded'],
9
+ ['Normal', 'status-neutral'],
10
+ ])('uses the theme-aware status tone for %s events', (type, statusClass) => {
11
+ const html = renderToString(<EventRenderer data={{
12
+ type,
13
+ reason: 'FailedScheduling',
14
+ message: 'No nodes are available',
15
+ }} />)
16
+
17
+ expect(html).toContain(statusClass)
18
+ expect(html).toContain('FailedScheduling')
19
+ expect(html).toContain('No nodes are available')
20
+ expect(html).not.toMatch(/text-(?:amber|blue)-(?:200|300|400)/)
21
+ expect(html).not.toContain('opacity-')
22
+ })
23
+ })