@skyhook-io/k8s-ui 1.12.0 → 1.13.3

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 (117) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.tsx +2 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
  4. package/src/components/issues/severity.ts +1 -0
  5. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  6. package/src/components/resources/ResourcesView.tsx +419 -9
  7. package/src/components/resources/index.ts +27 -0
  8. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  9. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  10. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  11. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  17. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  18. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  19. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  20. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  23. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  24. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  25. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  26. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  27. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  28. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  29. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  30. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  31. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  32. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  33. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  34. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  35. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  36. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  37. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  38. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  39. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  40. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  41. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  42. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  43. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  44. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  45. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  46. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  47. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  48. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  49. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  52. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  53. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  54. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  55. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  56. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  57. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  58. package/src/components/resources/renderers/index.ts +16 -1
  59. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  60. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  61. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  62. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  63. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  64. package/src/components/resources/resource-utils-calico.ts +261 -0
  65. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  66. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  67. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  68. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  69. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  70. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  71. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  72. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  73. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  74. package/src/components/resources/resource-utils-velero.ts +68 -0
  75. package/src/components/resources/resource-utils.ts +98 -0
  76. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  77. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  78. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  79. package/src/components/shared/index.ts +1 -1
  80. package/src/components/shared/revision-role-badges.test.ts +120 -0
  81. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  82. package/src/components/topology/K8sResourceNode.tsx +22 -0
  83. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  84. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  85. package/src/components/topology/TopologyGraph.tsx +6 -6
  86. package/src/components/topology/TopologySearch.tsx +7 -0
  87. package/src/components/topology/layout.ts +3 -0
  88. package/src/components/trace/ReachabilityView.tsx +1 -0
  89. package/src/components/trace/TracePanel.tsx +13 -2
  90. package/src/components/trace/reachFixtures.ts +26 -1
  91. package/src/components/trace/reachGraphModel.test.ts +20 -0
  92. package/src/components/trace/reachGraphModel.ts +8 -1
  93. package/src/components/trace/reachOrigins.test.ts +31 -0
  94. package/src/components/trace/reachOrigins.ts +11 -1
  95. package/src/components/ui/Badge.tsx +10 -0
  96. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  97. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  98. package/src/components/ui/ScheduleValue.tsx +85 -0
  99. package/src/components/ui/SelectMenu.tsx +202 -28
  100. package/src/components/ui/drawer-components.test.tsx +17 -1
  101. package/src/components/ui/drawer-components.tsx +9 -2
  102. package/src/components/ui/index.ts +2 -0
  103. package/src/components/workload/WorkloadView.tsx +5 -2
  104. package/src/types/core.ts +36 -4
  105. package/src/types/policy.ts +153 -2
  106. package/src/utils/clipboard.test.ts +86 -0
  107. package/src/utils/clipboard.ts +50 -0
  108. package/src/utils/extended-resources.ts +4 -7
  109. package/src/utils/format.test.ts +83 -1
  110. package/src/utils/format.ts +80 -1
  111. package/src/utils/inClusterConsent.test.ts +9 -0
  112. package/src/utils/inClusterConsent.ts +9 -9
  113. package/src/utils/index.ts +1 -0
  114. package/src/utils/navigation.test.ts +5 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/resource-icons.test.ts +49 -2
  117. 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'
@@ -22,15 +23,20 @@ import {
22
23
  getCNPGWALArchivingFailure,
23
24
  getCNPGLastBackupFailure,
24
25
  classifyCNPGClusterPhase,
26
+ CNPG_BARMAN_OBJECTSTORE_GROUP,
25
27
  } from '../resource-utils-cnpg'
26
28
  import { formatAge } from '../resource-utils'
29
+ import { RecoveryTime } from './CNPGShared'
27
30
 
28
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
29
35
  data: any
30
36
  onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
31
37
  }
32
38
 
33
- export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererProps) {
39
+ export function CNPGClusterRenderer({ data, onNavigate, declared}: CNPGClusterRendererProps) {
34
40
  const conditions = data.status?.conditions || []
35
41
  const instances = data.spec?.instances ?? 0
36
42
  // Presence matters — see getCNPGClusterStatus. A cluster whose status has no
@@ -230,6 +236,40 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
230
236
  <Property label="Target Primary" value={targetPrimary} />
231
237
  )}
232
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
+ )}
233
273
  <Property label="Update Strategy" value={getCNPGClusterUpdateStrategy(data)} />
234
274
  {data.status?.writeService && (
235
275
  <Property label="Write Service" value={
@@ -347,6 +387,7 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
347
387
  <ResourceLink
348
388
  name={backupConfig.plugin.barmanObjectName}
349
389
  kind="objectstores"
390
+ group={CNPG_BARMAN_OBJECTSTORE_GROUP}
350
391
  namespace={data.metadata?.namespace || ''}
351
392
  onNavigate={onNavigate}
352
393
  />
@@ -362,13 +403,13 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
362
403
  <Property label="Retention" value={backupConfig.retentionPolicy} />
363
404
  )}
364
405
  {backupConfig.lastSuccessfulBackup && (
365
- <Property label="Last Successful" value={backupConfig.lastSuccessfulBackup} />
406
+ <Property label="Last Successful" value={<RecoveryTime at={backupConfig.lastSuccessfulBackup} />} />
366
407
  )}
367
408
  {lastFailedBackup && (
368
- <Property label="Last Failed" value={lastFailedBackup} />
409
+ <Property label="Last Failed" value={<RecoveryTime at={lastFailedBackup} />} />
369
410
  )}
370
411
  {backupConfig.firstRecoverabilityPoint && (
371
- <Property label="First Recoverability" value={backupConfig.firstRecoverabilityPoint} />
412
+ <Property label="First Recoverability" value={<RecoveryTime at={backupConfig.firstRecoverabilityPoint} />} />
372
413
  )}
373
414
  </PropertyList>
374
415
  {/* Without this note the section renders empty on plugin-migrated
@@ -385,6 +426,8 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
385
426
  </Section>
386
427
  )}
387
428
 
429
+ {declared}
430
+
388
431
  {/* Certificates */}
389
432
  {certExpirations.length > 0 && (
390
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
+ })
@@ -0,0 +1,235 @@
1
+ import { Archive, Clock, HardDrive } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
+ import { HEALTH_BADGE_COLORS } from '../../../utils/badge-colors'
5
+ import { RecoveryTime } from './CNPGShared'
6
+ import {
7
+ getCNPGObjectStoreProvider,
8
+ getCNPGObjectStoreCredentialSecret,
9
+ getCNPGObjectStoreRecoveryWindows,
10
+ getCNPGObjectStoreDestination,
11
+ getCNPGObjectStoreRetention,
12
+ type CNPGObjectStoreRecoveryWindow,
13
+ } from '../resource-utils-cnpg'
14
+
15
+ /**
16
+ * ObjectStore (barmancloud.cnpg.io/v1) — where a plugin-backed CNPG Cluster's
17
+ * backups actually live.
18
+ *
19
+ * The Cluster page already tells operators the recovery window is tracked here,
20
+ * because a plugin-backed Cluster stops publishing recovery-point fields of its
21
+ * own. Until this renderer existed that sentence pointed at a page showing raw
22
+ * fields, so the restorable range was effectively unreachable in the product.
23
+ */
24
+
25
+ export function CNPGObjectStoreRenderer({
26
+ data,
27
+ clusterForServer,
28
+ archivingFailing,
29
+ onNavigate,
30
+ }: {
31
+ data: any
32
+ /** Server key -> the Cluster archiving under it, so a key is only linked when
33
+ * something is behind it. See RecoveryWindowRow. */
34
+ clusterForServer?: Map<string, string>
35
+ /** Servers whose Cluster reports WAL archiving stopped. The window keeps its
36
+ * last successful backup and reads as recoverable, while the recovery point
37
+ * has stopped advancing — a claim this status alone cannot correct. */
38
+ archivingFailing?: Set<string>
39
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
40
+ }) {
41
+ const windows = getCNPGObjectStoreRecoveryWindows(data)
42
+ const provider = getCNPGObjectStoreProvider(data)
43
+ const retention = getCNPGObjectStoreRetention(data)
44
+ const credentialSecret = getCNPGObjectStoreCredentialSecret(data)
45
+ const cfg = data?.spec?.configuration ?? {}
46
+ const failing = windows.filter((w) => w.failingSinceLastSuccess)
47
+
48
+ return (
49
+ <>
50
+ {failing.length > 0 && (
51
+ <AlertBanner
52
+ variant="error"
53
+ title={`Backups failing for ${failing.length === 1 ? failing[0].server : `${failing.length} servers`}`}
54
+ message="The most recent backup attempt failed after the last success, so the recoverable range has stopped moving forward."
55
+ />
56
+ )}
57
+
58
+ <Section title="Recovery Window" icon={Clock} defaultExpanded>
59
+ {windows.length === 0 ? (
60
+ // Configured but empty is NOT the same as healthy. Saying nothing here
61
+ // would read as "backups are fine" on a store holding nothing.
62
+ <div className="text-sm text-theme-text-secondary">
63
+ No server has reported a backup yet, so there is nothing to restore from this store.
64
+ </div>
65
+ ) : (
66
+ <div className="space-y-2">
67
+ {windows.map((w) => (
68
+ <RecoveryWindowRow
69
+ archivingFailing={archivingFailing}
70
+ clusterForServer={clusterForServer}
71
+ key={w.server}
72
+ window={w}
73
+ retention={retention}
74
+ namespace={data?.metadata?.namespace ?? ''}
75
+ onNavigate={onNavigate}
76
+ />
77
+ ))}
78
+ </div>
79
+ )}
80
+ </Section>
81
+
82
+ <Section title="Destination" icon={Archive} defaultExpanded>
83
+ <PropertyList>
84
+ <Property label="Path" value={getCNPGObjectStoreDestination(data)} />
85
+ {cfg.endpointURL && <Property label="Endpoint" value={cfg.endpointURL} />}
86
+ {/* Only when set — a custom CA is the difference between "cannot reach
87
+ the bucket" and "does not trust it", and it is otherwise invisible. */}
88
+ {(cfg.endpointCA?.name || cfg.endpointCA?.key) && (
89
+ <Property
90
+ label="Endpoint CA"
91
+ value={cfg.endpointCA?.name ? `${cfg.endpointCA.name}${cfg.endpointCA.key ? ` · ${cfg.endpointCA.key}` : ''}` : cfg.endpointCA.key}
92
+ />
93
+ )}
94
+ {provider && <Property label="Provider" value={provider} />}
95
+ {credentialSecret && (
96
+ <Property
97
+ label="Credentials"
98
+ value={
99
+ <ResourceLink
100
+ name={credentialSecret}
101
+ kind="secrets"
102
+ namespace={data?.metadata?.namespace ?? ''}
103
+ onNavigate={onNavigate}
104
+ />
105
+ }
106
+ />
107
+ )}
108
+ {cfg.serverName && <Property label="Server Name" value={cfg.serverName} />}
109
+ {retention && <Property label="Retention" value={retention} />}
110
+ </PropertyList>
111
+ </Section>
112
+
113
+ {(cfg.wal || cfg.data) && (
114
+ <Section title="Backup Settings" icon={HardDrive} defaultExpanded={false}>
115
+ <PropertyList>
116
+ {cfg.wal?.compression && <Property label="WAL Compression" value={cfg.wal.compression} />}
117
+ {cfg.wal?.encryption && <Property label="WAL Encryption" value={cfg.wal.encryption} />}
118
+ {cfg.wal?.maxParallel != null && (
119
+ <Property label="WAL Max Parallel" value={String(cfg.wal.maxParallel)} />
120
+ )}
121
+ {cfg.data?.compression && <Property label="Data Compression" value={cfg.data.compression} />}
122
+ {cfg.data?.encryption && <Property label="Data Encryption" value={cfg.data.encryption} />}
123
+ {cfg.data?.jobs != null && <Property label="Data Jobs" value={String(cfg.data.jobs)} />}
124
+ {cfg.data?.immediateCheckpoint != null && (
125
+ <Property label="Immediate Checkpoint" value={cfg.data.immediateCheckpoint ? 'Yes' : 'No'} />
126
+ )}
127
+ </PropertyList>
128
+ </Section>
129
+ )}
130
+ </>
131
+ )
132
+ }
133
+
134
+ /**
135
+ * One server's restorable range. The two timestamps are the answer to "how far
136
+ * back can I go, and how current is it" — the question the whole plugin path
137
+ * moved out of the Cluster.
138
+ */
139
+ function RecoveryWindowRow({
140
+ window: w,
141
+ retention,
142
+ namespace,
143
+ clusterForServer,
144
+ archivingFailing,
145
+ onNavigate,
146
+ }: {
147
+ window: CNPGObjectStoreRecoveryWindow
148
+ retention?: string
149
+ namespace: string
150
+ /** Server key -> the Cluster archiving under it. The key defaults to the
151
+ * cluster's name but the plugin's `serverName` parameter overrides it, so the
152
+ * two are not interchangeable and the mapping has to come from the plugin
153
+ * parameters rather than from string equality.
154
+ *
155
+ * Absent means unresolved, not "assume it resolves": the lookup may still be
156
+ * loading, it may have failed, or the host may not be able to ask. All three
157
+ * render the key as text, which is the part that is always true. */
158
+ clusterForServer?: Map<string, string>
159
+ /** Servers whose Cluster reports WAL archiving stopped. */
160
+ archivingFailing?: Set<string>
161
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
162
+ }) {
163
+ const stalled = archivingFailing?.has(w.server)
164
+ const tone = w.failingSinceLastSuccess || stalled
165
+ ? 'degraded'
166
+ : w.lastSuccessfulBackupTime
167
+ ? 'healthy'
168
+ : 'unknown'
169
+ return (
170
+ <div className="card-inner">
171
+ <div className="flex items-center gap-2 mb-1">
172
+ {/* The server key is usually the Cluster this window belongs to, and
173
+ naming it without a route leaves the reader knowing a cluster is in
174
+ trouble and unable to open it. Only usually, though — see
175
+ `clusterNames`. */}
176
+ <span className="text-sm font-medium text-theme-text-primary">
177
+ {clusterForServer?.get(w.server) ? (
178
+ <ResourceLink
179
+ name={clusterForServer.get(w.server) as string}
180
+ label={w.server}
181
+ kind="clusters"
182
+ namespace={namespace}
183
+ group="postgresql.cnpg.io"
184
+ onNavigate={onNavigate}
185
+ />
186
+ ) : (
187
+ w.server
188
+ )}
189
+ </span>
190
+ <span className={clsx('badge', HEALTH_BADGE_COLORS[tone as keyof typeof HEALTH_BADGE_COLORS])}>
191
+ {w.failingSinceLastSuccess
192
+ ? 'Backups failing'
193
+ : stalled
194
+ ? 'Not advancing'
195
+ : w.lastSuccessfulBackupTime
196
+ ? 'Recoverable'
197
+ : 'No backups yet'}
198
+ </span>
199
+ </div>
200
+ {stalled && !w.failingSinceLastSuccess && (
201
+ <div className="text-xs text-warning-text mb-1">
202
+ {/* The timestamps below are real and still describe the last backup that
203
+ worked. What they no longer describe is a window still growing, and
204
+ "Recoverable" alone invites exactly that reading on the screen
205
+ someone checks before a restore. */}
206
+ WAL archiving has stopped on the cluster behind this server, so the times below are the
207
+ last ones recorded rather than a window still advancing.
208
+ </div>
209
+ )}
210
+ <PropertyList>
211
+ {w.firstRecoverabilityPoint && (
212
+ <Property
213
+ label="Restorable From"
214
+ value={<RecoveryTime at={w.firstRecoverabilityPoint} />}
215
+ />
216
+ )}
217
+ {w.lastSuccessfulBackupTime ? (
218
+ <Property label="Last Successful Backup" value={<RecoveryTime at={w.lastSuccessfulBackupTime} />} />
219
+ ) : (
220
+ <Property label="Last Successful Backup" value="never" />
221
+ )}
222
+ {w.lastFailedBackupTime && (
223
+ <Property label="Last Failed Attempt" value={<RecoveryTime at={w.lastFailedBackupTime} />} />
224
+ )}
225
+ {retention && <Property label="Retention" value={retention} />}
226
+ </PropertyList>
227
+ {w.failingSinceLastSuccess && (
228
+ <div className="mt-2 pt-2 border-t border-theme-border text-xs text-theme-text-secondary">
229
+ Every backup since the last success has failed. The oldest restorable point will still age
230
+ out under the retention policy, so the window is shrinking from both ends.
231
+ </div>
232
+ )}
233
+ </div>
234
+ )
235
+ }