@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.
- package/package.json +4 -4
- package/src/components/applications/ApplicationDetail.tsx +2 -2
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
- package/src/components/issues/severity.ts +1 -0
- package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
- package/src/components/resources/ResourcesView.tsx +419 -9
- package/src/components/resources/index.ts +27 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
- package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
- package/src/components/resources/renderers/CNPGShared.tsx +27 -0
- package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
- package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
- package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
- package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
- package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
- package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
- package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
- package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
- package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
- package/src/components/resources/renderers/PolicySection.tsx +32 -5
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
- package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
- package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
- package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
- package/src/components/resources/renderers/calico-cells.tsx +177 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
- package/src/components/resources/renderers/index.ts +16 -1
- package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
- package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
- package/src/components/resources/renderers/velero-cells.tsx +29 -7
- package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
- package/src/components/resources/resource-utils-calico.test.ts +215 -0
- package/src/components/resources/resource-utils-calico.ts +261 -0
- package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
- package/src/components/resources/resource-utils-cnpg.ts +264 -3
- package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
- package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
- package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
- package/src/components/resources/resource-utils-kyverno.ts +43 -7
- package/src/components/resources/resource-utils-velero.test.ts +26 -0
- package/src/components/resources/resource-utils-velero.ts +68 -0
- package/src/components/resources/resource-utils.ts +98 -0
- package/src/components/shared/ResourceActionsBar.tsx +101 -16
- package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
- package/src/components/shared/index.ts +1 -1
- package/src/components/shared/revision-role-badges.test.ts +120 -0
- package/src/components/topology/K8sResourceNode.test.ts +10 -0
- package/src/components/topology/K8sResourceNode.tsx +22 -0
- package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +6 -6
- package/src/components/topology/TopologySearch.tsx +7 -0
- package/src/components/topology/layout.ts +3 -0
- package/src/components/trace/ReachabilityView.tsx +1 -0
- package/src/components/trace/TracePanel.tsx +13 -2
- package/src/components/trace/reachFixtures.ts +26 -1
- package/src/components/trace/reachGraphModel.test.ts +20 -0
- package/src/components/trace/reachGraphModel.ts +8 -1
- package/src/components/trace/reachOrigins.test.ts +31 -0
- package/src/components/trace/reachOrigins.ts +11 -1
- package/src/components/ui/Badge.tsx +10 -0
- package/src/components/ui/InClusterConsentDialog.tsx +5 -4
- package/src/components/ui/ScheduleValue.test.tsx +80 -0
- package/src/components/ui/ScheduleValue.tsx +85 -0
- package/src/components/ui/SelectMenu.tsx +202 -28
- package/src/components/ui/drawer-components.test.tsx +17 -1
- package/src/components/ui/drawer-components.tsx +9 -2
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/WorkloadView.tsx +5 -2
- package/src/types/core.ts +36 -4
- package/src/types/policy.ts +153 -2
- package/src/utils/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +50 -0
- package/src/utils/extended-resources.ts +4 -7
- package/src/utils/format.test.ts +83 -1
- package/src/utils/format.ts +80 -1
- package/src/utils/inClusterConsent.test.ts +9 -0
- package/src/utils/inClusterConsent.ts +9 -9
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +5 -0
- package/src/utils/navigation.ts +5 -0
- package/src/utils/resource-icons.test.ts +49 -2
- 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
|
+
}
|