@skyhook-io/k8s-ui 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
  3. package/src/components/gitops/GitOpsTableView.tsx +22 -2
  4. package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
  5. package/src/components/gitops/detail-helpers.ts +4 -1
  6. package/src/components/issues/severity.ts +1 -0
  7. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  8. package/src/components/resources/ResourcesView.tsx +412 -5
  9. package/src/components/resources/index.ts +27 -0
  10. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  11. package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
  12. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  13. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  14. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  15. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  16. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +71 -4
  17. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  18. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  19. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  20. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +11 -0
  21. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  22. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  23. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  24. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  25. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  26. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  27. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  28. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  29. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  30. package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
  31. package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
  32. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
  33. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
  34. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
  35. package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
  36. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  37. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  38. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  39. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  40. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  41. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  42. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  43. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  44. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  45. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  46. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
  47. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  48. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  49. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  50. package/src/components/resources/renderers/PolicySection.test.tsx +159 -0
  51. package/src/components/resources/renderers/PolicySection.tsx +197 -0
  52. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  53. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  54. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  55. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  56. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  57. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  58. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  59. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  60. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  61. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  62. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  63. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  64. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  65. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  66. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
  67. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  68. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  69. package/src/components/resources/renderers/index.ts +16 -0
  70. package/src/components/resources/renderers/kyverno-cells.tsx +90 -0
  71. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  72. package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
  73. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  74. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  75. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  76. package/src/components/resources/resource-utils-calico.ts +261 -0
  77. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  78. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  79. package/src/components/resources/resource-utils-cnpg.test.ts +117 -0
  80. package/src/components/resources/resource-utils-cnpg.ts +324 -3
  81. package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
  82. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  83. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  84. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  85. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  86. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  87. package/src/components/resources/resource-utils-velero.ts +68 -0
  88. package/src/components/resources/resource-utils.ts +98 -0
  89. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  90. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  91. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  92. package/src/components/shared/index.ts +1 -1
  93. package/src/components/shared/revision-role-badges.test.ts +120 -0
  94. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  95. package/src/components/topology/K8sResourceNode.tsx +22 -0
  96. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  97. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  98. package/src/components/topology/TopologyGraph.tsx +6 -6
  99. package/src/components/topology/TopologySearch.tsx +7 -0
  100. package/src/components/topology/layout.ts +3 -0
  101. package/src/components/trace/ReachabilityView.tsx +1 -0
  102. package/src/components/trace/TracePanel.tsx +12 -2
  103. package/src/components/trace/reachFixtures.ts +26 -1
  104. package/src/components/trace/reachGraphModel.test.ts +20 -0
  105. package/src/components/trace/reachGraphModel.ts +8 -1
  106. package/src/components/trace/reachOrigins.test.ts +31 -0
  107. package/src/components/trace/reachOrigins.ts +11 -1
  108. package/src/components/ui/Badge.tsx +10 -0
  109. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  110. package/src/components/ui/ScheduleValue.tsx +85 -0
  111. package/src/components/ui/drawer-components.test.tsx +17 -1
  112. package/src/components/ui/drawer-components.tsx +9 -2
  113. package/src/components/ui/index.ts +2 -0
  114. package/src/components/workload/WorkloadView.tsx +3 -0
  115. package/src/types/core.ts +29 -0
  116. package/src/types/gitops-flux-status.test.ts +163 -0
  117. package/src/types/gitops.ts +80 -24
  118. package/src/types/index.ts +1 -0
  119. package/src/types/policy.ts +193 -0
  120. package/src/utils/navigation.test.ts +5 -0
  121. package/src/utils/navigation.ts +5 -0
  122. package/src/utils/resource-icons.test.ts +49 -2
  123. package/src/utils/resource-icons.ts +29 -2
@@ -1,3 +1,4 @@
1
+ import type React from 'react'
1
2
  import { Database, HardDrive, Activity, Clock, Shield, KeyRound } from 'lucide-react'
2
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
4
  import { pluralize } from '../../../utils/pluralize'
@@ -7,6 +8,7 @@ import {
7
8
  getCNPGClusterImage,
8
9
  getCNPGClusterStorage,
9
10
  getCNPGClusterStorageClass,
11
+ getCNPGVolumeHealth,
10
12
  getCNPGClusterWALStorage,
11
13
  getCNPGClusterBootstrapMethod,
12
14
  getCNPGClusterUpdateStrategy,
@@ -21,15 +23,20 @@ import {
21
23
  getCNPGWALArchivingFailure,
22
24
  getCNPGLastBackupFailure,
23
25
  classifyCNPGClusterPhase,
26
+ CNPG_BARMAN_OBJECTSTORE_GROUP,
24
27
  } from '../resource-utils-cnpg'
25
28
  import { formatAge } from '../resource-utils'
29
+ import { RecoveryTime } from './CNPGShared'
26
30
 
27
31
  interface CNPGClusterRendererProps {
32
+ /** Filled by the host with the Databases, Publications and Subscriptions
33
+ * declared against this cluster — a reverse lookup the package cannot do. */
34
+ declared?: React.ReactNode
28
35
  data: any
29
36
  onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
30
37
  }
31
38
 
32
- export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererProps) {
39
+ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRendererProps) {
33
40
  const conditions = data.status?.conditions || []
34
41
  const instances = data.spec?.instances ?? 0
35
42
  // Presence matters — see getCNPGClusterStatus. A cluster whose status has no
@@ -43,6 +50,7 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
43
50
  const monitoring = getCNPGClusterMonitoring(data)
44
51
  const isReplica = getCNPGClusterIsReplica(data)
45
52
  const walStorage = getCNPGClusterWALStorage(data)
53
+ const volumeHealth = getCNPGVolumeHealth(data)
46
54
  const postgresParams = getCNPGClusterPostgresParams(data)
47
55
  const instanceNames = getCNPGClusterInstanceNames(data)
48
56
  const bootstrapMethod = getCNPGClusterBootstrapMethod(data)
@@ -210,6 +218,14 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
210
218
  />
211
219
  )}
212
220
 
221
+ {volumeHealth && volumeHealth.unusable.length > 0 && (
222
+ <AlertBanner
223
+ variant="error"
224
+ title="Unusable storage volume"
225
+ message={`${volumeHealth.unusable.join(', ')} cannot be used because a paired volume is missing. An instance backed by it will not start, and the cluster cannot return to full size until the volume is restored or removed.`}
226
+ />
227
+ )}
228
+
213
229
  {/* Cluster Overview */}
214
230
  <Section title="Cluster Overview" icon={Database} defaultExpanded>
215
231
  <PropertyList>
@@ -220,6 +236,40 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
220
236
  <Property label="Target Primary" value={targetPrimary} />
221
237
  )}
222
238
  <Property label="Image" value={getCNPGClusterImage(data)} />
239
+ {/* When the image comes from a catalog rather than a literal tag, the
240
+ catalog is where an "incomplete or invalid image catalog" phase is
241
+ diagnosed — so it is named and reachable rather than implied. */}
242
+ {data?.spec?.imageCatalogRef?.name && (
243
+ <Property
244
+ label="Image Catalog"
245
+ value={
246
+ <span className="inline-flex items-baseline gap-1.5 min-w-0">
247
+ <ResourceLink
248
+ name={data.spec.imageCatalogRef.name}
249
+ kind={
250
+ data.spec.imageCatalogRef.kind === 'ClusterImageCatalog'
251
+ ? 'clusterimagecatalogs'
252
+ : 'imagecatalogs'
253
+ }
254
+ namespace={
255
+ data.spec.imageCatalogRef.kind === 'ClusterImageCatalog'
256
+ ? ''
257
+ : (data.metadata?.namespace ?? '')
258
+ }
259
+ group="postgresql.cnpg.io"
260
+ onNavigate={onNavigate}
261
+ />
262
+ {/* A catalog holds one image per major, so the name alone does
263
+ not say which entry this cluster resolves to. */}
264
+ {data.spec.imageCatalogRef.major != null && (
265
+ <span className="text-xs text-theme-text-tertiary">
266
+ {`PostgreSQL ${data.spec.imageCatalogRef.major}`}
267
+ </span>
268
+ )}
269
+ </span>
270
+ }
271
+ />
272
+ )}
223
273
  <Property label="Update Strategy" value={getCNPGClusterUpdateStrategy(data)} />
224
274
  {data.status?.writeService && (
225
275
  <Property label="Write Service" value={
@@ -290,6 +340,20 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
290
340
  <PropertyList>
291
341
  <Property label="Data Size" value={getCNPGClusterStorage(data)} />
292
342
  <Property label="Storage Class" value={getCNPGClusterStorageClass(data)} />
343
+ {/* Requested size and class are spec-side intentions. These are what the
344
+ operator reports about the volumes that actually exist. */}
345
+ {volumeHealth?.total !== undefined && (
346
+ <Property label="Volumes" value={`${volumeHealth.healthy.length}/${volumeHealth.total} healthy`} />
347
+ )}
348
+ {volumeHealth && volumeHealth.unusable.length > 0 && (
349
+ <Property label="Unusable" value={volumeHealth.unusable.join(', ')} />
350
+ )}
351
+ {volumeHealth && volumeHealth.resizing.length > 0 && (
352
+ <Property label="Resizing" value={volumeHealth.resizing.join(', ')} />
353
+ )}
354
+ {volumeHealth && volumeHealth.dangling.length > 0 && (
355
+ <Property label="Detached" value={volumeHealth.dangling.join(', ')} />
356
+ )}
293
357
  </PropertyList>
294
358
  {walStorage && (
295
359
  <div className="mt-2 pt-2 border-t border-theme-border">
@@ -323,6 +387,7 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
323
387
  <ResourceLink
324
388
  name={backupConfig.plugin.barmanObjectName}
325
389
  kind="objectstores"
390
+ group={CNPG_BARMAN_OBJECTSTORE_GROUP}
326
391
  namespace={data.metadata?.namespace || ''}
327
392
  onNavigate={onNavigate}
328
393
  />
@@ -338,13 +403,13 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
338
403
  <Property label="Retention" value={backupConfig.retentionPolicy} />
339
404
  )}
340
405
  {backupConfig.lastSuccessfulBackup && (
341
- <Property label="Last Successful" value={backupConfig.lastSuccessfulBackup} />
406
+ <Property label="Last Successful" value={<RecoveryTime at={backupConfig.lastSuccessfulBackup} />} />
342
407
  )}
343
408
  {lastFailedBackup && (
344
- <Property label="Last Failed" value={lastFailedBackup} />
409
+ <Property label="Last Failed" value={<RecoveryTime at={lastFailedBackup} />} />
345
410
  )}
346
411
  {backupConfig.firstRecoverabilityPoint && (
347
- <Property label="First Recoverability" value={backupConfig.firstRecoverabilityPoint} />
412
+ <Property label="First Recoverability" value={<RecoveryTime at={backupConfig.firstRecoverabilityPoint} />} />
348
413
  )}
349
414
  </PropertyList>
350
415
  {/* Without this note the section renders empty on plugin-migrated
@@ -361,6 +426,8 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
361
426
  </Section>
362
427
  )}
363
428
 
429
+ {declared}
430
+
364
431
  {/* Certificates */}
365
432
  {certExpirations.length > 0 && (
366
433
  <Section title="Certificates" icon={KeyRound} defaultExpanded>
@@ -0,0 +1,243 @@
1
+ import type React from 'react'
2
+ import { Database, AlertTriangle, Table2 } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
5
+ import { HEALTH_BADGE_COLORS } from '../../../utils/badge-colors'
6
+ import {
7
+ getCNPGDeclarativeStatus,
8
+ getCNPGDeclarativeMessage,
9
+ getCNPGDeclarativeCluster,
10
+ getCNPGReclaimPolicy,
11
+ getCNPGImageCatalogEntries,
12
+ } from '../resource-utils-cnpg'
13
+
14
+ type Nav = (ref: { kind: string; namespace: string; name: string; group?: string }) => void
15
+
16
+ /**
17
+ * Database, Publication and Subscription — CNPG's declarative objects.
18
+ *
19
+ * One renderer for three kinds because they share the state that matters:
20
+ * whether the operator has applied them to PostgreSQL, and what it said if it
21
+ * could not. They differ only in which spec fields identify the thing being
22
+ * declared, which the caller supplies.
23
+ *
24
+ * The status is deliberately three-valued. `applied: false` is a failure the
25
+ * operator can explain; an absent `applied` means it has not reconciled yet,
26
+ * and rendering that as a failure would condemn every object in its first
27
+ * seconds.
28
+ */
29
+ /** Shared shell for the three declarative kinds; not part of the package API. */
30
+ function CNPGDeclarativeRenderer({
31
+ data,
32
+ onNavigate,
33
+ details,
34
+ extra,
35
+ }: {
36
+ data: any
37
+ onNavigate?: Nav
38
+ /** Kind-specific rows: what this object declares. */
39
+ details: Array<{ label: string; value: React.ReactNode }>
40
+ /** Sections the host adds below, e.g. reverse lookups needing a data fetch. */
41
+ extra?: React.ReactNode
42
+ }) {
43
+ const status = getCNPGDeclarativeStatus(data)
44
+ const message = getCNPGDeclarativeMessage(data)
45
+ const cluster = getCNPGDeclarativeCluster(data)
46
+ const reclaim = getCNPGReclaimPolicy(data)
47
+ const namespace = data?.metadata?.namespace ?? ''
48
+
49
+ return (
50
+ <>
51
+ {/* The operator's own words, verbatim. A failure here means the object
52
+ exists in Kubernetes and not in PostgreSQL — a split an operator will
53
+ not spot from the CR alone. */}
54
+ {status.level === 'unhealthy' && message && (
55
+ <AlertBanner variant="error" title="Not applied to PostgreSQL" message={message} />
56
+ )}
57
+
58
+ <Section title="Status" icon={Database} defaultExpanded>
59
+ <PropertyList>
60
+ <Property
61
+ label="Applied"
62
+ value={
63
+ <span className={clsx('badge', HEALTH_BADGE_COLORS[status.level as keyof typeof HEALTH_BADGE_COLORS])}>
64
+ {status.text}
65
+ </span>
66
+ }
67
+ />
68
+ {cluster && (
69
+ <Property
70
+ label="Cluster"
71
+ value={
72
+ <ResourceLink
73
+ name={cluster}
74
+ kind="clusters"
75
+ namespace={namespace}
76
+ group="postgresql.cnpg.io"
77
+ onNavigate={onNavigate}
78
+ />
79
+ }
80
+ />
81
+ )}
82
+ {details.map((d) => (
83
+ <Property key={d.label} label={d.label} value={d.value} />
84
+ ))}
85
+ <Property
86
+ label="On delete"
87
+ value={
88
+ reclaim.destructive ? (
89
+ <span className="text-warning-text">
90
+ {`${reclaim.value} — removing this resource drops it from PostgreSQL`}
91
+ </span>
92
+ ) : (
93
+ <span>{`${reclaim.value} — removing this resource leaves PostgreSQL untouched`}</span>
94
+ )
95
+ }
96
+ />
97
+ </PropertyList>
98
+ {/* Pending is not failure, and saying nothing here would leave the
99
+ reader to guess which of the two they are looking at. */}
100
+ {status.level === 'unknown' && (
101
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
102
+ The operator has not reported on this yet. It has not failed — it has not been applied.
103
+ </div>
104
+ )}
105
+ {status.level !== 'unhealthy' && message && (
106
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">{message}</div>
107
+ )}
108
+ </Section>
109
+ {extra}
110
+ </>
111
+ )
112
+ }
113
+
114
+ export function CNPGDatabaseRenderer({
115
+ data,
116
+ onNavigate,
117
+ usedBy,
118
+ }: {
119
+ data: any
120
+ onNavigate?: Nav
121
+ /** Filled by the host with what replicates out of this database. */
122
+ usedBy?: React.ReactNode
123
+ }) {
124
+ const spec = data?.spec ?? {}
125
+ const details: Array<{ label: string; value: React.ReactNode }> = [
126
+ { label: 'Database', value: spec.name ?? '-' },
127
+ { label: 'Owner', value: spec.owner ?? '-' },
128
+ ]
129
+ if (spec.encoding) details.push({ label: 'Encoding', value: spec.encoding })
130
+ if (spec.locale) details.push({ label: 'Locale', value: spec.locale })
131
+ if (spec.connectionLimit != null) {
132
+ details.push({ label: 'Connection Limit', value: String(spec.connectionLimit) })
133
+ }
134
+ if (spec.allowConnections === false) {
135
+ details.push({ label: 'Connections', value: 'Not allowed' })
136
+ }
137
+ if (spec.isTemplate === true) details.push({ label: 'Template', value: 'Yes' })
138
+ return <CNPGDeclarativeRenderer data={data} onNavigate={onNavigate} details={details} extra={usedBy} />
139
+ }
140
+
141
+ export function CNPGPublicationRenderer({
142
+ data,
143
+ onNavigate,
144
+ links,
145
+ }: {
146
+ data: any
147
+ onNavigate?: Nav
148
+ /** Resolved by the host: the PostgreSQL-side names below correspond to real
149
+ * CRs, and naming them without a route is a dead end. */
150
+ links?: { database?: React.ReactNode }
151
+ }) {
152
+ const spec = data?.spec ?? {}
153
+ const target = spec.target ?? {}
154
+ const scope = target.allTables
155
+ ? 'All tables'
156
+ : Array.isArray(target.objects)
157
+ ? `${target.objects.length} object${target.objects.length === 1 ? '' : 's'}`
158
+ : '-'
159
+ return (
160
+ <CNPGDeclarativeRenderer
161
+ data={data}
162
+ onNavigate={onNavigate}
163
+ details={[
164
+ { label: 'Publication', value: spec.name ?? '-' },
165
+ { label: 'In Database', value: links?.database ?? spec.dbname ?? '-' },
166
+ { label: 'Publishes', value: scope },
167
+ ]}
168
+ />
169
+ )
170
+ }
171
+
172
+ export function CNPGSubscriptionRenderer({
173
+ data,
174
+ onNavigate,
175
+ links,
176
+ }: {
177
+ data: any
178
+ onNavigate?: Nav
179
+ links?: { database?: React.ReactNode; publication?: React.ReactNode }
180
+ }) {
181
+ const spec = data?.spec ?? {}
182
+ return (
183
+ <CNPGDeclarativeRenderer
184
+ data={data}
185
+ onNavigate={onNavigate}
186
+ details={[
187
+ { label: 'Subscription', value: spec.name ?? '-' },
188
+ { label: 'In Database', value: links?.database ?? spec.dbname ?? '-' },
189
+ { label: 'Subscribes To', value: links?.publication ?? spec.publicationName ?? '-' },
190
+ // The upstream is declared on the destination Cluster, not here, which
191
+ // is why a missing one fails at apply time rather than at admission.
192
+ { label: 'Upstream Cluster', value: spec.externalClusterName ?? '-' },
193
+ ]}
194
+ />
195
+ )
196
+ }
197
+
198
+ /**
199
+ * ImageCatalog and ClusterImageCatalog — the PostgreSQL images a Cluster may
200
+ * use, pinned per major version.
201
+ *
202
+ * Worth its own surface because a Cluster referencing a catalog that lacks its
203
+ * major version reports "incomplete or invalid image catalog" and stops, and
204
+ * the catalog is where that is diagnosed.
205
+ */
206
+ export function CNPGImageCatalogRenderer({
207
+ data,
208
+ usedBy,
209
+ }: {
210
+ data: any
211
+ /** Filled by the host with the clusters pinned to this catalog — the question
212
+ * an operator has when a cluster reports an invalid catalog. */
213
+ usedBy?: React.ReactNode
214
+ }) {
215
+ const entries = getCNPGImageCatalogEntries(data)
216
+ return (
217
+ <>
218
+ <Section title={`Images (${entries.length})`} icon={Table2} defaultExpanded>
219
+ {entries.length === 0 ? (
220
+ <div className="flex items-start gap-2 text-sm text-theme-text-secondary">
221
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
222
+ <span>
223
+ No images are pinned. A cluster pointing at this catalog cannot resolve an image and will
224
+ not start.
225
+ </span>
226
+ </div>
227
+ ) : (
228
+ <div className="space-y-1.5">
229
+ {entries.map((e) => (
230
+ <div key={e.major} className="flex items-baseline gap-3 min-w-0">
231
+ <span className="badge shrink-0">{`PostgreSQL ${e.major}`}</span>
232
+ <span className="text-sm text-theme-text-secondary font-mono truncate" title={e.image}>
233
+ {e.image}
234
+ </span>
235
+ </div>
236
+ ))}
237
+ </div>
238
+ )}
239
+ </Section>
240
+ {usedBy}
241
+ </>
242
+ )
243
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { CNPGObjectStoreRenderer } from './CNPGObjectStoreRenderer'
4
+
5
+ /**
6
+ * A recovery window is keyed by the archive's server name. It defaults to the
7
+ * cluster's name, but the plugin's `serverName` parameter overrides it — the
8
+ * ObjectStore refuses the field outright and points at the Cluster — so the key
9
+ * and the cluster name are not interchangeable. A link is an assertion that
10
+ * something is on the other end of it.
11
+ */
12
+ const store = {
13
+ apiVersion: 'barmancloud.cnpg.io/v1',
14
+ kind: 'ObjectStore',
15
+ metadata: { name: 'pg-store', namespace: 'pg' },
16
+ spec: { configuration: { destinationPath: 's3://backups' } },
17
+ status: {
18
+ serverRecoveryWindow: {
19
+ 'pg-main': { firstRecoverabilityPoint: '2026-08-01T00:00:00Z', lastSuccessfulBackupTime: '2026-08-10T00:00:00Z' },
20
+ },
21
+ },
22
+ }
23
+
24
+ const nav = () => {}
25
+
26
+ describe('ObjectStore recovery-window server key', () => {
27
+ it('links the key to the cluster archiving under it', () => {
28
+ const html = renderToString(
29
+ <CNPGObjectStoreRenderer data={store} clusterForServer={new Map([['pg-main', 'pg-main']])} onNavigate={nav} />,
30
+ )
31
+ expect(html).toContain('>pg-main</button>')
32
+ })
33
+
34
+ // A key belonging to no cluster we found — an in-tree cluster, or one whose
35
+ // archive nothing here accounts for.
36
+ it('renders the key as text when nothing archives under it', () => {
37
+ const html = renderToString(
38
+ <CNPGObjectStoreRenderer data={store} clusterForServer={new Map([['other-server', 'other-cluster']])} onNavigate={nav} />,
39
+ )
40
+ expect(html).toContain('pg-main')
41
+ expect(html).not.toContain('>pg-main</button>')
42
+ })
43
+
44
+ // Absent means unresolved — still loading, lookup failed, or the host cannot
45
+ // ask. None of those establish that the cluster is there.
46
+ it('does not link when nothing resolved the key', () => {
47
+ const html = renderToString(<CNPGObjectStoreRenderer data={store} onNavigate={nav} />)
48
+ expect(html).toContain('pg-main')
49
+ expect(html).not.toContain('>pg-main</button>')
50
+ })
51
+
52
+ // The whole reason the mapping exists: the key is the archive's name, and a
53
+ // cluster that renamed its archive must still be reachable from it.
54
+ it('links a renamed archive to the cluster behind it, showing the archive name', () => {
55
+ const html = renderToString(
56
+ <CNPGObjectStoreRenderer
57
+ data={store}
58
+ clusterForServer={new Map([['pg-main', 'pg-primary']])}
59
+ onNavigate={nav}
60
+ />,
61
+ )
62
+ expect(html).toContain('>pg-main</button>')
63
+ expect(html).not.toContain('>pg-primary</button>')
64
+ })
65
+ })
66
+
67
+ // The recovery window is read before a restore, and it is derived from the
68
+ // ObjectStore alone — which cannot see that WAL archiving stopped on the cluster
69
+ // feeding it. Left to itself the row keeps a green "Recoverable" and a
70
+ // four-day-old successful backup while the recovery point has frozen, and the
71
+ // warning lives on a different screen entirely.
72
+ describe('a window that has stopped advancing', () => {
73
+ const store = {
74
+ metadata: { name: 'pg-store', namespace: 'pg' },
75
+ status: {
76
+ serverRecoveryWindow: {
77
+ 'pg-doomed': {
78
+ firstRecoverabilityPoint: '2026-07-29T02:00:00Z',
79
+ lastSuccessfulBackupTime: '2026-08-12T02:00:00Z',
80
+ },
81
+ },
82
+ },
83
+ }
84
+
85
+ it('does not call a frozen window recoverable', () => {
86
+ const html = renderToString(
87
+ <CNPGObjectStoreRenderer data={store} archivingFailing={new Set(['pg-doomed'])} />,
88
+ )
89
+ expect(html).toContain('Not advancing')
90
+ expect(html).not.toContain('>Recoverable<')
91
+ expect(html).toContain('WAL archiving has stopped')
92
+ })
93
+
94
+ it('keeps the plain answer when archiving is healthy', () => {
95
+ const html = renderToString(<CNPGObjectStoreRenderer data={store} />)
96
+ expect(html).toContain('Recoverable')
97
+ expect(html).not.toContain('WAL archiving has stopped')
98
+ })
99
+ })