@skyhook-io/k8s-ui 1.11.0 → 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 (30) 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/resources/ResourcesView.tsx +2 -0
  7. package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
  8. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +24 -0
  9. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +11 -0
  10. package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
  11. package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
  12. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
  13. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
  14. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
  15. package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
  16. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
  17. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  18. package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
  19. package/src/components/resources/renderers/PolicySection.tsx +170 -0
  20. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
  21. package/src/components/resources/renderers/index.ts +1 -0
  22. package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
  23. package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
  24. package/src/components/resources/resource-utils-cnpg.test.ts +63 -0
  25. package/src/components/resources/resource-utils-cnpg.ts +60 -0
  26. package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
  27. package/src/types/gitops-flux-status.test.ts +163 -0
  28. package/src/types/gitops.ts +80 -24
  29. package/src/types/index.ts +1 -0
  30. package/src/types/policy.ts +42 -0
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { PolicySection } from './PolicySection'
4
+ import type { PolicyResourceResponse } from '../../../types/policy'
5
+
6
+ const base: PolicyResourceResponse = {
7
+ evaluated: true,
8
+ status: 'ready',
9
+ liveUpdates: true,
10
+ counts: { pass: 0, fail: 0, warn: 0, error: 0, skip: 0 },
11
+ findings: [],
12
+ }
13
+ const resp = (over: Partial<PolicyResourceResponse>): PolicyResourceResponse => ({ ...base, ...over })
14
+
15
+
16
+ // The unhappy paths carry this section. An empty finding list means several
17
+ // unrelated things, and rendering them all as blank space would tell an
18
+ // operator they are compliant in the cases where nothing was checked.
19
+ describe('PolicySection — what an empty result means', () => {
20
+ it('says which rules failed when there are violations', () => {
21
+ const html = renderToString(<PolicySection data={resp({
22
+ counts: { pass: 2, fail: 1, warn: 0, error: 0, skip: 0 },
23
+ findings: [{ policy: 'require-run-as-nonroot', result: 'fail', message: 'must set runAsNonRoot' }],
24
+ })} />)
25
+ expect(html).toContain('require-run-as-nonroot')
26
+ expect(html).toContain('must set runAsNonRoot')
27
+ // The passing checks are counted, not listed — otherwise two failures drown
28
+ // in a wall of green on a heavily-policed workload.
29
+ expect(html).toContain('2 other checks passing')
30
+ })
31
+
32
+ it('distinguishes "everything passed" from "nothing was checked"', () => {
33
+ const passing = renderToString(<PolicySection data={resp({
34
+ counts: { pass: 5, fail: 0, warn: 0, error: 0, skip: 0 },
35
+ })} />)
36
+ expect(passing).toContain('All 5 checks passing')
37
+
38
+ const unchecked = renderToString(<PolicySection data={resp({})} />)
39
+ expect(unchecked).toContain('No policy applies')
40
+ expect(unchecked).not.toContain('passing')
41
+ })
42
+
43
+ it('never claims compliance when the results could not be read', () => {
44
+ const html = renderToString(<PolicySection data={resp({
45
+ evaluated: false, status: 'deferred', reasonCode: 'rbac_denied',
46
+ })} />)
47
+ expect(html).toContain('has not been checked')
48
+ expect(html).toContain('rbac_denied')
49
+ expect(html).not.toContain('passing')
50
+ })
51
+
52
+ it('says results are still loading during warmup rather than reporting clean', () => {
53
+ const html = renderToString(<PolicySection data={resp({ evaluated: false, status: 'warmup' })} />)
54
+ expect(html).toContain('still loading')
55
+ })
56
+
57
+ it('renders nothing at all when no policy engine is installed', () => {
58
+ // A disclaimer on every cluster without a policy engine is pure noise.
59
+ expect(renderToString(<PolicySection data={resp({ evaluated: false, status: 'not_installed' })} />)).toBe('')
60
+ expect(renderToString(<PolicySection data={null} />)).toBe('')
61
+ })
62
+ })
63
+
64
+ describe('PolicySection — partial coverage', () => {
65
+ it('admits when some report families were unreadable, even while passing', () => {
66
+ // "All checks passing" plus an unreadable family is a claim of coverage we
67
+ // do not have.
68
+ const html = renderToString(<PolicySection data={resp({
69
+ counts: { pass: 3, fail: 0, warn: 0, error: 0, skip: 0 },
70
+ deniedGroups: ['openreports.io'],
71
+ })} />)
72
+ expect(html).toContain('All 3 checks passing')
73
+ expect(html).toContain('may be incomplete')
74
+ expect(html).toContain('openreports.io')
75
+ })
76
+
77
+ it('admits when the results are frozen', () => {
78
+ const html = renderToString(<PolicySection data={resp({
79
+ counts: { pass: 1, fail: 0, warn: 0, error: 0, skip: 0 }, liveUpdates: false,
80
+ })} />)
81
+ expect(html).toContain('not updating live')
82
+ })
83
+ })
84
+
85
+ describe('PolicySection — request states', () => {
86
+ it('shows loading rather than an empty result', () => {
87
+ expect(renderToString(<PolicySection data={null} loading />)).toContain('Loading policy results')
88
+ })
89
+
90
+ it('treats a permission denial as an expected state, not a red failure', () => {
91
+ const denied = Object.assign(new Error('forbidden'), { status: 403 })
92
+ const html = renderToString(<PolicySection data={null} error={denied} />)
93
+ expect(html).toContain('don’t have permission')
94
+ expect(html).not.toContain('text-red')
95
+ })
96
+
97
+ it('keeps a genuine fault loud', () => {
98
+ const html = renderToString(<PolicySection data={null} error={new Error('connection reset')} />)
99
+ expect(html).toContain('connection reset')
100
+ expect(html).toContain('text-red')
101
+ })
102
+ })
@@ -0,0 +1,170 @@
1
+ import { ShieldCheck, ShieldAlert, ShieldQuestion } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section } from '../../ui/drawer-components'
4
+ import { SEVERITY_BADGE } from '../../../utils/badge-colors'
5
+ import { isForbiddenError } from '../../../types/fetch-error'
6
+ import type { PolicyResourceResponse, PolicyResourceFinding } from '../../../types/policy'
7
+
8
+ interface PolicySectionProps {
9
+ data: PolicyResourceResponse | null
10
+ loading?: boolean
11
+ error?: Error | null
12
+ }
13
+
14
+ const TITLE = 'Policy'
15
+
16
+ // Result → badge tone. `error` means the engine could not evaluate the rule,
17
+ // which is not the same as the rule failing, so it does not share fail's red.
18
+ function resultTone(result: string): { badge: string; label: string } {
19
+ switch (result.toLowerCase()) {
20
+ case 'fail':
21
+ return { badge: SEVERITY_BADGE.error, label: 'Fail' }
22
+ case 'warn':
23
+ return { badge: SEVERITY_BADGE.warning, label: 'Warn' }
24
+ case 'error':
25
+ return { badge: SEVERITY_BADGE.alert, label: 'Error' }
26
+ case 'skip':
27
+ return { badge: SEVERITY_BADGE.neutral, label: 'Skipped' }
28
+ default:
29
+ return { badge: SEVERITY_BADGE.neutral, label: result }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * PolicySection reports what the cluster's policy engine says about this one
35
+ * resource.
36
+ *
37
+ * The unhappy paths carry the weight here. An empty finding list means one of
38
+ * several unrelated things, and rendering them all as blank space would tell an
39
+ * operator they are compliant in the cases where nothing was actually checked —
40
+ * the failure mode this section exists to avoid. Each state therefore says which
41
+ * one it is, in its own words, and the red treatment is reserved for genuine
42
+ * faults. Same division the RBAC sections already draw.
43
+ */
44
+ export function PolicySection({ data, loading, error }: PolicySectionProps) {
45
+ if (loading) {
46
+ return (
47
+ <Section title={TITLE} icon={ShieldQuestion}>
48
+ <div className="text-sm text-theme-text-tertiary">Loading policy results…</div>
49
+ </Section>
50
+ )
51
+ }
52
+
53
+ if (error) {
54
+ // A denial is an expected state on a namespace-scoped account, not a fault.
55
+ if (isForbiddenError(error)) {
56
+ return (
57
+ <Section title={TITLE} icon={ShieldQuestion}>
58
+ <div className="text-sm text-theme-text-tertiary">
59
+ You don’t have permission to view policy results in this namespace.
60
+ </div>
61
+ </Section>
62
+ )
63
+ }
64
+ return (
65
+ <Section title={TITLE} icon={ShieldAlert}>
66
+ <div className="text-sm text-red-400">{`Could not load policy results: ${error.message}`}</div>
67
+ </Section>
68
+ )
69
+ }
70
+
71
+ // No engine installed — the section is genuinely not applicable, so it is
72
+ // absent rather than showing a reassuring "nothing to report". A disclaimer on
73
+ // every cluster without a policy engine would be noise.
74
+ if (!data || data.status === 'not_installed') return null
75
+
76
+ if (!data.evaluated) {
77
+ return (
78
+ <Section title={TITLE} icon={ShieldQuestion}>
79
+ <div className="text-sm text-theme-text-tertiary">
80
+ {(data.status === 'warmup'
81
+ ? 'Policy results are still loading from the cluster.'
82
+ : 'Policy results are unavailable, so this resource has not been checked.') +
83
+ (data.reasonCode ? ` (${data.reasonCode})` : '')}
84
+ </div>
85
+ </Section>
86
+ )
87
+ }
88
+
89
+ const { counts, findings } = data
90
+ const checked = counts.pass + counts.fail + counts.warn + counts.error + counts.skip
91
+
92
+ // Evaluated, but no policy selected this resource at all. Distinct from
93
+ // "passing everything": nothing examined it, so there is nothing to pass.
94
+ if (checked === 0) {
95
+ return (
96
+ <Section title={TITLE} icon={ShieldQuestion}>
97
+ <div className="text-sm text-theme-text-tertiary">
98
+ No policy applies to this resource.
99
+ </div>
100
+ <PartialCoverageNote data={data} />
101
+ </Section>
102
+ )
103
+ }
104
+
105
+ const failing = findings.length
106
+
107
+ return (
108
+ <Section
109
+ title={TITLE}
110
+ icon={failing > 0 ? ShieldAlert : ShieldCheck}
111
+ defaultExpanded={failing > 0}
112
+ >
113
+ {failing === 0 ? (
114
+ <div className="text-sm text-theme-text-secondary">
115
+ {`All ${counts.pass} ${counts.pass === 1 ? 'check' : 'checks'} passing.`}
116
+ </div>
117
+ ) : (
118
+ <div className="space-y-2">
119
+ {findings.map((f, i) => (
120
+ <PolicyFindingRow key={`${f.policy}/${f.rule}/${i}`} finding={f} />
121
+ ))}
122
+ {counts.pass > 0 && (
123
+ <div className="text-xs text-theme-text-tertiary pt-1">
124
+ {`${counts.pass} other ${counts.pass === 1 ? 'check' : 'checks'} passing`}
125
+ </div>
126
+ )}
127
+ </div>
128
+ )}
129
+ <PartialCoverageNote data={data} />
130
+ </Section>
131
+ )
132
+ }
133
+
134
+ function PolicyFindingRow({ finding }: { finding: PolicyResourceFinding }) {
135
+ const tone = resultTone(finding.result)
136
+ return (
137
+ <div className="flex items-start gap-2 min-w-0">
138
+ <span className={clsx('badge shrink-0', tone.badge)}>{tone.label}</span>
139
+ <div className="min-w-0">
140
+ <div className="text-sm text-theme-text-primary truncate" title={finding.policy}>
141
+ {finding.policy}
142
+ {finding.rule && finding.rule !== finding.policy && (
143
+ <span className="text-theme-text-tertiary"> · {finding.rule}</span>
144
+ )}
145
+ </div>
146
+ {finding.message && (
147
+ <div className="text-xs text-theme-text-secondary">{finding.message}</div>
148
+ )}
149
+ </div>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ /**
155
+ * A result can be real and incomplete at the same time: some report families
156
+ * may be unreadable by this identity, or the index may be frozen. Saying
157
+ * "all checks passing" without mentioning that claims coverage we do not have.
158
+ */
159
+ function PartialCoverageNote({ data }: { data: PolicyResourceResponse }) {
160
+ const denied = data.deniedGroups ?? []
161
+ if (denied.length === 0 && data.liveUpdates) return null
162
+ return (
163
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-tertiary">
164
+ {denied.length > 0 && (
165
+ <div>{`Some policy results could not be read (${denied.join(', ')}), so this list may be incomplete.`}</div>
166
+ )}
167
+ {!data.liveUpdates && <div>These results are not updating live.</div>}
168
+ </div>
169
+ )
170
+ }
@@ -1,6 +1,8 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { Server, ExternalLink, Scale, Minus, Plus, Loader2, Shield } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
+ import { PolicySection } from './PolicySection'
5
+ import type { PolicyResourceResponse } from '../../../types/policy'
4
6
  import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge, useOperationalIssuesShown } from '../../ui/drawer-components'
5
7
  import { DialogPortal } from '../../ui/DialogPortal'
6
8
  import { Tooltip } from '../../ui/Tooltip'
@@ -40,6 +42,14 @@ interface WorkloadRendererProps {
40
42
  rbacData?: RBACSubjectResponse | null
41
43
  rbacLoading?: boolean
42
44
  rbacError?: Error | null
45
+ /**
46
+ * Policy findings for this workload. Undefined means the host didn't wire the
47
+ * fetch, so the section is omitted entirely — consumers that skip it get the
48
+ * original sections and nothing breaks.
49
+ */
50
+ policyData?: PolicyResourceResponse | null
51
+ policyLoading?: boolean
52
+ policyError?: Error | null
43
53
  }
44
54
 
45
55
  // Check if the workload is actively progressing (scaling, rolling update)
@@ -140,7 +150,7 @@ function compactHPASummary(diagnosis: HPADiagnosis): string {
140
150
  return diagnosis.summary
141
151
  }
142
152
 
143
- export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, scalerDiagnostics, onRequestRefresh, rbacData, rbacLoading, rbacError }: WorkloadRendererProps) {
153
+ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale, isScalePending, scaleBlockedBy, scalerDiagnostics, onRequestRefresh, rbacData, rbacLoading, rbacError, policyData, policyLoading, policyError }: WorkloadRendererProps) {
144
154
  const status = data.status || {}
145
155
  const spec = data.spec || {}
146
156
  const metadata = data.metadata || {}
@@ -372,6 +382,10 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
372
382
  </div>
373
383
  </DialogPortal>
374
384
 
385
+ {policyData !== undefined && (
386
+ <PolicySection data={policyData} loading={policyLoading} error={policyError} />
387
+ )}
388
+
375
389
  <Section title="Strategy">
376
390
  <PropertyList>
377
391
  {isDaemonSet || isStatefulSet ? (
@@ -160,3 +160,4 @@ export * from './ResourceSliceRenderer'
160
160
  export * from './nvidia-cells'
161
161
  export * from './NvidiaClusterPolicyRenderer'
162
162
  export * from './NvidiaDriverRenderer'
163
+ export { PolicySection } from './PolicySection'
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { clsx } from 'clsx'
4
4
  import {
5
+ getPolicyReportScope,
5
6
  getPolicyReportStatus,
6
7
  getPolicyReportSummary,
7
8
  getKyvernoPolicyStatus,
@@ -19,6 +20,14 @@ export function PolicyReportCell({ resource, column }: { resource: any; column:
19
20
  </span>
20
21
  )
21
22
  }
23
+ // Kyverno names a per-resource report after the subject's UID, so the Name
24
+ // column reads as a bare UUID. Without the subject the whole table is
25
+ // unidentifiable rows with counts beside them — you cannot tell which of
26
+ // your workloads a failing report belongs to without opening every one.
27
+ case 'scope': {
28
+ const scope = getPolicyReportScope(resource)
29
+ return <span className="truncate text-theme-text-primary" title={scope}>{scope}</span>
30
+ }
22
31
  case 'pass': {
23
32
  const summary = getPolicyReportSummary(resource)
24
33
  return <span className={clsx('text-sm', summary.pass > 0 ? 'text-green-400' : 'text-theme-text-tertiary')}>{summary.pass}</span>
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { PolicyReportCell } from './kyverno-cells'
4
+ import { getPolicyReportScope } from '../resource-utils-kyverno'
5
+
6
+ // Kyverno names each per-resource report after the subject's UID, so the Name
7
+ // column is a bare UUID. Without the subject, the table is a list of
8
+ // unidentifiable rows with counts beside them.
9
+ describe('PolicyReport subject column', () => {
10
+ const report = {
11
+ metadata: { name: '4c1d2f8a-91e3-4b7c-9f11-2a6e0c5d7b83', namespace: 'payments' },
12
+ scope: { kind: 'Deployment', namespace: 'payments', name: 'checkout-api' },
13
+ }
14
+
15
+ it('names the workload the report is about', () => {
16
+ const html = renderToString(<PolicyReportCell resource={report} column="scope" />)
17
+ expect(html).toContain('checkout-api')
18
+ expect(html).toContain('Deployment')
19
+ })
20
+
21
+ it('degrades to a dash rather than an empty cell when there is no subject', () => {
22
+ // Cluster-scoped reports and some producers omit scope entirely.
23
+ expect(getPolicyReportScope({ metadata: { name: 'x' } })).toBe('-')
24
+ const html = renderToString(<PolicyReportCell resource={{ metadata: { name: 'x' } }} column="scope" />)
25
+ expect(html).toContain('-')
26
+ })
27
+ })
@@ -4,6 +4,8 @@ import {
4
4
  getCNPGClusterStatus,
5
5
  getCNPGBackupStatus,
6
6
  getCNPGPoolerStatus,
7
+ isCNPGPoolerPaused,
8
+ getCNPGVolumeHealth,
7
9
  getCNPGClusterInstancesReportedState,
8
10
  getCNPGClusterBackupConfig,
9
11
  getCNPGClusterBarmanPlugin,
@@ -599,3 +601,64 @@ describe('an absent count is unknown, never zero', () => {
599
601
  .toMatchObject({ text: 'Scheduled', level: 'healthy' })
600
602
  })
601
603
  })
604
+
605
+ describe('paused Pooler', () => {
606
+ // PgBouncer PAUSE holds client connections instead of serving them, while
607
+ // every pod stays scheduled and Ready. Reading only the pod counts rendered
608
+ // a pooler that serves nothing in healthy green.
609
+ const paused = { spec: { instances: 2, pgbouncer: { paused: true } }, status: { instances: 2 } }
610
+
611
+ it('does not read as healthy', () => {
612
+ expect(getCNPGPoolerStatus(paused)).toMatchObject({ text: 'Paused', level: 'degraded' })
613
+ })
614
+
615
+ it('treats pausing as intent, not a fault', () => {
616
+ // Same tier as a paused Velero Schedule: amber, not red.
617
+ expect(getCNPGPoolerStatus(paused).level).not.toBe('unhealthy')
618
+ })
619
+
620
+ it('lets a real fault outrank the pause', () => {
621
+ const pausedAndDown = { spec: { instances: 2, pgbouncer: { paused: true } }, status: { instances: 0 } }
622
+ expect(getCNPGPoolerStatus(pausedAndDown)).toMatchObject({ text: 'Not Scheduled', level: 'unhealthy' })
623
+ })
624
+
625
+ it('only an explicit true pauses', () => {
626
+ expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: {} }, status: { instances: 2 } }))
627
+ .toMatchObject({ text: 'Scheduled', level: 'healthy' })
628
+ expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: { paused: false } }, status: { instances: 2 } }))
629
+ .toMatchObject({ text: 'Scheduled', level: 'healthy' })
630
+ expect(isCNPGPoolerPaused({ spec: { pgbouncer: { paused: true } } })).toBe(true)
631
+ expect(isCNPGPoolerPaused({ spec: {} })).toBe(false)
632
+ })
633
+ })
634
+
635
+ describe('volume health', () => {
636
+ // Field names and shape verified against a live CloudNativePG 1.27 CRD and a
637
+ // running cluster: pvcCount is a number, the rest are arrays of PVC names.
638
+ const healthy = {
639
+ status: { pvcCount: 2, healthyPVC: ['pg-healthy-1', 'pg-healthy-2'] },
640
+ }
641
+
642
+ it('reads what the operator reports about volumes that exist', () => {
643
+ const h = getCNPGVolumeHealth(healthy)
644
+ expect(h).toMatchObject({ total: 2, healthy: ['pg-healthy-1', 'pg-healthy-2'], unusable: [] })
645
+ })
646
+
647
+ it('surfaces an unusable volume, which is why an instance cannot start', () => {
648
+ // Upstream: unusable means a paired volume is missing.
649
+ const h = getCNPGVolumeHealth({ status: { pvcCount: 3, healthyPVC: ['a'], unusablePVC: ['pg-main-3'] } })
650
+ expect(h?.unusable).toEqual(['pg-main-3'])
651
+ })
652
+
653
+ it('stays silent when the operator has reported nothing', () => {
654
+ // Absence must not render as "0 problems" — the section is hidden instead,
655
+ // so it never claims a check it did not perform.
656
+ expect(getCNPGVolumeHealth({ status: {} })).toBeNull()
657
+ expect(getCNPGVolumeHealth({})).toBeNull()
658
+ })
659
+
660
+ it('ignores malformed entries rather than rendering them', () => {
661
+ const h = getCNPGVolumeHealth({ status: { pvcCount: 1, healthyPVC: ['ok', 42, null] } })
662
+ expect(h?.healthy).toEqual(['ok'])
663
+ })
664
+ })
@@ -666,6 +666,12 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
666
666
  return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
667
667
  }
668
668
 
669
+ // Faults outrank intent: a Pooler that is both paused and unscheduled has a
670
+ // problem worth fixing before the pause is worth mentioning.
671
+ if (isCNPGPoolerPaused(resource)) {
672
+ return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
673
+ }
674
+
669
675
  if (desired > 0 && scheduled >= desired) {
670
676
  return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
671
677
  }
@@ -673,6 +679,16 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
673
679
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
674
680
  }
675
681
 
682
+ /**
683
+ * PgBouncer holds client connections instead of serving them while paused, and
684
+ * every pod stays scheduled and Ready throughout - the one Pooler state that
685
+ * pod counts cannot express. CRD-defaulted to false, so only an explicit true
686
+ * pauses.
687
+ */
688
+ export function isCNPGPoolerPaused(resource: any): boolean {
689
+ return resource.spec?.pgbouncer?.paused === true
690
+ }
691
+
676
692
  /** Name of the Deployment CNPG generates for this Pooler — where real readiness lives. */
677
693
  export function getCNPGPoolerDeploymentName(resource: any): string {
678
694
  return resource.metadata?.name || ''
@@ -707,3 +723,47 @@ export function getCNPGPoolerAuthQuerySecret(resource: any): { name: string } |
707
723
  if (!secret?.name) return undefined
708
724
  return { name: secret.name }
709
725
  }
726
+
727
+ /**
728
+ * Volume health for a CNPG cluster, read from the arrays the operator publishes
729
+ * on status. Radar previously showed only the REQUESTED size and storage class,
730
+ * which are spec-side intentions — a cluster whose volumes had failed rendered
731
+ * a cheerful "20Gi" with nothing to suggest otherwise.
732
+ *
733
+ * `unusablePVC` is the sharp one. Upstream defines it as a volume that cannot be
734
+ * used because a paired volume is missing, which is why a database instance can
735
+ * be permanently unable to start while every other field looks ordinary.
736
+ *
737
+ * Returns null when the operator has published nothing, so the section can stay
738
+ * silent rather than claim zero problems it never checked for.
739
+ */
740
+ export interface CNPGVolumeHealth {
741
+ total?: number
742
+ healthy: string[]
743
+ unusable: string[]
744
+ dangling: string[]
745
+ resizing: string[]
746
+ initializing: string[]
747
+ }
748
+
749
+ export function getCNPGVolumeHealth(resource: any): CNPGVolumeHealth | null {
750
+ const s = resource?.status
751
+ if (!s) return null
752
+ const list = (v: any): string[] => (Array.isArray(v) ? v.filter((x: any) => typeof x === 'string') : [])
753
+ const health: CNPGVolumeHealth = {
754
+ total: typeof s.pvcCount === 'number' ? s.pvcCount : undefined,
755
+ healthy: list(s.healthyPVC),
756
+ unusable: list(s.unusablePVC),
757
+ dangling: list(s.danglingPVC),
758
+ resizing: list(s.resizingPVC),
759
+ initializing: list(s.initializingPVC),
760
+ }
761
+ const reportedAnything =
762
+ health.total !== undefined ||
763
+ health.healthy.length > 0 ||
764
+ health.unusable.length > 0 ||
765
+ health.dangling.length > 0 ||
766
+ health.resizing.length > 0 ||
767
+ health.initializing.length > 0
768
+ return reportedAnything ? health : null
769
+ }
@@ -9,6 +9,7 @@ import {
9
9
  getProviderConfigStatus,
10
10
  getProviderConfigRef,
11
11
  getCrossplaneResourceRefs,
12
+ getBoundXRRef,
12
13
  getExternalName,
13
14
  getComposingXRRef,
14
15
  isCrossplanePaused,
@@ -512,6 +513,73 @@ describe('getCrossplaneResourceRefs', () => {
512
513
  })
513
514
  })
514
515
 
516
+ describe('getBoundXRRef', () => {
517
+ it('returns the singular spec.resourceRef of a v1 claim', () => {
518
+ const claim = {
519
+ spec: {
520
+ compositionRef: { name: 'database' },
521
+ resourceRef: {
522
+ apiVersion: 'platform.example.org/v1alpha1',
523
+ kind: 'Database',
524
+ name: 'example-database-x7k2m',
525
+ },
526
+ },
527
+ } as const
528
+ const ref = getBoundXRRef(claim)
529
+ expect(ref?.kind).toBe('Database')
530
+ expect(ref?.name).toBe('example-database-x7k2m')
531
+ expect(ref?.apiVersion).toBe('platform.example.org/v1alpha1')
532
+ })
533
+
534
+ it('returns null when resourceRef is absent or malformed', () => {
535
+ expect(getBoundXRRef({ spec: {} } as const)).toBeNull()
536
+ expect(getBoundXRRef({ spec: { resourceRef: { name: 'no-kind' } } } as const)).toBeNull()
537
+ expect(getBoundXRRef({ spec: { resourceRef: { kind: 'X' } } } as const)).toBeNull()
538
+ expect(getBoundXRRef({} as const)).toBeNull()
539
+ })
540
+ })
541
+
542
+ describe('claim vs XR composed-refs split', () => {
543
+ // A v1 claim carries NO resourceRefs of its own — only a singular resourceRef
544
+ // to its bound XR. The composed refs live on that XR. The claim panel must
545
+ // follow the ref rather than read the claim, or it shows "No composed
546
+ // resources" for a claim that has composed plenty.
547
+ const claim = {
548
+ spec: {
549
+ compositionRef: { name: 'database' },
550
+ resourceRef: {
551
+ apiVersion: 'platform.example.org/v1alpha1',
552
+ kind: 'Database',
553
+ name: 'example-database-x7k2m',
554
+ },
555
+ },
556
+ } as const
557
+ const boundXR = {
558
+ spec: {
559
+ resourceRefs: [
560
+ { apiVersion: 'database.example.org/v1beta1', kind: 'Instance', name: 'example-database-x7k2m' },
561
+ { apiVersion: 'identity.example.org/v1beta1', kind: 'Role', name: 'example-database-access' },
562
+ { apiVersion: 'kubernetes.crossplane.io/v1alpha2', kind: 'Object', name: 'example-database-connection' },
563
+ ],
564
+ },
565
+ } as const
566
+
567
+ it('reads nothing off the claim itself', () => {
568
+ expect(getCrossplaneResourceRefs(claim)).toEqual([])
569
+ })
570
+
571
+ it('is detected as a claim with a bound XR', () => {
572
+ expect(isClaim(claim)).toBe(true)
573
+ expect(getBoundXRRef(claim)?.name).toBe('example-database-x7k2m')
574
+ })
575
+
576
+ it('reads the composed refs off the bound XR', () => {
577
+ const refs = getCrossplaneResourceRefs(boundXR)
578
+ expect(refs).toHaveLength(3)
579
+ expect(refs.map(r => r.kind)).toEqual(['Instance', 'Role', 'Object'])
580
+ })
581
+ })
582
+
515
583
  describe('getExternalName', () => {
516
584
  it('reads the crossplane.io/external-name annotation', () => {
517
585
  const resource = {