@skyhook-io/k8s-ui 1.16.2 → 1.17.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 (152) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  6. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  7. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  8. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  9. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  10. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  11. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  12. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  13. package/src/components/cnpg/index.ts +14 -0
  14. package/src/components/cnpg/primitives.tsx +134 -0
  15. package/src/components/cnpg/relations.test.ts +304 -0
  16. package/src/components/cnpg/relations.ts +417 -0
  17. package/src/components/cnpg/workspace.test.ts +285 -0
  18. package/src/components/cnpg/workspace.ts +700 -0
  19. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  20. package/src/components/gitops/detail-helpers.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  22. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  23. package/src/components/issues/IssuesView.tsx +10 -1
  24. package/src/components/issues/issue-timing.ts +2 -2
  25. package/src/components/issues/issues.test.ts +1 -1
  26. package/src/components/issues/types.ts +24 -0
  27. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  28. package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
  29. package/src/components/logs/useLogBuffer.test.ts +17 -0
  30. package/src/components/logs/useLogStream.ts +11 -0
  31. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  32. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  33. package/src/components/resources/ResourcesView.tsx +12 -9
  34. package/src/components/resources/index.ts +1 -1
  35. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  36. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  37. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  38. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  44. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  45. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  46. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  48. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  51. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  53. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  56. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  58. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  59. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  60. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  61. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  62. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  63. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  64. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  65. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  66. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  67. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  68. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  69. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  70. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  75. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  76. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  77. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  78. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  79. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  80. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  81. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  82. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  84. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  85. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  86. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  88. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  89. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  90. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  91. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  92. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  93. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  94. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  95. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  96. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  97. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  98. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  99. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  100. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  101. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  102. package/src/components/resources/resource-utils-cnpg.ts +3 -7
  103. package/src/components/resources/resource-utils-keda.ts +1 -7
  104. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  105. package/src/components/resources/resource-utils-velero.ts +0 -12
  106. package/src/components/resources/resource-utils.ts +17 -33
  107. package/src/components/resources/status-overclaim.test.ts +1 -1
  108. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  109. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  110. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  111. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  112. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  113. package/src/components/shared/SetImageDialog.test.ts +75 -6
  114. package/src/components/shared/SetImageDialog.tsx +92 -84
  115. package/src/components/shared/index.ts +2 -1
  116. package/src/components/timeline/TimelineList.tsx +187 -38
  117. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  118. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  119. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  120. package/src/components/timeline/index.ts +1 -0
  121. package/src/components/timeline/timeline-filters.ts +7 -0
  122. package/src/components/topology/TopologyGraph.tsx +5 -3
  123. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  124. package/src/components/ui/ScheduleValue.tsx +3 -2
  125. package/src/components/ui/drawer-components.test.tsx +66 -1
  126. package/src/components/ui/drawer-components.tsx +43 -29
  127. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  128. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  129. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  130. package/src/components/workload/WorkloadView.tsx +216 -48
  131. package/src/components/workload/index.ts +1 -0
  132. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  133. package/src/index.ts +3 -0
  134. package/src/types/capacity.ts +10 -0
  135. package/src/types/core.ts +32 -1
  136. package/src/utils/format.test.ts +40 -2
  137. package/src/utils/format.ts +5 -1
  138. package/src/utils/gateway-references.test.ts +17 -0
  139. package/src/utils/gateway-references.ts +29 -0
  140. package/src/utils/gitops-write-guard.test.ts +401 -0
  141. package/src/utils/gitops-write-guard.ts +509 -0
  142. package/src/utils/index.ts +2 -0
  143. package/src/utils/ingress-class-references.ts +22 -0
  144. package/src/utils/log-level.ts +14 -0
  145. package/src/utils/navigation.test.ts +13 -0
  146. package/src/utils/navigation.ts +5 -0
  147. package/src/utils/node-lifecycle.test.ts +36 -0
  148. package/src/utils/node-lifecycle.ts +77 -0
  149. package/src/utils/resource-hierarchy.test.ts +32 -0
  150. package/src/utils/resource-hierarchy.ts +28 -2
  151. package/src/utils/resource-refs.test.ts +23 -0
  152. package/src/utils/resource-refs.ts +30 -0
@@ -0,0 +1,236 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { CNPGBackupSummary, CNPGScheduledBackupSummary } from './CNPGBackupSummary'
4
+ import { CNPGObjectStoreSummary } from './CNPGObjectStoreSummary'
5
+ import { CNPGDatabaseSummary } from './CNPGDeclarativeSummary'
6
+ import { CNPGPoolerSummary } from './CNPGPoolerSummary'
7
+ import { CNPGImageCatalogSummary } from './CNPGImageCatalogSummary'
8
+ import { CNPG_WORKSPACE_KEYS, type CNPGWorkspaceKey, type CNPGWorkspaceResponse } from './workspace'
9
+
10
+ const PG = 'postgresql.cnpg.io/v1'
11
+ const PLUGIN = 'barman-cloud.cloudnative-pg.io'
12
+ const nav = () => {}
13
+
14
+ function ws(objects: Partial<Record<CNPGWorkspaceKey, any[]>>, over: Partial<CNPGWorkspaceResponse> = {}): CNPGWorkspaceResponse {
15
+ const coverage: CNPGWorkspaceResponse['coverage'] = {}
16
+ for (const k of CNPG_WORKSPACE_KEYS) coverage[k] = { state: 'full' }
17
+ return {
18
+ installed: true,
19
+ context: 'c',
20
+ namespaces: ['pg'],
21
+ coverage: { ...coverage, ...(over.coverage ?? {}) },
22
+ objects,
23
+ issues: over.issues ?? [],
24
+ audit: [],
25
+ backupsOmitted: over.backupsOmitted ?? 0,
26
+ }
27
+ }
28
+
29
+ function text(html: string): string {
30
+ return html
31
+ .split(/<[^>]*>/)
32
+ .join('')
33
+ .split('&#x27;').join("'")
34
+ .split('&quot;').join('"')
35
+ .split('&amp;').join('&')
36
+ }
37
+
38
+ const mainCluster = {
39
+ apiVersion: PG,
40
+ kind: 'Cluster',
41
+ metadata: { name: 'main', namespace: 'pg' },
42
+ spec: { plugins: [{ name: PLUGIN, parameters: { barmanObjectName: 'store' } }], managed: { roles: [{ name: 'reporting' }] } },
43
+ status: { conditions: [{ type: 'ContinuousArchiving', status: 'False', message: 'upload failed' }] },
44
+ }
45
+
46
+ describe('CNPGBackupSummary', () => {
47
+ const b = {
48
+ apiVersion: PG,
49
+ kind: 'Backup',
50
+ metadata: { name: 'main-20260901', namespace: 'pg', labels: { 'cnpg.io/scheduled-backup': 'nightly' } },
51
+ spec: { cluster: { name: 'main' }, method: 'plugin', pluginConfiguration: { name: PLUGIN } },
52
+ status: {
53
+ phase: 'failed',
54
+ startedAt: '2026-09-01T00:00:00Z',
55
+ stoppedAt: '2026-09-01T00:02:30Z',
56
+ instanceID: { podName: 'main-2' },
57
+ error: 'can not upload',
58
+ },
59
+ }
60
+
61
+ it('shows the outcome and relationships from the same status fields', () => {
62
+ const html = renderToString(<CNPGBackupSummary resource={b} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />)
63
+ const t = text(html)
64
+ expect(t).toContain('Failed')
65
+ expect(t).toContain('took 2m 30s')
66
+ expect(t).toContain('main-2')
67
+ expect(t).toContain('can not upload')
68
+ expect(t).toContain('ScheduledBackup nightly')
69
+ expect(t).toContain("ObjectStore store · from the Cluster's current configuration")
70
+ expect(t).not.toContain('On demand')
71
+ })
72
+
73
+ it('does not link a same-name schedule that replaced the one that took the backup', () => {
74
+ const owned = { ...b, metadata: { ...b.metadata, ownerReferences: [{ apiVersion: PG, kind: 'ScheduledBackup', name: 'nightly', uid: 'old' }] } }
75
+ const sched = { apiVersion: PG, kind: 'ScheduledBackup', metadata: { name: 'nightly', namespace: 'pg', uid: 'new' } }
76
+ const t = text(renderToString(<CNPGBackupSummary resource={owned} workspace={ws({ clusters: [mainCluster], scheduledBackups: [sched] })} onNavigate={nav} />))
77
+ expect(t).toContain('an earlier schedule of that name')
78
+ })
79
+
80
+ it('shows its own issues on top', () => {
81
+ const issues = [
82
+ { id: 'i1', severity: 'critical' as const, kind: 'Backup', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main-20260901', reason: 'CNPGBackupFailed', message: 'Backup failed' },
83
+ { id: 'i2', severity: 'critical' as const, kind: 'Backup', group: 'velero.io', namespace: 'pg', name: 'main-20260901', reason: 'VeleroBackupFailed', message: 'Velero backup failed' },
84
+ ]
85
+ const t = text(renderToString(<CNPGBackupSummary resource={b} workspace={ws({ clusters: [mainCluster] }, { issues })} onNavigate={nav} />))
86
+ expect(t).toContain('Backup failed')
87
+ expect(t).not.toContain('Velero backup failed')
88
+ })
89
+ })
90
+
91
+ describe('CNPGScheduledBackupSummary', () => {
92
+ it('lists owned runs and notes omitted history', () => {
93
+ const sched = { apiVersion: PG, kind: 'ScheduledBackup', metadata: { name: 'nightly', namespace: 'pg' }, spec: { cluster: { name: 'main' }, schedule: '0 0 0 * * *' } }
94
+ const run = { apiVersion: PG, kind: 'Backup', metadata: { name: 'run-1', namespace: 'pg', labels: { 'cnpg.io/scheduled-backup': 'nightly' } }, status: { phase: 'completed', startedAt: '2026-09-01T00:00:00Z' } }
95
+ const t = text(renderToString(<CNPGScheduledBackupSummary resource={sched} workspace={ws({ backups: [run] }, { backupsOmitted: 3 })} onNavigate={nav} />))
96
+ expect(t).toContain('0 0 0 * * *')
97
+ expect(t).toContain('seconds first')
98
+ expect(t).not.toContain('Daily')
99
+ expect(t).toContain('run-1')
100
+ expect(t).toContain('Completed')
101
+ expect(t).toContain('Backups older than 7 days are not listed')
102
+ })
103
+
104
+ it('says when Backups are not readable instead of listing none', () => {
105
+ const sched = { apiVersion: PG, kind: 'ScheduledBackup', metadata: { name: 'nightly', namespace: 'pg' }, spec: { cluster: { name: 'main' } } }
106
+ const t = text(renderToString(<CNPGScheduledBackupSummary resource={sched} workspace={ws({}, { coverage: { backups: { state: 'denied' } } })} />))
107
+ expect(t).toContain('No access to Backups')
108
+ expect(t).not.toContain('No Backups from this schedule')
109
+ })
110
+ })
111
+
112
+ describe('CNPGObjectStoreSummary', () => {
113
+ const store = {
114
+ apiVersion: 'barmancloud.cnpg.io/v1',
115
+ kind: 'ObjectStore',
116
+ metadata: { name: 'store', namespace: 'pg' },
117
+ spec: {
118
+ configuration: {
119
+ destinationPath: 's3://bucket/pg',
120
+ s3Credentials: { accessKeyId: { name: 's3-creds', key: 'ACCESS_KEY_ID' }, secretAccessKey: { name: 's3-creds', key: 'SECRET' } },
121
+ },
122
+ retentionPolicy: '30d',
123
+ },
124
+ status: {
125
+ serverRecoveryWindow: {
126
+ main: { firstRecoverabilityPoint: '2026-09-01T00:00:00Z', lastSuccessfulBackupTime: '2026-09-02T00:00:00Z', lastFailedBackupTime: '2026-09-03T00:00:00Z' },
127
+ },
128
+ },
129
+ }
130
+
131
+ it('labels upload health as inferred and explains the stalled window', () => {
132
+ const t = text(renderToString(<CNPGObjectStoreSummary resource={store} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />))
133
+ expect(t).toContain('inferred')
134
+ expect(t).toContain("Inferred from 1 cluster's WAL archiving and backup results — ObjectStore has no health status")
135
+ expect(t).toContain('Uploads failing')
136
+ expect(t).toContain('A backup failed after the last recorded success.')
137
+ expect(t).not.toContain('restorable')
138
+ expect(t).toContain('s3-creds')
139
+ expect(t).toContain('s3://bucket/pg')
140
+ })
141
+
142
+ it('never renders credential keys or Secret contents', () => {
143
+ const html = renderToString(<CNPGObjectStoreSummary resource={store} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />)
144
+ expect(html).not.toContain('ACCESS_KEY_ID')
145
+ expect(html).not.toContain('SECRET')
146
+ })
147
+
148
+ it('records a failure with no success without claiming a recovery point', () => {
149
+ const failing = { ...store, status: { serverRecoveryWindow: { main: { lastFailedBackupTime: '2026-09-03T00:00:00Z' } } } }
150
+ const t = text(renderToString(<CNPGObjectStoreSummary resource={failing} workspace={ws({ clusters: [mainCluster] })} />))
151
+ expect(t).toContain('No successful backup recorded.')
152
+ expect(t).not.toContain('restorable')
153
+ })
154
+
155
+ it('says when no visible cluster uses the store', () => {
156
+ const t = text(renderToString(<CNPGObjectStoreSummary resource={store} workspace={ws({ clusters: [] })} />))
157
+ expect(t).toContain('No visible cluster uses this store')
158
+ expect(t).not.toContain('Inferred from')
159
+ })
160
+ })
161
+
162
+ describe('CNPGDatabaseSummary', () => {
163
+ const db = (status: any) => ({
164
+ apiVersion: PG,
165
+ kind: 'Database',
166
+ metadata: { name: 'app-db', namespace: 'pg', generation: 2 },
167
+ spec: { cluster: { name: 'main' }, name: 'app', owner: 'app', databaseReclaimPolicy: 'delete' },
168
+ status,
169
+ })
170
+
171
+ it('reports an unreconciled database as pending, not failed', () => {
172
+ const t = text(renderToString(<CNPGDatabaseSummary resource={db({})} workspace={ws({ clusters: [mainCluster] })} onNavigate={nav} />))
173
+ expect(t).toContain('Pending')
174
+ expect(t).not.toContain('Not applied')
175
+ expect(t).toContain('drops it from PostgreSQL')
176
+ expect(t).toContain('GitOps source not recorded')
177
+ expect(t).not.toContain('Applied directly')
178
+ })
179
+
180
+ it('reports a failure and the missing managed role beside it', () => {
181
+ const t = text(
182
+ renderToString(
183
+ <CNPGDatabaseSummary
184
+ resource={db({ applied: false, observedGeneration: 2, message: 'role "app" does not exist' })}
185
+ workspace={ws({ clusters: [mainCluster] })}
186
+ onNavigate={nav}
187
+ />,
188
+ ),
189
+ )
190
+ expect(t).toContain('Not applied')
191
+ expect(t).toContain('“app” is not among main\'s managed roles')
192
+ })
193
+ })
194
+
195
+ describe('CNPGPoolerSummary', () => {
196
+ it('reports unknown scheduled count and unmeasured pressure', () => {
197
+ const pooler = { apiVersion: PG, kind: 'Pooler', metadata: { name: 'main-rw', namespace: 'pg' }, spec: { cluster: { name: 'main' }, type: 'rw', instances: 2 } }
198
+ const t = text(renderToString(<CNPGPoolerSummary resource={pooler} workspace={ws({})} onNavigate={nav} />))
199
+ expect(t).toContain('Scheduled count not reported')
200
+ expect(t).toContain('Not measured')
201
+ expect(t).toContain('main-rw')
202
+ })
203
+ })
204
+
205
+ describe('CNPGImageCatalogSummary', () => {
206
+ it('lists users and flags a major the catalog lacks', () => {
207
+ const catalog = { apiVersion: PG, kind: 'ClusterImageCatalog', metadata: { name: 'pg' }, spec: { images: [{ major: 16, image: 'ghcr.io/cnpg/postgresql:16' }] } }
208
+ const clusters = [
209
+ { apiVersion: PG, kind: 'Cluster', metadata: { name: 'a', namespace: 'x' }, spec: { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 16 } } },
210
+ { apiVersion: PG, kind: 'Cluster', metadata: { name: 'b', namespace: 'y' }, spec: { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 17 } } },
211
+ ]
212
+ const t = text(renderToString(<CNPGImageCatalogSummary resource={catalog} workspace={ws({ clusters })} onNavigate={nav} />))
213
+ expect(t).toContain('x/a')
214
+ expect(t).toContain('Requests PostgreSQL 17 · not in this catalog')
215
+ expect(t).toContain('clusters in namespaces you cannot read are not listed')
216
+ })
217
+
218
+ it('keeps known users when cluster coverage is partial', () => {
219
+ const catalog = { apiVersion: PG, kind: 'ClusterImageCatalog', metadata: { name: 'pg' }, spec: { images: [{ major: 16, image: 'img:16' }] } }
220
+ const clusters = [{ apiVersion: PG, kind: 'Cluster', metadata: { name: 'a', namespace: 'x' }, spec: { imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 16 } } }]
221
+ const t = text(
222
+ renderToString(
223
+ <CNPGImageCatalogSummary resource={catalog} workspace={ws({ clusters }, { coverage: { clusters: { state: 'partial', deniedNamespaces: ['y'] } } })} />,
224
+ ),
225
+ )
226
+ expect(t).toContain('x/a')
227
+ expect(t).toContain('Only clusters in namespaces you can read are listed.')
228
+ expect(t).not.toContain('No access to Clusters')
229
+ })
230
+
231
+ it('reserves the unavailable text for denied coverage', () => {
232
+ const catalog = { apiVersion: PG, kind: 'ClusterImageCatalog', metadata: { name: 'pg' }, spec: {} }
233
+ const t = text(renderToString(<CNPGImageCatalogSummary resource={catalog} workspace={ws({}, { coverage: { clusters: { state: 'denied' } } })} />))
234
+ expect(t).toContain('No access to Clusters')
235
+ })
236
+ })
@@ -0,0 +1,70 @@
1
+ import { getCNPGPoolerDeploymentName, getCNPGPoolerMode, getCNPGPoolerStatus, isCNPGPoolerPaused } from '../resources/resource-utils-cnpg'
2
+ import type { CNPGWorkspaceResponse } from './workspace'
3
+ import { FactGrid, FactRow, FactValue, RefLink, SummaryHeading, type CNPGNavigate } from './primitives'
4
+ import { ClusterLink, NotReported, Note, ObjectProblems, PhaseBadge, SummaryShell } from './CNPGSharedSummary'
5
+ import { refOf } from './relations'
6
+
7
+ const TYPE_LABEL: Record<string, string> = {
8
+ rw: 'rw · routes to the primary',
9
+ ro: 'ro · routes to replicas',
10
+ r: 'r · routes to any instance',
11
+ }
12
+
13
+ export function CNPGPoolerSummary({
14
+ resource,
15
+ workspace,
16
+ onNavigate,
17
+ }: {
18
+ resource: any
19
+ workspace: CNPGWorkspaceResponse | null
20
+ onNavigate?: CNPGNavigate
21
+ }) {
22
+ const ns = resource?.metadata?.namespace ?? ''
23
+ const type = resource?.spec?.type
24
+ const desired = resource?.spec?.instances
25
+ const scheduled = resource?.status?.instances
26
+ const deployment = getCNPGPoolerDeploymentName(resource)
27
+
28
+ return (
29
+ <SummaryShell>
30
+ <ObjectProblems issues={workspace?.issues} subject={refOf(resource, 'Pooler')} onNavigate={onNavigate} />
31
+
32
+ <SummaryHeading>State</SummaryHeading>
33
+ <FactGrid>
34
+ <FactRow label="Status">
35
+ <PhaseBadge status={getCNPGPoolerStatus(resource)} />
36
+ {isCNPGPoolerPaused(resource) && <Note>PgBouncer is paused: it holds client connections instead of serving them.</Note>}
37
+ </FactRow>
38
+ <FactRow label="Instances">
39
+ <span>
40
+ {typeof scheduled === 'number' ? `${scheduled} scheduled` : <NotReported text="Scheduled count not reported" />}
41
+ <span className="text-theme-text-secondary">
42
+ {' · '}
43
+ {typeof desired === 'number' ? `${desired} desired` : 'desired not set'}
44
+ </span>
45
+ </span>
46
+ <Note>The Pooler counts scheduled pods, not ready ones; readiness is on its Deployment.</Note>
47
+ </FactRow>
48
+ <FactRow label="Deployment">
49
+ {deployment ? (
50
+ <RefLink refTo={{ kind: 'Deployment', group: 'apps', namespace: ns, name: deployment }} onNavigate={onNavigate} mono />
51
+ ) : (
52
+ <NotReported />
53
+ )}
54
+ </FactRow>
55
+ <FactRow label="Connection pressure">
56
+ <FactValue fact={{ text: 'Not measured — needs PgBouncer metrics, which Radar does not read yet', tone: 'unknown' }} />
57
+ </FactRow>
58
+ </FactGrid>
59
+
60
+ <SummaryHeading>Routing</SummaryHeading>
61
+ <FactGrid>
62
+ <FactRow label="Cluster">
63
+ <ClusterLink resource={resource} workspace={workspace} onNavigate={onNavigate} />
64
+ </FactRow>
65
+ <FactRow label="Type">{type ? TYPE_LABEL[type] ?? type : <NotReported text="Not set" />}</FactRow>
66
+ <FactRow label="Pool mode">{getCNPGPoolerMode(resource)}</FactRow>
67
+ </FactGrid>
68
+ </SummaryShell>
69
+ )
70
+ }
@@ -0,0 +1,88 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Badge } from '../ui/Badge'
3
+ import type { StatusBadge as StatusBadgeValue } from '../resources/resource-utils'
4
+ import { CNPG_GROUP } from '../resources/resource-utils-cnpg'
5
+ import type { CNPGWorkspaceIssue, CNPGWorkspaceResponse } from './workspace'
6
+ import { FactValue, ProblemCallout, RefLink, type CNPGNavigate } from './primitives'
7
+ import { clustersIn, healthSeverity, problemsForObject, relationUnavailable, targetCluster, type CNPGObjectRef } from './relations'
8
+
9
+ const MAX_PROBLEMS = 3
10
+
11
+ export function SummaryShell({ children }: { children: ReactNode }) {
12
+ return <div className="px-4 py-4">{children}</div>
13
+ }
14
+
15
+ /** The object's own Radar issues, most severe first. */
16
+ export function ObjectProblems({
17
+ issues,
18
+ subject,
19
+ onNavigate,
20
+ }: {
21
+ issues: CNPGWorkspaceIssue[] | undefined
22
+ subject: CNPGObjectRef
23
+ onNavigate?: CNPGNavigate
24
+ }) {
25
+ const problems = problemsForObject(issues, subject)
26
+ if (problems.length === 0) return null
27
+ const shown = problems.slice(0, MAX_PROBLEMS)
28
+ const rest = problems.length - shown.length
29
+ return (
30
+ <div className="mb-4 space-y-2">
31
+ {shown.map((p, i) => (
32
+ <ProblemCallout
33
+ key={p.id}
34
+ problem={p}
35
+ onNavigate={onNavigate}
36
+ subjectIsSelf
37
+ more={i === shown.length - 1 && rest > 0 ? <span>+{rest} more</span> : null}
38
+ />
39
+ ))}
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export function PhaseBadge({ status }: { status: StatusBadgeValue }) {
45
+ return (
46
+ <Badge severity={healthSeverity(status.level)} size="sm">
47
+ {status.text}
48
+ </Badge>
49
+ )
50
+ }
51
+
52
+ export function NotReported({ text = 'Not reported' }: { text?: string }) {
53
+ return <span className="text-theme-text-tertiary">{text}</span>
54
+ }
55
+
56
+ /** A timestamp as an age, with the absolute time on hover. */
57
+ export function TimeAgo({ at, missing }: { at: unknown; missing?: string }) {
58
+ if (typeof at !== 'string' || !at || !Number.isFinite(Date.parse(at))) return <NotReported text={missing} />
59
+ return <FactValue fact={{ text: '', tone: 'neutral', at }} />
60
+ }
61
+
62
+ export function Note({ children }: { children: ReactNode }) {
63
+ return <div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">{children}</div>
64
+ }
65
+
66
+ /** The Cluster an object declares itself against, linked. */
67
+ export function ClusterLink({
68
+ resource,
69
+ workspace,
70
+ onNavigate,
71
+ }: {
72
+ resource: any
73
+ workspace: CNPGWorkspaceResponse | null
74
+ onNavigate?: CNPGNavigate
75
+ }) {
76
+ const name = resource?.spec?.cluster?.name
77
+ if (!name) return <NotReported text="Not set" />
78
+ const ns = resource?.metadata?.namespace ?? ''
79
+ const visible = !!targetCluster(resource, clustersIn(workspace))
80
+ return (
81
+ <span>
82
+ <RefLink refTo={{ kind: 'Cluster', group: CNPG_GROUP, namespace: ns, name }} onNavigate={onNavigate} mono />
83
+ {workspace && !visible && !relationUnavailable(workspace, 'clusters', ns, 'Clusters') && (
84
+ <span className="text-theme-text-tertiary"> · not found in this namespace</span>
85
+ )}
86
+ </span>
87
+ )
88
+ }
@@ -0,0 +1,14 @@
1
+ export * from './workspace'
2
+ export * from './primitives'
3
+ export * from './CNPGClusterSummary'
4
+ export * from './CNPGBackupSummary'
5
+ export * from './CNPGObjectStoreSummary'
6
+ export * from './CNPGDeclarativeSummary'
7
+ export * from './CNPGPoolerSummary'
8
+ export * from './CNPGImageCatalogSummary'
9
+ export {
10
+ inferredObjectStoreHealth,
11
+ usersOfObjectStore,
12
+ type CNPGObjectStoreHealth,
13
+ type CNPGObjectStoreUser,
14
+ } from './relations'
@@ -0,0 +1,134 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import type { HealthLevel } from '../resources/resource-utils'
4
+ import { StatusDot } from '../ui/status-tone'
5
+ import { Tooltip } from '../ui/Tooltip'
6
+ import { AlertBanner } from '../ui/drawer-components'
7
+ import { TONE_TEXT_CLASS } from '../ui/severity-tone'
8
+ import type { CNPGFact, CNPGProblem } from './workspace'
9
+ import { formatRelativeAgeTime } from '../../utils/format'
10
+
11
+ export interface CNPGRef {
12
+ kind: string
13
+ group?: string
14
+ namespace: string
15
+ name: string
16
+ }
17
+
18
+ export type CNPGNavigate = (ref: CNPGRef) => void
19
+
20
+ const TONE_TEXT: Record<HealthLevel, string> = {
21
+ healthy: 'text-theme-text-primary',
22
+ degraded: TONE_TEXT_CLASS.amber,
23
+ alert: TONE_TEXT_CLASS.orange,
24
+ unhealthy: TONE_TEXT_CLASS.red,
25
+ unknown: 'text-theme-text-tertiary',
26
+ neutral: 'text-theme-text-secondary',
27
+ }
28
+
29
+ export const CNPG_PRIMARY_BUTTON = 'btn-brand inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium'
30
+ export const CNPG_SECONDARY_BUTTON =
31
+ 'inline-flex items-center gap-1.5 rounded-lg border border-theme-border bg-theme-surface px-3 py-1.5 text-sm text-theme-text-primary transition-colors hover:bg-theme-hover'
32
+
33
+ export function toneTextClass(tone: HealthLevel): string {
34
+ return TONE_TEXT[tone]
35
+ }
36
+
37
+ export function FactValue({ fact, className }: { fact: CNPGFact; className?: string }) {
38
+ const when = fact.at ? formatRelativeAgeTime(fact.at) : null
39
+ const body = (
40
+ <span className={clsx(toneTextClass(fact.tone), className)}>
41
+ {fact.text}
42
+ {when && <span className="text-theme-text-secondary">{fact.text ? ' · ' : ''}{when}</span>}
43
+ </span>
44
+ )
45
+ if (!fact.source && !fact.at) return body
46
+ return (
47
+ <Tooltip content={[fact.at ? new Date(fact.at).toUTCString() : null, fact.source].filter(Boolean).join(' · ')} position="top">
48
+ {body}
49
+ </Tooltip>
50
+ )
51
+ }
52
+
53
+ export function FactSource({ fact }: { fact: CNPGFact }) {
54
+ if (!fact.source) return null
55
+ return <div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">{fact.source}</div>
56
+ }
57
+
58
+ export function FactGrid({ children }: { children: ReactNode }) {
59
+ return <dl className="grid grid-cols-[9.5rem_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">{children}</dl>
60
+ }
61
+
62
+ export function FactRow({ label, children }: { label: ReactNode; children: ReactNode }) {
63
+ return (
64
+ <>
65
+ <dt className="text-theme-text-tertiary">{label}</dt>
66
+ <dd className="min-w-0 break-words text-theme-text-primary">{children}</dd>
67
+ </>
68
+ )
69
+ }
70
+
71
+ export function SummaryHeading({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
72
+ return (
73
+ <div className="mb-2 mt-5 flex items-baseline gap-2 first:mt-0">
74
+ <h3 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{children}</h3>
75
+ {hint && <span className="text-[11px] text-theme-text-tertiary">{hint}</span>}
76
+ </div>
77
+ )
78
+ }
79
+
80
+ export function RefLink({ refTo, onNavigate, children, mono }: { refTo: CNPGRef; onNavigate?: CNPGNavigate; children?: ReactNode; mono?: boolean }) {
81
+ const label = children ?? refTo.name
82
+ if (!onNavigate) return <span className={clsx(mono && 'font-mono')}>{label}</span>
83
+ return (
84
+ <button
85
+ type="button"
86
+ onClick={() => onNavigate(refTo)}
87
+ className={clsx('text-accent-text hover:underline text-left break-all', mono && 'font-mono')}
88
+ >
89
+ {label}
90
+ </button>
91
+ )
92
+ }
93
+
94
+ const PROBLEM_VARIANT: Record<CNPGProblem['severity'], 'error' | 'warning' | 'info'> = {
95
+ critical: 'error',
96
+ warning: 'warning',
97
+ posture: 'info',
98
+ }
99
+
100
+ export function ProblemCallout({
101
+ problem,
102
+ more,
103
+ onNavigate,
104
+ action,
105
+ subjectIsSelf,
106
+ }: {
107
+ problem: CNPGProblem
108
+ more?: ReactNode
109
+ onNavigate?: CNPGNavigate
110
+ action?: ReactNode
111
+ /** The callout sits on the subject's own page, so linking to it would loop. */
112
+ subjectIsSelf?: boolean
113
+ }) {
114
+ const aboutChild = !subjectIsSelf && problem.subject.kind !== 'Cluster'
115
+ return (
116
+ <AlertBanner variant={PROBLEM_VARIANT[problem.severity]} title={problem.title} message={problem.detail}>
117
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
118
+ {aboutChild && (
119
+ <span>
120
+ {problem.subject.kind}{' '}
121
+ <RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
122
+ </span>
123
+ )}
124
+ <span>{problem.source === 'audit' ? 'Radar check' : 'Radar issue'}</span>
125
+ {action}
126
+ {more}
127
+ </div>
128
+ </AlertBanner>
129
+ )
130
+ }
131
+
132
+ export function ToneDot({ tone }: { tone: HealthLevel }) {
133
+ return <StatusDot tone={tone} size="sm" />
134
+ }