@skyhook-io/k8s-ui 1.10.4 → 1.11.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.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +259 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
- package/src/components/resources/resource-utils-cnpg.ts +373 -56
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +11 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +517 -0
- package/src/components/trace/ReachabilityView.tsx +1173 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1423 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +892 -0
- package/src/components/trace/reachInspector.ts +797 -0
- package/src/components/trace/reachMarks.test.ts +698 -0
- package/src/components/trace/reachMarks.ts +623 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +449 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +414 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +64 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
- package/src/utils/resource-icons.test.ts +33 -0
- package/src/utils/resource-icons.ts +65 -2
|
@@ -8,49 +8,222 @@ import { parseGoTimeString } from '../../utils/parse-go-time'
|
|
|
8
8
|
// CNPG CLUSTER UTILITIES
|
|
9
9
|
// ============================================================================
|
|
10
10
|
|
|
11
|
+
export const CNPG_GROUP = 'postgresql.cnpg.io'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Exact API-group match for a resource's `apiVersion` ("group/version").
|
|
15
|
+
*
|
|
16
|
+
* Used for the `clusters` and `backups` plurals, where several operators ship
|
|
17
|
+
* the same kind and a wrong guess fabricates a status. A substring test is not
|
|
18
|
+
* a group guard — `extension.postgresql.cnpg.io/v1` contains `postgresql.cnpg.io`
|
|
19
|
+
* — and these are exactly the plurals where that would misfire.
|
|
20
|
+
*/
|
|
21
|
+
export function isApiGroup(apiVersion: unknown, group: string): boolean {
|
|
22
|
+
if (typeof apiVersion !== 'string') return false
|
|
23
|
+
const slash = apiVersion.lastIndexOf('/')
|
|
24
|
+
// A core-group resource ("v1") has no slash and belongs to no CRD group.
|
|
25
|
+
return slash > 0 && apiVersion.slice(0, slash) === group
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Phase strings are full English sentences copied verbatim from CNPG's
|
|
29
|
+
// api/v1/cluster_types.go — match on equality, never substring. Verified
|
|
30
|
+
// against CNPG 1.27. Radar's Go issue detection mirrors these buckets in
|
|
31
|
+
// internal/issues/source_cnpg.go; the two must not drift, or a cluster gets
|
|
32
|
+
// a red badge and no issue (or the reverse).
|
|
33
|
+
|
|
34
|
+
/** Cluster is reconciled and serving. */
|
|
35
|
+
export const CNPG_CLUSTER_PHASES_HEALTHY = ['Cluster in healthy state'] as const
|
|
36
|
+
|
|
37
|
+
/** Operator is mid-operation. Expected to resolve on its own. */
|
|
38
|
+
export const CNPG_CLUSTER_PHASES_TRANSIENT = [
|
|
39
|
+
'Setting up primary',
|
|
40
|
+
'Creating a new replica',
|
|
41
|
+
'Switchover in progress',
|
|
42
|
+
'Upgrading cluster',
|
|
43
|
+
'Upgrading Postgres major version',
|
|
44
|
+
'Online upgrade in progress',
|
|
45
|
+
'Applying configuration',
|
|
46
|
+
'Primary instance is being restarted in-place',
|
|
47
|
+
'Primary instance is being restarted without a switchover',
|
|
48
|
+
'Promoting to primary cluster',
|
|
49
|
+
'Waiting for the instances to become active',
|
|
50
|
+
// Upstream sets this when an upgrade is postponed by the OPERATOR's own
|
|
51
|
+
// rollout-delay config and requeued — nothing is waiting on a human, so it
|
|
52
|
+
// does not belong with 'Waiting for user action'.
|
|
53
|
+
'Cluster upgrade delayed',
|
|
54
|
+
] as const
|
|
55
|
+
|
|
56
|
+
/** Losing the primary — degraded now, self-resolving only if a replica can take over. */
|
|
57
|
+
export const CNPG_CLUSTER_PHASES_FAILING = ['Failing over'] as const
|
|
58
|
+
|
|
59
|
+
/** Reconciliation has stopped. These need a human; none of them self-heal. */
|
|
60
|
+
export const CNPG_CLUSTER_PHASES_TERMINAL = [
|
|
61
|
+
'Cluster is unrecoverable and needs manual intervention',
|
|
62
|
+
// Not in 1.27 or 1.28; added upstream after 1.28 (PhaseDefinitionInvalid).
|
|
63
|
+
// Listed early because matching is by equality — a phase string the running
|
|
64
|
+
// operator never emits is inert, so carrying it costs nothing and stops a
|
|
65
|
+
// future minor from landing in the unknown bucket.
|
|
66
|
+
'Invalid cluster definition',
|
|
67
|
+
'Unable to create required cluster objects',
|
|
68
|
+
'Cluster has incomplete or invalid image catalog',
|
|
69
|
+
'Cluster cannot proceed to reconciliation due to an unknown plugin being required',
|
|
70
|
+
'Cluster cannot proceed to reconciliation due to an error while interacting with plugins',
|
|
71
|
+
'Cluster cannot execute instance online upgrade due to missing architecture binary',
|
|
72
|
+
] as const
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stalled waiting on a human. Under `primaryUpdateStrategy: supervised` this is
|
|
76
|
+
* the documented, expected resting state, so it is styled as attention rather
|
|
77
|
+
* than failure and the Go side suppresses the issue entirely.
|
|
78
|
+
*/
|
|
79
|
+
export const CNPG_CLUSTER_PHASES_ATTENTION = [
|
|
80
|
+
'Waiting for user action',
|
|
81
|
+
] as const
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Short display states for the table badge.
|
|
85
|
+
*
|
|
86
|
+
* CNPG's phases are prose, not tokens — "Cluster is unrecoverable and needs
|
|
87
|
+
* manual intervention" is banner copy. At 315px it fits no defensible column,
|
|
88
|
+
* so this is about badge semantics rather than width: a badge carries a state,
|
|
89
|
+
* the drawer and the `title` carry the operator's exact words.
|
|
90
|
+
*
|
|
91
|
+
* Radar's own labels (Healthy, WAL Archiving Failing, …) are not in here —
|
|
92
|
+
* those are already states and are produced directly by getCNPGClusterStatus.
|
|
93
|
+
*
|
|
94
|
+
* Both in-place and without-a-switchover restarts collapse to "Restarting
|
|
95
|
+
* Primary". The distinction is about the operator's mechanism, not about what a
|
|
96
|
+
* scanning operator needs; the exact phase remains in the drawer. Splitting
|
|
97
|
+
* them honestly would need ~140px and a wider column than the rest earns.
|
|
98
|
+
*/
|
|
99
|
+
const CNPG_PHASE_DISPLAY_STATE: Record<string, string> = {
|
|
100
|
+
// Terminal
|
|
101
|
+
'Cluster is unrecoverable and needs manual intervention': 'Unrecoverable',
|
|
102
|
+
'Invalid cluster definition': 'Invalid Spec',
|
|
103
|
+
'Unable to create required cluster objects': 'Setup Failed',
|
|
104
|
+
'Cluster has incomplete or invalid image catalog': 'Image Catalog Error',
|
|
105
|
+
'Cluster cannot proceed to reconciliation due to an unknown plugin being required': 'Unknown Plugin',
|
|
106
|
+
'Cluster cannot proceed to reconciliation due to an error while interacting with plugins': 'Plugin Error',
|
|
107
|
+
'Cluster cannot execute instance online upgrade due to missing architecture binary': 'Arch Binary Missing',
|
|
108
|
+
// Transient
|
|
109
|
+
'Setting up primary': 'Setting Up Primary',
|
|
110
|
+
'Creating a new replica': 'Creating Replica',
|
|
111
|
+
'Switchover in progress': 'Switchover',
|
|
112
|
+
'Upgrading cluster': 'Upgrading',
|
|
113
|
+
'Upgrading Postgres major version': 'Major Upgrade',
|
|
114
|
+
'Online upgrade in progress': 'Online Upgrade',
|
|
115
|
+
'Applying configuration': 'Configuring',
|
|
116
|
+
'Primary instance is being restarted in-place': 'Restarting Primary',
|
|
117
|
+
'Primary instance is being restarted without a switchover': 'Restarting Primary',
|
|
118
|
+
'Promoting to primary cluster': 'Promoting',
|
|
119
|
+
'Waiting for the instances to become active': 'Starting Instances',
|
|
120
|
+
'Cluster upgrade delayed': 'Upgrade Delayed',
|
|
121
|
+
// Attention
|
|
122
|
+
'Waiting for user action': 'Needs Action',
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The short state for a phase, or the phase itself when we have no mapping.
|
|
127
|
+
*
|
|
128
|
+
* An unrecognized phase from a newer CNPG minor passes through verbatim —
|
|
129
|
+
* inventing a state for a string we have never seen would be worse than showing
|
|
130
|
+
* the operator's words, and that is the case badge truncation exists for.
|
|
131
|
+
*/
|
|
132
|
+
export function getCNPGClusterDisplayState(phase: string): string {
|
|
133
|
+
return CNPG_PHASE_DISPLAY_STATE[phase] || phase
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type CNPGPhaseBucket = 'healthy' | 'transient' | 'failing' | 'terminal' | 'attention' | 'unknown'
|
|
137
|
+
|
|
138
|
+
export function classifyCNPGClusterPhase(phase: string): CNPGPhaseBucket {
|
|
139
|
+
if (!phase) return 'unknown'
|
|
140
|
+
if ((CNPG_CLUSTER_PHASES_HEALTHY as readonly string[]).includes(phase)) return 'healthy'
|
|
141
|
+
if ((CNPG_CLUSTER_PHASES_TERMINAL as readonly string[]).includes(phase)) return 'terminal'
|
|
142
|
+
if ((CNPG_CLUSTER_PHASES_FAILING as readonly string[]).includes(phase)) return 'failing'
|
|
143
|
+
if ((CNPG_CLUSTER_PHASES_TRANSIENT as readonly string[]).includes(phase)) return 'transient'
|
|
144
|
+
if ((CNPG_CLUSTER_PHASES_ATTENTION as readonly string[]).includes(phase)) return 'attention'
|
|
145
|
+
return 'unknown'
|
|
146
|
+
}
|
|
147
|
+
|
|
11
148
|
export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
12
149
|
const status = resource.status || {}
|
|
13
150
|
const phase = status.phase || ''
|
|
14
151
|
const instances = resource.spec?.instances ?? 0
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
152
|
+
// Readiness PRESENCE matters. A cluster whose status has no readyInstances yet
|
|
153
|
+
// (freshly created, operator hasn't written status) is unknown, not zero —
|
|
154
|
+
// defaulting to 0 fabricated a red "Not Ready" badge for a cluster the issue
|
|
155
|
+
// detector correctly said nothing about.
|
|
156
|
+
const readyKnown = typeof status.readyInstances === 'number'
|
|
157
|
+
const readyInstances: number = readyKnown ? status.readyInstances : 0
|
|
158
|
+
const countsKnown = instances > 0 && readyKnown
|
|
159
|
+
const bucket = classifyCNPGClusterPhase(phase)
|
|
160
|
+
|
|
161
|
+
// Ordered by descending severity, and mirrored by source_cnpg.go. Terminal
|
|
162
|
+
// phases outrank instance counts: an unrecoverable cluster whose pods happen
|
|
163
|
+
// to still be Ready is still unrecoverable, and reading the counts first is
|
|
164
|
+
// what used to render it neutral.
|
|
165
|
+
if (bucket === 'terminal') {
|
|
166
|
+
return { text: getCNPGClusterDisplayState(phase), color: healthColors.unhealthy, level: 'unhealthy' }
|
|
24
167
|
}
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
'
|
|
30
|
-
'Creating replica',
|
|
31
|
-
'Switchover in progress',
|
|
32
|
-
'Upgrading cluster',
|
|
33
|
-
'Online upgrade in progress',
|
|
34
|
-
]
|
|
35
|
-
const unhealthyPhases = [
|
|
36
|
-
'Failing over',
|
|
37
|
-
'Failing over (streaming)',
|
|
38
|
-
'Failing over (designated primary)',
|
|
39
|
-
]
|
|
40
|
-
|
|
41
|
-
if (healthyPhases.some(p => phase.includes(p))) {
|
|
42
|
-
return { text: 'Healthy', color: healthColors.healthy, level: 'healthy' }
|
|
168
|
+
// Zero ready instances is a hard down that no phase excuses — including a
|
|
169
|
+
// failover, where the phase alone would otherwise downgrade a total outage to
|
|
170
|
+
// orange.
|
|
171
|
+
if (countsKnown && readyInstances === 0) {
|
|
172
|
+
return { text: 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
43
173
|
}
|
|
44
|
-
if (
|
|
45
|
-
return { text: 'Failing Over', color: healthColors.
|
|
174
|
+
if (bucket === 'failing') {
|
|
175
|
+
return { text: 'Failing Over', color: healthColors.alert, level: 'alert' }
|
|
46
176
|
}
|
|
47
|
-
|
|
48
|
-
|
|
177
|
+
// Data-durability failures don't move any instance count, so a cluster with a
|
|
178
|
+
// broken recovery point looks perfectly healthy on availability alone — which
|
|
179
|
+
// is precisely how it stays invisible until a restore. The detail drawer has
|
|
180
|
+
// always shown this; the list badge must too, or the headline signal of this
|
|
181
|
+
// integration reads green.
|
|
182
|
+
if (getCNPGWALArchivingFailure(resource)) {
|
|
183
|
+
return { text: 'WAL Archiving Failing', color: healthColors.alert, level: 'alert' }
|
|
184
|
+
}
|
|
185
|
+
// A transient phase explains partial readiness better than a bare "Degraded",
|
|
186
|
+
// so it is checked BEFORE the shortfall — mid-operation the cluster is
|
|
187
|
+
// legitimately below its desired count.
|
|
188
|
+
if (bucket === 'transient' || bucket === 'attention') {
|
|
189
|
+
return { text: getCNPGClusterDisplayState(phase), color: healthColors.degraded, level: 'degraded' }
|
|
190
|
+
}
|
|
191
|
+
// The shortfall must be checked BEFORE the healthy phase, not after. CNPG
|
|
192
|
+
// reports "Cluster in healthy state" the moment reconciliation is settled,
|
|
193
|
+
// which can be true while instances are still missing — returning Healthy
|
|
194
|
+
// there paints a green badge on a cluster the Go detector flags as
|
|
195
|
+
// CNPGClusterDegraded. Mirrors source_cnpg.go's !phaseExplained gate.
|
|
196
|
+
if (countsKnown && readyInstances < instances) {
|
|
197
|
+
return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
|
|
198
|
+
}
|
|
199
|
+
if (getCNPGLastBackupFailure(resource)) {
|
|
200
|
+
return { text: 'Backup Failed', color: healthColors.degraded, level: 'degraded' }
|
|
201
|
+
}
|
|
202
|
+
if (bucket === 'healthy') {
|
|
203
|
+
return { text: 'Healthy', color: healthColors.healthy, level: 'healthy' }
|
|
49
204
|
}
|
|
50
205
|
|
|
51
|
-
// Conditions fallback
|
|
52
206
|
const conditions = status.conditions || []
|
|
53
207
|
const readyCond = conditions.find((c: any) => c.type === 'Ready')
|
|
208
|
+
|
|
209
|
+
// Unrecognized phase — surface the operator's own words rather than inventing
|
|
210
|
+
// a health level. A new CNPG minor adding a phase lands here, not in "Healthy".
|
|
211
|
+
//
|
|
212
|
+
// This must outrank the Ready condition below. Ready=True says the instances
|
|
213
|
+
// are serving; it does not say the cluster is fine, and CNPG sets it during
|
|
214
|
+
// phases we have never seen. Reading it first turned an unknown phase into a
|
|
215
|
+
// green "Ready" badge — asserting health from a signal that doesn't establish
|
|
216
|
+
// it, and defeating the pass-through this branch exists for. Ready=False
|
|
217
|
+
// still colors the phase red: showing the operator's words in red loses
|
|
218
|
+
// nothing and keeps a real failure signal.
|
|
219
|
+
if (phase) {
|
|
220
|
+
if (readyCond?.status === 'False') {
|
|
221
|
+
return { text: phase, color: healthColors.unhealthy, level: 'unhealthy' }
|
|
222
|
+
}
|
|
223
|
+
return { text: phase, color: healthColors.unknown, level: 'unknown' }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// No phase at all — conditions are the only signal left.
|
|
54
227
|
if (readyCond?.status === 'True') {
|
|
55
228
|
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
56
229
|
}
|
|
@@ -58,17 +231,17 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
|
58
231
|
return { text: readyCond.reason || 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
59
232
|
}
|
|
60
233
|
|
|
61
|
-
if (phase) {
|
|
62
|
-
return { text: phase, color: healthColors.unknown, level: 'unknown' }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
234
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
66
235
|
}
|
|
67
236
|
|
|
237
|
+
// Absence is not zero — same rule the badge follows via readyKnown. Between
|
|
238
|
+
// creation and the operator's first status write there is no readyInstances,
|
|
239
|
+
// and defaulting it to 0 rendered "0/3" next to a badge that (correctly) did
|
|
240
|
+
// not say degraded: the cell claimed a total outage the badge denied.
|
|
241
|
+
const countOrDash = (n: unknown): string => (typeof n === 'number' ? String(n) : '-')
|
|
242
|
+
|
|
68
243
|
export function getCNPGClusterInstances(resource: any): string {
|
|
69
|
-
|
|
70
|
-
const ready = resource.status?.readyInstances ?? 0
|
|
71
|
-
return `${ready}/${desired}`
|
|
244
|
+
return `${countOrDash(resource.status?.readyInstances)}/${countOrDash(resource.spec?.instances)}`
|
|
72
245
|
}
|
|
73
246
|
|
|
74
247
|
export function getCNPGClusterPrimary(resource: any): string {
|
|
@@ -118,21 +291,66 @@ export function getCNPGClusterUpdateStrategy(resource: any): string {
|
|
|
118
291
|
return resource.spec?.primaryUpdateStrategy || 'unsupervised'
|
|
119
292
|
}
|
|
120
293
|
|
|
294
|
+
export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
|
|
295
|
+
|
|
296
|
+
export interface CNPGBarmanPlugin {
|
|
297
|
+
name: string
|
|
298
|
+
/** ObjectStore CR (barmancloud.cnpg.io/v1) holding the real destination + recovery window. */
|
|
299
|
+
barmanObjectName?: string
|
|
300
|
+
/** Resolved server key inside ObjectStore.status.serverRecoveryWindow. */
|
|
301
|
+
serverName?: string
|
|
302
|
+
isWALArchiver: boolean
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Detects the barman-cloud CNPG-I plugin. In-tree `spec.backup.barmanObjectStore`
|
|
307
|
+
* is deprecated as of CNPG 1.26 and the migration moves config into an
|
|
308
|
+
* `ObjectStore` CR in a different API group — at which point CNPG stops setting
|
|
309
|
+
* status.lastSuccessfulBackup / firstRecoverabilityPoint entirely.
|
|
310
|
+
*/
|
|
311
|
+
export function getCNPGClusterBarmanPlugin(resource: any): CNPGBarmanPlugin | null {
|
|
312
|
+
const plugins = resource.spec?.plugins
|
|
313
|
+
if (!Array.isArray(plugins)) return null
|
|
314
|
+
const plugin = plugins.find(
|
|
315
|
+
(p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.enabled !== false
|
|
316
|
+
)
|
|
317
|
+
if (!plugin) return null
|
|
318
|
+
return {
|
|
319
|
+
name: plugin.name,
|
|
320
|
+
barmanObjectName: plugin.parameters?.barmanObjectName,
|
|
321
|
+
// ObjectStore keys the recovery window by the plugin's serverName when set,
|
|
322
|
+
// otherwise by the cluster name.
|
|
323
|
+
serverName: plugin.parameters?.serverName || resource.metadata?.name,
|
|
324
|
+
isWALArchiver: plugin.isWALArchiver === true,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
121
328
|
export function getCNPGClusterBackupConfig(resource: any): {
|
|
122
329
|
configured: boolean
|
|
123
330
|
destinationPath?: string
|
|
124
331
|
retentionPolicy?: string
|
|
125
332
|
lastSuccessfulBackup?: string
|
|
126
333
|
firstRecoverabilityPoint?: string
|
|
334
|
+
/** Non-null when backup is managed by the barman-cloud plugin. */
|
|
335
|
+
plugin: CNPGBarmanPlugin | null
|
|
336
|
+
/**
|
|
337
|
+
* True when the status RPO fields are deprecated-and-unset by design (plugin
|
|
338
|
+
* mode) rather than genuinely absent. Distinguishes "tracked elsewhere" from
|
|
339
|
+
* "no backups configured" — the ambiguity that made this section render blank.
|
|
340
|
+
*/
|
|
341
|
+
rpoTrackedOnObjectStore: boolean
|
|
127
342
|
} {
|
|
128
343
|
const backup = resource.spec?.backup
|
|
129
344
|
const barman = backup?.barmanObjectStore
|
|
345
|
+
const plugin = getCNPGClusterBarmanPlugin(resource)
|
|
130
346
|
return {
|
|
131
|
-
configured: !!barman,
|
|
347
|
+
configured: !!barman || !!plugin,
|
|
132
348
|
destinationPath: barman?.destinationPath,
|
|
133
349
|
retentionPolicy: backup?.retentionPolicy,
|
|
134
350
|
lastSuccessfulBackup: resource.status?.lastSuccessfulBackup,
|
|
135
351
|
firstRecoverabilityPoint: resource.status?.firstRecoverabilityPoint,
|
|
352
|
+
plugin,
|
|
353
|
+
rpoTrackedOnObjectStore: !!plugin && !resource.status?.lastSuccessfulBackup,
|
|
136
354
|
}
|
|
137
355
|
}
|
|
138
356
|
|
|
@@ -172,6 +390,7 @@ export interface CNPGInstanceReportedState {
|
|
|
172
390
|
podName: string
|
|
173
391
|
isPrimary: boolean
|
|
174
392
|
timelineID?: number
|
|
393
|
+
ip?: string
|
|
175
394
|
}
|
|
176
395
|
|
|
177
396
|
export function getCNPGClusterInstancesReportedState(resource: any): CNPGInstanceReportedState[] {
|
|
@@ -180,7 +399,9 @@ export function getCNPGClusterInstancesReportedState(resource: any): CNPGInstanc
|
|
|
180
399
|
return Object.entries(reported).map(([podName, state]: [string, any]) => ({
|
|
181
400
|
podName,
|
|
182
401
|
isPrimary: state?.isPrimary === true,
|
|
183
|
-
|
|
402
|
+
// CNPG serializes this as timeLineID (capital L).
|
|
403
|
+
timelineID: state?.timeLineID,
|
|
404
|
+
ip: state?.ip,
|
|
184
405
|
}))
|
|
185
406
|
}
|
|
186
407
|
|
|
@@ -204,22 +425,98 @@ export function getCNPGClusterCertificateExpirations(resource: any): CNPGCertifi
|
|
|
204
425
|
})
|
|
205
426
|
}
|
|
206
427
|
|
|
428
|
+
// ============================================================================
|
|
429
|
+
// CNPG CLUSTER CONDITIONS — WAL ARCHIVING / BACKUP HEALTH
|
|
430
|
+
// ============================================================================
|
|
431
|
+
|
|
432
|
+
export interface CNPGCondition {
|
|
433
|
+
type: string
|
|
434
|
+
status: string
|
|
435
|
+
reason?: string
|
|
436
|
+
message?: string
|
|
437
|
+
lastTransitionTime?: string
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function getCNPGClusterCondition(resource: any, type: string): CNPGCondition | null {
|
|
441
|
+
const conditions = resource.status?.conditions
|
|
442
|
+
if (!Array.isArray(conditions)) return null
|
|
443
|
+
const cond = conditions.find((c: any) => c?.type === type)
|
|
444
|
+
if (!cond) return null
|
|
445
|
+
return {
|
|
446
|
+
type: cond.type,
|
|
447
|
+
status: String(cond.status ?? ''),
|
|
448
|
+
reason: cond.reason,
|
|
449
|
+
message: cond.message,
|
|
450
|
+
lastTransitionTime: cond.lastTransitionTime,
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* WAL archiving failure — the classic silent CNPG incident. The cluster keeps
|
|
456
|
+
* serving traffic normally while the recovery point stops advancing.
|
|
457
|
+
*
|
|
458
|
+
* The condition proves the LAST ARCHIVE ATTEMPT failed, not an exact RPO, so
|
|
459
|
+
* callers must not turn `since` into a data-loss window.
|
|
460
|
+
*/
|
|
461
|
+
export function getCNPGWALArchivingFailure(resource: any): CNPGCondition | null {
|
|
462
|
+
const cond = getCNPGClusterCondition(resource, 'ContinuousArchiving')
|
|
463
|
+
if (!cond || cond.status !== 'False') return null
|
|
464
|
+
return cond
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Last-backup failure. CNPG also sets LastBackupSucceeded=False with reason
|
|
469
|
+
* `BackupStarted` while a backup is merely in flight — treating that as a
|
|
470
|
+
* failure would light the banner on every backup run, so only an explicit
|
|
471
|
+
* LastBackupFailed reason counts.
|
|
472
|
+
*/
|
|
473
|
+
export function getCNPGLastBackupFailure(resource: any): CNPGCondition | null {
|
|
474
|
+
const cond = getCNPGClusterCondition(resource, 'LastBackupSucceeded')
|
|
475
|
+
if (!cond || cond.status !== 'False') return null
|
|
476
|
+
if (cond.reason === 'BackupStarted') return null
|
|
477
|
+
return cond
|
|
478
|
+
}
|
|
479
|
+
|
|
207
480
|
// ============================================================================
|
|
208
481
|
// CNPG BACKUP UTILITIES
|
|
209
482
|
// ============================================================================
|
|
210
483
|
|
|
484
|
+
// Backup phases are lowercase tokens from CNPG's api/v1/backup_types.go,
|
|
485
|
+
// verified against CNPG 1.27. Unlike cluster phases these are stable enum
|
|
486
|
+
// tokens, but they are still matched exactly — an unrecognized phase must
|
|
487
|
+
// surface as unknown rather than be swallowed by a prefix match.
|
|
488
|
+
export const CNPG_BACKUP_PHASES_TRANSIENT = ['pending', 'started', 'running', 'finalizing'] as const
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* WAL archiving is broken upstream of this Backup, so the recovery window for
|
|
492
|
+
* the whole cluster is affected — not just this one object. Kept separate from
|
|
493
|
+
* a plain `failed` so callers can escalate it to the cluster level.
|
|
494
|
+
*/
|
|
495
|
+
export const CNPG_BACKUP_PHASE_WAL_ARCHIVING_FAILING = 'walArchivingFailing'
|
|
496
|
+
|
|
497
|
+
export type CNPGBackupPhaseBucket = 'healthy' | 'transient' | 'failed' | 'walArchivingFailing' | 'unknown'
|
|
498
|
+
|
|
499
|
+
export function classifyCNPGBackupPhase(phase: string): CNPGBackupPhaseBucket {
|
|
500
|
+
if (!phase) return 'unknown'
|
|
501
|
+
if (phase === CNPG_BACKUP_PHASE_WAL_ARCHIVING_FAILING) return 'walArchivingFailing'
|
|
502
|
+
if (phase === 'completed') return 'healthy'
|
|
503
|
+
if (phase === 'failed') return 'failed'
|
|
504
|
+
if ((CNPG_BACKUP_PHASES_TRANSIENT as readonly string[]).includes(phase)) return 'transient'
|
|
505
|
+
return 'unknown'
|
|
506
|
+
}
|
|
507
|
+
|
|
211
508
|
export function getCNPGBackupStatus(resource: any): StatusBadge {
|
|
212
509
|
const phase = resource.status?.phase || ''
|
|
213
510
|
|
|
214
|
-
switch (phase
|
|
215
|
-
case '
|
|
511
|
+
switch (classifyCNPGBackupPhase(phase)) {
|
|
512
|
+
case 'healthy':
|
|
216
513
|
return { text: 'Completed', color: healthColors.healthy, level: 'healthy' }
|
|
217
|
-
case '
|
|
218
|
-
return { text:
|
|
219
|
-
case 'pending':
|
|
220
|
-
return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
|
|
514
|
+
case 'transient':
|
|
515
|
+
return { text: phase.charAt(0).toUpperCase() + phase.slice(1), color: healthColors.degraded, level: 'degraded' }
|
|
221
516
|
case 'failed':
|
|
222
517
|
return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
518
|
+
case 'walArchivingFailing':
|
|
519
|
+
return { text: 'WAL Archiving Failing', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
223
520
|
default:
|
|
224
521
|
if (phase) return { text: phase, color: healthColors.unknown, level: 'unknown' }
|
|
225
522
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
@@ -341,24 +638,46 @@ export function getCNPGScheduledBackupOwnerRef(resource: any): string {
|
|
|
341
638
|
// CNPG POOLER UTILITIES
|
|
342
639
|
// ============================================================================
|
|
343
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Pooler.status.instances is "the number of pods trying to be scheduled" — NOT
|
|
643
|
+
* ready pods. A Pooler whose PgBouncer pods are all Pending still reports
|
|
644
|
+
* status.instances == spec.instances, so reading it as readiness renders a
|
|
645
|
+
* broken Pooler green. Readiness lives on the generated Deployment (same name,
|
|
646
|
+
* same namespace), which the Pooler CR alone cannot see — so the positive state
|
|
647
|
+
* is labelled "Scheduled", not "Ready".
|
|
648
|
+
*/
|
|
344
649
|
export function getCNPGPoolerStatus(resource: any): StatusBadge {
|
|
345
650
|
const desired = resource.spec?.instances ?? 0
|
|
346
|
-
|
|
651
|
+
// Presence, not value — the same distinction getCNPGClusterStatus makes. A
|
|
652
|
+
// Pooler the controller has not reconciled yet has no status.instances at
|
|
653
|
+
// all, and reading that as 0 rendered a red "Not Scheduled" on a Pooler that
|
|
654
|
+
// may be perfectly healthy: a fabricated failure, not merely a wrong count.
|
|
655
|
+
const scheduledKnown = typeof resource.status?.instances === 'number'
|
|
656
|
+
const scheduled = scheduledKnown ? resource.status.instances : 0
|
|
657
|
+
|
|
658
|
+
if (!scheduledKnown) {
|
|
659
|
+
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
660
|
+
}
|
|
347
661
|
|
|
348
|
-
if (desired > 0 &&
|
|
349
|
-
if (
|
|
350
|
-
return { text: 'Not
|
|
662
|
+
if (desired > 0 && scheduled < desired) {
|
|
663
|
+
if (scheduled === 0) {
|
|
664
|
+
return { text: 'Not Scheduled', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
351
665
|
}
|
|
352
666
|
return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
|
|
353
667
|
}
|
|
354
668
|
|
|
355
|
-
if (desired > 0 &&
|
|
356
|
-
return { text: '
|
|
669
|
+
if (desired > 0 && scheduled >= desired) {
|
|
670
|
+
return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
|
|
357
671
|
}
|
|
358
672
|
|
|
359
673
|
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
360
674
|
}
|
|
361
675
|
|
|
676
|
+
/** Name of the Deployment CNPG generates for this Pooler — where real readiness lives. */
|
|
677
|
+
export function getCNPGPoolerDeploymentName(resource: any): string {
|
|
678
|
+
return resource.metadata?.name || ''
|
|
679
|
+
}
|
|
680
|
+
|
|
362
681
|
export function getCNPGPoolerCluster(resource: any): string {
|
|
363
682
|
return resource.spec?.cluster?.name || '-'
|
|
364
683
|
}
|
|
@@ -372,9 +691,7 @@ export function getCNPGPoolerMode(resource: any): string {
|
|
|
372
691
|
}
|
|
373
692
|
|
|
374
693
|
export function getCNPGPoolerInstances(resource: any): string {
|
|
375
|
-
|
|
376
|
-
const ready = resource.status?.instances ?? 0
|
|
377
|
-
return `${ready}/${desired}`
|
|
694
|
+
return `${countOrDash(resource.status?.instances)}/${countOrDash(resource.spec?.instances)}`
|
|
378
695
|
}
|
|
379
696
|
|
|
380
697
|
export function getCNPGPoolerParameters(resource: any): Record<string, string> {
|