@skyhook-io/k8s-ui 1.16.2 → 1.16.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 +3 -3
- package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
- package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
- package/src/components/cnpg/index.ts +14 -0
- package/src/components/cnpg/primitives.tsx +134 -0
- package/src/components/cnpg/relations.test.ts +304 -0
- package/src/components/cnpg/relations.ts +417 -0
- package/src/components/cnpg/workspace.test.ts +285 -0
- package/src/components/cnpg/workspace.ts +700 -0
- package/src/components/issues/IssuesView.tsx +10 -1
- package/src/components/issues/types.ts +24 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +12 -3
- package/src/components/logs/useLogBuffer.test.ts +17 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
- package/src/components/resources/ResourcesSidebar.tsx +183 -11
- package/src/components/resources/ResourcesView.tsx +5 -1
- package/src/components/resources/index.ts +1 -1
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
- package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.ts +3 -3
- package/src/components/resources/status-overclaim.test.ts +1 -1
- package/src/components/timeline/TimelineList.tsx +185 -23
- package/src/components/timeline/TimelineListFold.test.tsx +56 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
- package/src/components/timeline/index.ts +1 -0
- package/src/components/timeline/timeline-filters.ts +7 -0
- package/src/components/workload/WorkloadView.tsx +96 -5
- package/src/components/workload/index.ts +1 -0
- package/src/components/workload/workload-logs-availability.test.ts +6 -0
- package/src/index.ts +3 -0
- package/src/utils/log-level.ts +14 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// CloudNativePG workspace model: pure derivations over the /api/cnpg/workspace
|
|
2
|
+
// payload. Every fact here is something the cluster actually reports; when it
|
|
3
|
+
// does not report something the value is "unknown", never zero or healthy.
|
|
4
|
+
|
|
5
|
+
import type { HealthLevel } from '../resources/resource-utils'
|
|
6
|
+
import {
|
|
7
|
+
CNPG_BARMAN_PLUGIN_NAME,
|
|
8
|
+
getCNPGClusterBackupConfig,
|
|
9
|
+
getCNPGClusterBarmanPlugin,
|
|
10
|
+
getCNPGClusterImageTag,
|
|
11
|
+
getCNPGClusterStatus,
|
|
12
|
+
getCNPGObjectStoreRecoveryWindows,
|
|
13
|
+
isApiGroup,
|
|
14
|
+
} from '../resources/resource-utils-cnpg'
|
|
15
|
+
|
|
16
|
+
export const CNPG_WORKSPACE_KEYS = [
|
|
17
|
+
'clusters',
|
|
18
|
+
'backups',
|
|
19
|
+
'scheduledBackups',
|
|
20
|
+
'poolers',
|
|
21
|
+
'databases',
|
|
22
|
+
'publications',
|
|
23
|
+
'subscriptions',
|
|
24
|
+
'imageCatalogs',
|
|
25
|
+
'clusterImageCatalogs',
|
|
26
|
+
'objectStores',
|
|
27
|
+
'pods',
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
export type CNPGWorkspaceKey = (typeof CNPG_WORKSPACE_KEYS)[number]
|
|
31
|
+
|
|
32
|
+
export type CNPGCoverageState = 'full' | 'partial' | 'denied' | 'notInstalled' | 'syncing' | 'error'
|
|
33
|
+
|
|
34
|
+
export interface CNPGKindCoverage {
|
|
35
|
+
state: CNPGCoverageState
|
|
36
|
+
/** Denied namespaces, named only when the caller supplied the candidate list. */
|
|
37
|
+
deniedNamespaces?: string[]
|
|
38
|
+
/** For partial coverage: the namespaces that were read. */
|
|
39
|
+
allowedNamespaces?: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CNPGWorkspaceIssue {
|
|
43
|
+
id: string
|
|
44
|
+
severity: 'critical' | 'warning'
|
|
45
|
+
category?: string
|
|
46
|
+
kind: string
|
|
47
|
+
group?: string
|
|
48
|
+
namespace?: string
|
|
49
|
+
name: string
|
|
50
|
+
reason: string
|
|
51
|
+
message?: string
|
|
52
|
+
cause?: string
|
|
53
|
+
action?: string
|
|
54
|
+
first_seen?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CNPGAuditFinding {
|
|
58
|
+
checkId: string
|
|
59
|
+
severity: string
|
|
60
|
+
kind: string
|
|
61
|
+
group?: string
|
|
62
|
+
namespace?: string
|
|
63
|
+
name: string
|
|
64
|
+
message: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CNPGWorkspaceResponse {
|
|
68
|
+
installed: boolean
|
|
69
|
+
context: string
|
|
70
|
+
namespaces: string[] | null
|
|
71
|
+
coverage: Partial<Record<CNPGWorkspaceKey, CNPGKindCoverage>>
|
|
72
|
+
objects: Partial<Record<CNPGWorkspaceKey, any[]>>
|
|
73
|
+
issues: CNPGWorkspaceIssue[]
|
|
74
|
+
audit: CNPGAuditFinding[]
|
|
75
|
+
backupsOmitted: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const CNPG_KIND_BY_KEY: Record<CNPGWorkspaceKey, { kind: string; group: string; plural: string }> = {
|
|
79
|
+
clusters: { kind: 'Cluster', group: 'postgresql.cnpg.io', plural: 'clusters' },
|
|
80
|
+
backups: { kind: 'Backup', group: 'postgresql.cnpg.io', plural: 'backups' },
|
|
81
|
+
scheduledBackups: { kind: 'ScheduledBackup', group: 'postgresql.cnpg.io', plural: 'scheduledbackups' },
|
|
82
|
+
poolers: { kind: 'Pooler', group: 'postgresql.cnpg.io', plural: 'poolers' },
|
|
83
|
+
databases: { kind: 'Database', group: 'postgresql.cnpg.io', plural: 'databases' },
|
|
84
|
+
publications: { kind: 'Publication', group: 'postgresql.cnpg.io', plural: 'publications' },
|
|
85
|
+
subscriptions: { kind: 'Subscription', group: 'postgresql.cnpg.io', plural: 'subscriptions' },
|
|
86
|
+
imageCatalogs: { kind: 'ImageCatalog', group: 'postgresql.cnpg.io', plural: 'imagecatalogs' },
|
|
87
|
+
clusterImageCatalogs: { kind: 'ClusterImageCatalog', group: 'postgresql.cnpg.io', plural: 'clusterimagecatalogs' },
|
|
88
|
+
objectStores: { kind: 'ObjectStore', group: 'barmancloud.cnpg.io', plural: 'objectstores' },
|
|
89
|
+
pods: { kind: 'Pod', group: '', plural: 'pods' },
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isCNPGWorkspaceKind(kind: string, group: string | undefined): boolean {
|
|
93
|
+
return Object.values(CNPG_KIND_BY_KEY).some((k) => k.group !== '' && k.group === (group ?? '') && k.kind === kind)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The value is observed, derived, or not available from the cluster. */
|
|
97
|
+
export type CNPGFactTone = HealthLevel
|
|
98
|
+
|
|
99
|
+
export interface CNPGFact {
|
|
100
|
+
text: string
|
|
101
|
+
tone: CNPGFactTone
|
|
102
|
+
/** Where the value comes from, shown next to it so claims carry their source. */
|
|
103
|
+
source?: string
|
|
104
|
+
/** A timestamp the text refers to; the UI renders it as an age. */
|
|
105
|
+
at?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type CNPGProblemCategory = 'availability' | 'protection' | 'declarations' | 'pooling'
|
|
109
|
+
|
|
110
|
+
export const CNPG_PROBLEM_CATEGORIES: { id: CNPGProblemCategory; label: string }[] = [
|
|
111
|
+
{ id: 'availability', label: 'Availability' },
|
|
112
|
+
{ id: 'protection', label: 'Protection' },
|
|
113
|
+
{ id: 'declarations', label: 'Declarations' },
|
|
114
|
+
{ id: 'pooling', label: 'Pooling' },
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
export interface CNPGProblem {
|
|
118
|
+
/** Stable identity for keys. */
|
|
119
|
+
id: string
|
|
120
|
+
severity: 'critical' | 'warning' | 'posture'
|
|
121
|
+
category: CNPGProblemCategory
|
|
122
|
+
title: string
|
|
123
|
+
detail?: string
|
|
124
|
+
/** The object the evidence is about (may be the Cluster or a child object). */
|
|
125
|
+
subject: { kind: string; group: string; namespace: string; name: string }
|
|
126
|
+
source: 'issue' | 'audit'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface CNPGInstance {
|
|
130
|
+
name: string
|
|
131
|
+
role: 'primary' | 'replica' | 'unknown'
|
|
132
|
+
ready: boolean | null
|
|
133
|
+
node?: string
|
|
134
|
+
zone?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface CNPGProtectionFacts {
|
|
138
|
+
schedule: CNPGFact & { names: string[] }
|
|
139
|
+
destination: CNPGFact & {
|
|
140
|
+
method: 'plugin' | 'barmanObjectStore' | 'volumeSnapshot' | 'none'
|
|
141
|
+
objectStore?: string
|
|
142
|
+
}
|
|
143
|
+
lastSuccessfulBackup: CNPGFact
|
|
144
|
+
walArchiving: CNPGFact
|
|
145
|
+
recoveryWindow: CNPGFact & { from?: string }
|
|
146
|
+
restoreValidation: CNPGFact & { restoredInto?: { namespace: string; name: string } }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface CNPGFleetRow {
|
|
150
|
+
key: string
|
|
151
|
+
namespace: string
|
|
152
|
+
name: string
|
|
153
|
+
cluster: any
|
|
154
|
+
controllerStatus: { text: string; level: HealthLevel }
|
|
155
|
+
instances: { ready: number | null; desired: number | null }
|
|
156
|
+
pods: CNPGInstance[]
|
|
157
|
+
replicaCluster: { source?: string } | null
|
|
158
|
+
hibernated: boolean
|
|
159
|
+
pgVersion: string | null
|
|
160
|
+
catalog: { kind: string; name: string } | null
|
|
161
|
+
replication: CNPGFact
|
|
162
|
+
protection: CNPGProtectionFacts & { summary: CNPGFact }
|
|
163
|
+
declarations: { summary: CNPGFact; total: number; failed: number; pending: number }
|
|
164
|
+
poolers: string[]
|
|
165
|
+
/** False when Poolers are not readable in this cluster's namespace, so an empty list means unknown. */
|
|
166
|
+
poolersKnown: boolean
|
|
167
|
+
problems: CNPGProblem[]
|
|
168
|
+
/** Any issue at warning or worse. Posture findings alone do not need attention. */
|
|
169
|
+
attention: boolean
|
|
170
|
+
categories: Set<CNPGProblemCategory>
|
|
171
|
+
/** GitOps owner recorded on the Cluster, when it carries the standard labels. */
|
|
172
|
+
gitops: CNPGGitOpsSource | null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface CNPGFleet {
|
|
176
|
+
rows: CNPGFleetRow[]
|
|
177
|
+
attentionCount: number
|
|
178
|
+
categoryCounts: Record<CNPGProblemCategory, number>
|
|
179
|
+
/** Kinds whose coverage is not complete, so counts built on them are lower bounds. */
|
|
180
|
+
incompleteKinds: CNPGWorkspaceKey[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const PROTECTION_ISSUE_REASONS = new Set([
|
|
184
|
+
'CNPGWALArchivingFailing',
|
|
185
|
+
'CNPGLastBackupFailed',
|
|
186
|
+
'CNPGBackupFailed',
|
|
187
|
+
'CNPGScheduledBackupMissed',
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
export function cnpgIssueCategory(issue: Pick<CNPGWorkspaceIssue, 'kind' | 'reason'>): CNPGProblemCategory {
|
|
191
|
+
if (PROTECTION_ISSUE_REASONS.has(issue.reason)) return 'protection'
|
|
192
|
+
switch (issue.kind) {
|
|
193
|
+
case 'Backup':
|
|
194
|
+
case 'ScheduledBackup':
|
|
195
|
+
case 'ObjectStore':
|
|
196
|
+
return 'protection'
|
|
197
|
+
case 'Database':
|
|
198
|
+
case 'Publication':
|
|
199
|
+
case 'Subscription':
|
|
200
|
+
return 'declarations'
|
|
201
|
+
case 'Pooler':
|
|
202
|
+
return 'pooling'
|
|
203
|
+
default:
|
|
204
|
+
return 'availability'
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function key(ns: string | undefined, name: string): string {
|
|
209
|
+
return `${ns ?? ''}/${name}`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function specClusterName(obj: any): string | undefined {
|
|
213
|
+
const n = obj?.spec?.cluster?.name
|
|
214
|
+
return typeof n === 'string' && n ? n : undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function coverageOf(resp: CNPGWorkspaceResponse, k: CNPGWorkspaceKey): CNPGKindCoverage {
|
|
218
|
+
return resp.coverage?.[k] ?? { state: 'notInstalled' }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Coverage is usable in a namespace when that namespace's objects were read. */
|
|
222
|
+
export function coverageReadable(cov: CNPGKindCoverage, namespace?: string): boolean {
|
|
223
|
+
if (cov.state === 'full') return true
|
|
224
|
+
if (cov.state === 'partial') {
|
|
225
|
+
if (!namespace) return false
|
|
226
|
+
if (cov.allowedNamespaces) return cov.allowedNamespaces.includes(namespace)
|
|
227
|
+
if (cov.deniedNamespaces) return !cov.deniedNamespaces.includes(namespace)
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function coverageUnavailableText(cov: CNPGKindCoverage, what: string): string {
|
|
234
|
+
switch (cov.state) {
|
|
235
|
+
case 'denied':
|
|
236
|
+
case 'partial':
|
|
237
|
+
return `No access to ${what}`
|
|
238
|
+
case 'syncing':
|
|
239
|
+
return 'Loading…'
|
|
240
|
+
case 'error':
|
|
241
|
+
return `Could not read ${what}`
|
|
242
|
+
default:
|
|
243
|
+
return 'Not installed'
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function timeOf(obj: any): number {
|
|
248
|
+
const t = obj?.status?.stoppedAt || obj?.status?.startedAt || obj?.metadata?.creationTimestamp
|
|
249
|
+
const ms = t ? Date.parse(t) : NaN
|
|
250
|
+
return Number.isFinite(ms) ? ms : 0
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function podRole(pod: any, cluster: any): CNPGInstance['role'] {
|
|
254
|
+
const role = pod?.metadata?.labels?.['cnpg.io/instanceRole'] ?? pod?.metadata?.labels?.role
|
|
255
|
+
if (role === 'primary') return 'primary'
|
|
256
|
+
if (role === 'replica') return 'replica'
|
|
257
|
+
const primary = cluster?.status?.currentPrimary
|
|
258
|
+
if (primary && pod?.metadata?.name === primary) return 'primary'
|
|
259
|
+
return 'unknown'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function podReady(pod: any): boolean | null {
|
|
263
|
+
const conds = pod?.status?.conditions
|
|
264
|
+
if (!Array.isArray(conds)) return null
|
|
265
|
+
const ready = conds.find((c: any) => c?.type === 'Ready')
|
|
266
|
+
if (!ready) return null
|
|
267
|
+
return ready.status === 'True'
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type CNPGGitOpsSource = { tool: 'argocd' | 'flux'; name: string; namespace?: string }
|
|
271
|
+
|
|
272
|
+
/** The GitOps owner recorded on an object's standard Argo CD / Flux labels. */
|
|
273
|
+
export function cnpgGitOpsSource(obj: any): CNPGGitOpsSource | null {
|
|
274
|
+
const labels = obj?.metadata?.labels ?? {}
|
|
275
|
+
const annotations = obj?.metadata?.annotations ?? {}
|
|
276
|
+
const argo = labels['argocd.argoproj.io/instance']
|
|
277
|
+
if (argo) return { tool: 'argocd', name: argo }
|
|
278
|
+
const tracking = annotations['argocd.argoproj.io/tracking-id']
|
|
279
|
+
if (typeof tracking === 'string' && tracking.includes(':')) {
|
|
280
|
+
return { tool: 'argocd', name: tracking.split(':')[0] }
|
|
281
|
+
}
|
|
282
|
+
const fluxName = labels['kustomize.toolkit.fluxcd.io/name'] || labels['helm.toolkit.fluxcd.io/name']
|
|
283
|
+
if (fluxName) {
|
|
284
|
+
return {
|
|
285
|
+
tool: 'flux',
|
|
286
|
+
name: fluxName,
|
|
287
|
+
namespace: labels['kustomize.toolkit.fluxcd.io/namespace'] || labels['helm.toolkit.fluxcd.io/namespace'],
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function scheduleFact(
|
|
294
|
+
cluster: any,
|
|
295
|
+
schedules: any[],
|
|
296
|
+
cov: CNPGKindCoverage,
|
|
297
|
+
): CNPGProtectionFacts['schedule'] {
|
|
298
|
+
const ns = cluster.metadata?.namespace
|
|
299
|
+
if (!coverageReadable(cov, ns)) {
|
|
300
|
+
return { text: coverageUnavailableText(cov, 'ScheduledBackups'), tone: 'unknown', names: [] }
|
|
301
|
+
}
|
|
302
|
+
const mine = schedules.filter((s) => s.metadata?.namespace === ns && specClusterName(s) === cluster.metadata?.name)
|
|
303
|
+
if (mine.length === 0) return { text: 'No declarative schedule', tone: 'neutral', names: [] }
|
|
304
|
+
const active = mine.filter((s) => s.spec?.suspend !== true)
|
|
305
|
+
const names = mine.map((s) => s.metadata?.name).filter(Boolean)
|
|
306
|
+
if (active.length === 0) {
|
|
307
|
+
return { text: mine.length === 1 ? 'Schedule suspended' : 'All schedules suspended', tone: 'degraded', names }
|
|
308
|
+
}
|
|
309
|
+
const cron = active[0]?.spec?.schedule
|
|
310
|
+
return {
|
|
311
|
+
text: active.length === 1 ? (cron ? `Scheduled · ${cron}` : 'Scheduled') : `${active.length} schedules`,
|
|
312
|
+
tone: 'healthy',
|
|
313
|
+
names,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function destinationFact(cluster: any): CNPGProtectionFacts['destination'] {
|
|
318
|
+
const plugin = getCNPGClusterBarmanPlugin(cluster)
|
|
319
|
+
if (plugin?.barmanObjectName) {
|
|
320
|
+
return {
|
|
321
|
+
text: `ObjectStore ${plugin.barmanObjectName}`,
|
|
322
|
+
tone: 'neutral',
|
|
323
|
+
method: 'plugin',
|
|
324
|
+
objectStore: plugin.barmanObjectName,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const cfg = getCNPGClusterBackupConfig(cluster)
|
|
328
|
+
if (cfg.destinationPath) {
|
|
329
|
+
return { text: cfg.destinationPath, tone: 'neutral', method: 'barmanObjectStore' }
|
|
330
|
+
}
|
|
331
|
+
if (cluster?.spec?.backup?.volumeSnapshot) {
|
|
332
|
+
return { text: 'Volume snapshots', tone: 'neutral', method: 'volumeSnapshot' }
|
|
333
|
+
}
|
|
334
|
+
return { text: 'No destination configured', tone: 'neutral', method: 'none' }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function recoveryWindowFor(cluster: any, stores: any[]): { from?: string; lastSuccess?: string; store?: string } | null {
|
|
338
|
+
const plugin = getCNPGClusterBarmanPlugin(cluster)
|
|
339
|
+
if (!plugin?.barmanObjectName) return null
|
|
340
|
+
const store = stores.find(
|
|
341
|
+
(s) => s.metadata?.namespace === cluster.metadata?.namespace && s.metadata?.name === plugin.barmanObjectName,
|
|
342
|
+
)
|
|
343
|
+
if (!store) return null
|
|
344
|
+
const server = plugin.serverName || cluster.metadata?.name
|
|
345
|
+
const w = getCNPGObjectStoreRecoveryWindows(store).find((x) => x.server === server)
|
|
346
|
+
if (!w) return { store: store.metadata?.name }
|
|
347
|
+
return {
|
|
348
|
+
from: w.firstRecoverabilityPoint,
|
|
349
|
+
lastSuccess: w.lastSuccessfulBackupTime,
|
|
350
|
+
store: store.metadata?.name,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function lastBackupFact(
|
|
355
|
+
cluster: any,
|
|
356
|
+
backups: any[],
|
|
357
|
+
backupsCov: CNPGKindCoverage,
|
|
358
|
+
window: ReturnType<typeof recoveryWindowFor>,
|
|
359
|
+
storesUnreadable: CNPGKindCoverage | null,
|
|
360
|
+
): CNPGProtectionFacts['lastSuccessfulBackup'] {
|
|
361
|
+
const ns = cluster.metadata?.namespace
|
|
362
|
+
const name = cluster.metadata?.name
|
|
363
|
+
const candidates: { at: string; source: string }[] = []
|
|
364
|
+
if (coverageReadable(backupsCov, ns)) {
|
|
365
|
+
const completed = backups
|
|
366
|
+
.filter(
|
|
367
|
+
(b) =>
|
|
368
|
+
b.metadata?.namespace === ns &&
|
|
369
|
+
specClusterName(b) === name &&
|
|
370
|
+
isApiGroup(b.apiVersion, 'postgresql.cnpg.io') &&
|
|
371
|
+
b.status?.phase === 'completed',
|
|
372
|
+
)
|
|
373
|
+
.sort((a, b) => timeOf(b) - timeOf(a))[0]
|
|
374
|
+
const at = completed?.status?.stoppedAt || completed?.status?.startedAt
|
|
375
|
+
if (completed && at) candidates.push({ at, source: `Backup ${completed.metadata?.name}` })
|
|
376
|
+
}
|
|
377
|
+
if (window?.lastSuccess) candidates.push({ at: window.lastSuccess, source: `ObjectStore ${window.store} status` })
|
|
378
|
+
const cfg = getCNPGClusterBackupConfig(cluster)
|
|
379
|
+
if (!cfg.plugin && cfg.lastSuccessfulBackup) candidates.push({ at: cfg.lastSuccessfulBackup, source: 'Cluster status' })
|
|
380
|
+
if (candidates.length === 0) {
|
|
381
|
+
if (!coverageReadable(backupsCov, ns)) {
|
|
382
|
+
return { text: coverageUnavailableText(backupsCov, 'Backups'), tone: 'unknown' }
|
|
383
|
+
}
|
|
384
|
+
if (storesUnreadable) return { text: coverageUnavailableText(storesUnreadable, 'ObjectStores'), tone: 'unknown' }
|
|
385
|
+
return { text: 'None observed', tone: 'unknown' }
|
|
386
|
+
}
|
|
387
|
+
const best = candidates.reduce((a, b) => (Date.parse(a.at) >= Date.parse(b.at) ? a : b))
|
|
388
|
+
return { text: 'Completed', tone: 'healthy', at: best.at, source: best.source }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function walFact(cluster: any): CNPGFact {
|
|
392
|
+
const conds = cluster?.status?.conditions
|
|
393
|
+
const c = Array.isArray(conds) ? conds.find((x: any) => x?.type === 'ContinuousArchiving') : null
|
|
394
|
+
if (!c) return { text: 'Not reported', tone: 'unknown', source: 'Cluster status' }
|
|
395
|
+
if (c.status === 'True') return { text: 'Archiving', tone: 'healthy', source: 'ContinuousArchiving condition' }
|
|
396
|
+
if (c.status === 'False') {
|
|
397
|
+
return { text: c.message ? `Failing · ${c.message}` : 'Failing', tone: 'unhealthy', source: 'ContinuousArchiving condition' }
|
|
398
|
+
}
|
|
399
|
+
return { text: 'Unknown', tone: 'unknown', source: 'ContinuousArchiving condition' }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function restoreValidationFact(
|
|
403
|
+
cluster: any,
|
|
404
|
+
allClusters: any[],
|
|
405
|
+
backups: any[],
|
|
406
|
+
backupsReadable: boolean,
|
|
407
|
+
): CNPGProtectionFacts['restoreValidation'] {
|
|
408
|
+
const plugin = getCNPGClusterBarmanPlugin(cluster)
|
|
409
|
+
const server = plugin?.serverName || cluster.metadata?.name
|
|
410
|
+
const store = plugin?.barmanObjectName
|
|
411
|
+
const ns = cluster.metadata?.namespace
|
|
412
|
+
const name = cluster.metadata?.name
|
|
413
|
+
const restored = allClusters.find((c) => {
|
|
414
|
+
if (c === cluster || c.metadata?.namespace !== ns) return false
|
|
415
|
+
const recovery = c.spec?.bootstrap?.recovery
|
|
416
|
+
if (!recovery) return false
|
|
417
|
+
const sourceName = recovery.source
|
|
418
|
+
if (sourceName) {
|
|
419
|
+
const ext = (c.spec?.externalClusters ?? []).find((e: any) => e?.name === sourceName)
|
|
420
|
+
const params = ext?.plugin?.name === CNPG_BARMAN_PLUGIN_NAME ? ext.plugin.parameters : undefined
|
|
421
|
+
if (store && params?.barmanObjectName === store && (params?.serverName || sourceName) === server) return true
|
|
422
|
+
}
|
|
423
|
+
const backupName = recovery.backup?.name
|
|
424
|
+
if (!backupName) return false
|
|
425
|
+
const backup = backups.find((b) => b.metadata?.namespace === ns && b.metadata?.name === backupName)
|
|
426
|
+
return specClusterName(backup) === name
|
|
427
|
+
})
|
|
428
|
+
if (!restored) {
|
|
429
|
+
// A recovery by Backup name is only attributable when that Backup could be read.
|
|
430
|
+
const unresolved = !backupsReadable && allClusters.some((c) => c !== cluster && c.metadata?.namespace === ns && c.spec?.bootstrap?.recovery?.backup?.name)
|
|
431
|
+
if (unresolved) return { text: 'Unknown: no access to Backups', tone: 'unknown', source: `A Cluster in ${ns} recovers from a Backup Radar cannot read` }
|
|
432
|
+
return { text: 'None recorded', tone: 'unknown', source: 'Kubernetes does not record restore tests' }
|
|
433
|
+
}
|
|
434
|
+
const rname = restored.metadata?.name
|
|
435
|
+
const ready = typeof restored.status?.readyInstances === 'number' && restored.status.readyInstances > 0
|
|
436
|
+
if (!ready) {
|
|
437
|
+
return {
|
|
438
|
+
text: `Recovery declared in ${rname}`,
|
|
439
|
+
tone: 'unknown',
|
|
440
|
+
source: `Cluster ${rname} bootstraps from this cluster's backups but has no ready instance yet`,
|
|
441
|
+
restoredInto: { namespace: restored.metadata?.namespace, name: rname },
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
text: `Restored into ${rname}`,
|
|
446
|
+
tone: 'neutral',
|
|
447
|
+
source: `Cluster ${rname} bootstrapped from this cluster's backups and has ready instances · created ${restored.metadata?.creationTimestamp ?? 'unknown'}. This proves one recovery, not that today's backups restore.`,
|
|
448
|
+
restoredInto: { namespace: restored.metadata?.namespace, name: rname },
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function protectionSummary(p: CNPGProtectionFacts): CNPGFact {
|
|
453
|
+
if (p.walArchiving.tone === 'unhealthy') return { text: 'WAL archiving failing', tone: 'unhealthy' }
|
|
454
|
+
if (p.destination.method === 'none' && p.schedule.names.length === 0 && p.schedule.tone !== 'unknown') {
|
|
455
|
+
return { text: 'No backup destination or schedule', tone: 'neutral' }
|
|
456
|
+
}
|
|
457
|
+
if (p.schedule.tone === 'degraded') return { text: p.schedule.text, tone: 'degraded' }
|
|
458
|
+
if (p.lastSuccessfulBackup.at) return { text: 'Last backup', tone: 'healthy', at: p.lastSuccessfulBackup.at }
|
|
459
|
+
return { text: p.lastSuccessfulBackup.text, tone: p.lastSuccessfulBackup.tone }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function catalogRef(cluster: any): CNPGFleetRow['catalog'] {
|
|
463
|
+
const ref = cluster?.spec?.imageCatalogRef
|
|
464
|
+
if (!ref?.name) return null
|
|
465
|
+
return { kind: ref.kind || 'ImageCatalog', name: ref.name }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function pgVersion(cluster: any): string | null {
|
|
469
|
+
const tag = getCNPGClusterImageTag(cluster)
|
|
470
|
+
if (tag && tag !== '-') {
|
|
471
|
+
const m = tag.match(/^(\d+(?:\.\d+)?)/)
|
|
472
|
+
if (m) return m[1]
|
|
473
|
+
}
|
|
474
|
+
const major = cluster?.spec?.imageCatalogRef?.major
|
|
475
|
+
return typeof major === 'number' ? String(major) : null
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function replicationFact(cluster: any, pods: CNPGInstance[], hibernated: boolean, podsCov: CNPGKindCoverage): CNPGFact {
|
|
479
|
+
if (hibernated) return { text: 'Hibernated', tone: 'neutral' }
|
|
480
|
+
const desired = cluster?.spec?.instances
|
|
481
|
+
if (desired === 1) return { text: 'Single instance', tone: 'neutral' }
|
|
482
|
+
if (!coverageReadable(podsCov, cluster?.metadata?.namespace)) {
|
|
483
|
+
return { text: coverageUnavailableText(podsCov, 'Pods'), tone: 'unknown' }
|
|
484
|
+
}
|
|
485
|
+
const replicas = pods.filter((p) => p.role === 'replica')
|
|
486
|
+
const readyReplicas = replicas.filter((p) => p.ready === true).length
|
|
487
|
+
if (replicas.length === 0) return { text: 'No replica pods observed', tone: 'unknown' }
|
|
488
|
+
return {
|
|
489
|
+
text: `${readyReplicas}/${replicas.length} replicas ready · lag unknown`,
|
|
490
|
+
tone: 'unknown',
|
|
491
|
+
source: 'Pod readiness does not show whether a replica is streaming',
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function problemsFor(
|
|
496
|
+
cluster: any,
|
|
497
|
+
issues: CNPGWorkspaceIssue[],
|
|
498
|
+
audit: CNPGAuditFinding[],
|
|
499
|
+
children: Map<string, string>,
|
|
500
|
+
): CNPGProblem[] {
|
|
501
|
+
const ns = cluster.metadata?.namespace
|
|
502
|
+
const name = cluster.metadata?.name
|
|
503
|
+
const out: CNPGProblem[] = []
|
|
504
|
+
for (const issue of issues) {
|
|
505
|
+
if ((issue.namespace ?? '') !== ns) continue
|
|
506
|
+
const isSelf = issue.kind === 'Cluster' && issue.name === name
|
|
507
|
+
const owner = children.get(`${issue.kind}/${ns}/${issue.name}`)
|
|
508
|
+
if (!isSelf && owner !== name) continue
|
|
509
|
+
out.push({
|
|
510
|
+
id: `${issue.id}:${issue.kind}/${issue.name}`,
|
|
511
|
+
severity: issue.severity,
|
|
512
|
+
category: cnpgIssueCategory(issue),
|
|
513
|
+
title: issue.message || issue.reason,
|
|
514
|
+
detail: issue.cause || undefined,
|
|
515
|
+
subject: { kind: issue.kind, group: issue.group ?? '', namespace: ns, name: issue.name },
|
|
516
|
+
source: 'issue',
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
for (const f of audit) {
|
|
520
|
+
if (f.kind !== 'Cluster' || f.name !== name || (f.namespace ?? '') !== ns) continue
|
|
521
|
+
out.push({
|
|
522
|
+
id: `audit:${f.checkId}:${ns}/${name}`,
|
|
523
|
+
severity: 'posture',
|
|
524
|
+
category: 'protection',
|
|
525
|
+
title: f.checkId === 'cnpgNoDeclarativeBackup' ? 'No declarative backup schedule' : f.message,
|
|
526
|
+
detail: f.message,
|
|
527
|
+
subject: { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: ns, name },
|
|
528
|
+
source: 'audit',
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
const rank = { critical: 0, warning: 1, posture: 2 } as const
|
|
532
|
+
return out.sort((a, b) => rank[a.severity] - rank[b.severity] || a.title.localeCompare(b.title))
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Index "Kind/ns/name" → owning cluster name, from each child's spec.cluster.name. */
|
|
536
|
+
function childIndex(resp: CNPGWorkspaceResponse): Map<string, string> {
|
|
537
|
+
const idx = new Map<string, string>()
|
|
538
|
+
const add = (kind: string, list: any[] | undefined) => {
|
|
539
|
+
for (const o of list ?? []) {
|
|
540
|
+
const c = specClusterName(o)
|
|
541
|
+
if (c) idx.set(`${kind}/${o.metadata?.namespace}/${o.metadata?.name}`, c)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
add('Backup', resp.objects.backups)
|
|
545
|
+
add('ScheduledBackup', resp.objects.scheduledBackups)
|
|
546
|
+
add('Pooler', resp.objects.poolers)
|
|
547
|
+
add('Database', resp.objects.databases)
|
|
548
|
+
add('Publication', resp.objects.publications)
|
|
549
|
+
add('Subscription', resp.objects.subscriptions)
|
|
550
|
+
for (const p of resp.objects.pods ?? []) {
|
|
551
|
+
const c = p?.metadata?.labels?.['cnpg.io/cluster']
|
|
552
|
+
if (c) idx.set(`Pod/${p.metadata?.namespace}/${p.metadata?.name}`, c)
|
|
553
|
+
}
|
|
554
|
+
return idx
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function declarationsFor(cluster: any, resp: CNPGWorkspaceResponse): CNPGFleetRow['declarations'] {
|
|
558
|
+
const ns = cluster.metadata?.namespace
|
|
559
|
+
const name = cluster.metadata?.name
|
|
560
|
+
const lists: [CNPGWorkspaceKey, any[]][] = [
|
|
561
|
+
['databases', resp.objects.databases ?? []],
|
|
562
|
+
['publications', resp.objects.publications ?? []],
|
|
563
|
+
['subscriptions', resp.objects.subscriptions ?? []],
|
|
564
|
+
]
|
|
565
|
+
let total = 0
|
|
566
|
+
let failed = 0
|
|
567
|
+
let pending = 0
|
|
568
|
+
let unreadable = false
|
|
569
|
+
for (const [k, list] of lists) {
|
|
570
|
+
if (!coverageReadable(coverageOf(resp, k), ns)) {
|
|
571
|
+
if (coverageOf(resp, k).state !== 'notInstalled') unreadable = true
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
for (const o of list) {
|
|
575
|
+
if (o.metadata?.namespace !== ns || specClusterName(o) !== name) continue
|
|
576
|
+
total++
|
|
577
|
+
if (o.status?.applied === false) failed++
|
|
578
|
+
else if (o.status?.applied !== true) pending++
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const roleStatus = cluster?.status?.managedRolesStatus
|
|
582
|
+
const reconciledRoles = new Set<string>(roleStatus?.byStatus?.reconciled ?? [])
|
|
583
|
+
const failedRoles = new Set<string>(Object.keys(roleStatus?.cannotReconcile ?? {}))
|
|
584
|
+
const declaredRoles: any[] = Array.isArray(cluster?.spec?.managed?.roles) ? cluster.spec.managed.roles : []
|
|
585
|
+
for (const r of declaredRoles) {
|
|
586
|
+
if (!r?.name) continue
|
|
587
|
+
total++
|
|
588
|
+
if (failedRoles.has(r.name)) failed++
|
|
589
|
+
else if (!reconciledRoles.has(r.name)) pending++
|
|
590
|
+
}
|
|
591
|
+
let summary: CNPGFact
|
|
592
|
+
if (total === 0) {
|
|
593
|
+
summary = unreadable ? { text: 'No access to some declarations', tone: 'unknown' } : { text: 'None declared', tone: 'neutral' }
|
|
594
|
+
} else if (failed > 0) {
|
|
595
|
+
summary = { text: `${failed} of ${total} not reconciled`, tone: 'degraded' }
|
|
596
|
+
} else if (pending > 0) {
|
|
597
|
+
summary = { text: `${pending} of ${total} pending`, tone: 'unknown' }
|
|
598
|
+
} else {
|
|
599
|
+
summary = { text: `${total} reconciled`, tone: 'healthy' }
|
|
600
|
+
}
|
|
601
|
+
if (unreadable && total > 0) summary = { ...summary, source: 'Some declaration kinds are not readable' }
|
|
602
|
+
return { summary, total, failed, pending }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function buildCNPGFleet(resp: CNPGWorkspaceResponse): CNPGFleet {
|
|
606
|
+
const clusters = (resp.objects.clusters ?? []).filter((c) => isApiGroup(c?.apiVersion, 'postgresql.cnpg.io'))
|
|
607
|
+
const pods = resp.objects.pods ?? []
|
|
608
|
+
const stores = resp.objects.objectStores ?? []
|
|
609
|
+
const children = childIndex(resp)
|
|
610
|
+
const poolers = resp.objects.poolers ?? []
|
|
611
|
+
|
|
612
|
+
const rows: CNPGFleetRow[] = clusters.map((cluster) => {
|
|
613
|
+
const ns = cluster.metadata?.namespace ?? ''
|
|
614
|
+
const name = cluster.metadata?.name ?? ''
|
|
615
|
+
const status = getCNPGClusterStatus(cluster)
|
|
616
|
+
const hibernated = cluster?.metadata?.annotations?.['cnpg.io/hibernation'] === 'on'
|
|
617
|
+
const instancePods: CNPGInstance[] = pods
|
|
618
|
+
.filter((p) => p.metadata?.namespace === ns && p.metadata?.labels?.['cnpg.io/cluster'] === name)
|
|
619
|
+
.map((p) => ({
|
|
620
|
+
name: p.metadata?.name,
|
|
621
|
+
role: podRole(p, cluster),
|
|
622
|
+
ready: podReady(p),
|
|
623
|
+
node: p.spec?.nodeName,
|
|
624
|
+
}))
|
|
625
|
+
.sort((a, b) => (a.role === 'primary' ? -1 : b.role === 'primary' ? 1 : a.name.localeCompare(b.name)))
|
|
626
|
+
const readyInstances = typeof cluster?.status?.readyInstances === 'number' ? cluster.status.readyInstances : null
|
|
627
|
+
const desired = typeof cluster?.spec?.instances === 'number' ? cluster.spec.instances : null
|
|
628
|
+
|
|
629
|
+
const window = recoveryWindowFor(cluster, stores)
|
|
630
|
+
const wal = walFact(cluster)
|
|
631
|
+
const storesCov = coverageOf(resp, 'objectStores')
|
|
632
|
+
const storesUnreadable = !!getCNPGClusterBarmanPlugin(cluster)?.barmanObjectName && !coverageReadable(storesCov, ns)
|
|
633
|
+
const protection: CNPGProtectionFacts = {
|
|
634
|
+
schedule: scheduleFact(cluster, resp.objects.scheduledBackups ?? [], coverageOf(resp, 'scheduledBackups')),
|
|
635
|
+
destination: destinationFact(cluster),
|
|
636
|
+
lastSuccessfulBackup: lastBackupFact(cluster, resp.objects.backups ?? [], coverageOf(resp, 'backups'), window, storesUnreadable ? storesCov : null),
|
|
637
|
+
walArchiving: wal,
|
|
638
|
+
// The latest recoverable point follows WAL archiving, not the last base
|
|
639
|
+
// backup, and no status reports it; only failing archiving stops it.
|
|
640
|
+
recoveryWindow: window?.from
|
|
641
|
+
? {
|
|
642
|
+
text: 'Recoverable window',
|
|
643
|
+
tone: wal.tone === 'unhealthy' ? 'degraded' : 'neutral',
|
|
644
|
+
from: window.from,
|
|
645
|
+
source: `ObjectStore ${window.store} status (earliest point)`,
|
|
646
|
+
}
|
|
647
|
+
: storesUnreadable
|
|
648
|
+
? { text: coverageUnavailableText(storesCov, 'ObjectStores'), tone: 'unknown' }
|
|
649
|
+
: { text: 'Not reported', tone: 'unknown' },
|
|
650
|
+
restoreValidation: restoreValidationFact(cluster, clusters, resp.objects.backups ?? [], coverageReadable(coverageOf(resp, 'backups'), ns)),
|
|
651
|
+
}
|
|
652
|
+
const problems = problemsFor(cluster, resp.issues ?? [], resp.audit ?? [], children)
|
|
653
|
+
const categories = new Set<CNPGProblemCategory>(
|
|
654
|
+
problems.filter((p) => p.severity !== 'posture').map((p) => p.category),
|
|
655
|
+
)
|
|
656
|
+
const replica = cluster?.spec?.replica?.enabled ? { source: cluster.spec.replica.source } : null
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
key: key(ns, name),
|
|
660
|
+
namespace: ns,
|
|
661
|
+
name,
|
|
662
|
+
cluster,
|
|
663
|
+
controllerStatus: { text: status.text, level: status.level },
|
|
664
|
+
instances: { ready: readyInstances, desired },
|
|
665
|
+
pods: instancePods,
|
|
666
|
+
replicaCluster: replica,
|
|
667
|
+
hibernated,
|
|
668
|
+
pgVersion: pgVersion(cluster),
|
|
669
|
+
catalog: catalogRef(cluster),
|
|
670
|
+
replication: replicationFact(cluster, instancePods, hibernated, coverageOf(resp, 'pods')),
|
|
671
|
+
protection: { ...protection, summary: protectionSummary(protection) },
|
|
672
|
+
declarations: declarationsFor(cluster, resp),
|
|
673
|
+
poolers: poolers
|
|
674
|
+
.filter((p) => p.metadata?.namespace === ns && specClusterName(p) === name)
|
|
675
|
+
.map((p) => p.metadata?.name),
|
|
676
|
+
poolersKnown: coverageReadable(coverageOf(resp, 'poolers'), ns),
|
|
677
|
+
problems,
|
|
678
|
+
attention: problems.some((p) => p.severity !== 'posture'),
|
|
679
|
+
categories,
|
|
680
|
+
gitops: cnpgGitOpsSource(cluster),
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
rows.sort((a, b) => Number(b.attention) - Number(a.attention) || a.namespace.localeCompare(b.namespace) || a.name.localeCompare(b.name))
|
|
685
|
+
|
|
686
|
+
const categoryCounts = { availability: 0, protection: 0, declarations: 0, pooling: 0 } as Record<CNPGProblemCategory, number>
|
|
687
|
+
for (const r of rows) for (const c of r.categories) categoryCounts[c]++
|
|
688
|
+
|
|
689
|
+
const incompleteKinds = CNPG_WORKSPACE_KEYS.filter((k) => {
|
|
690
|
+
const s = coverageOf(resp, k).state
|
|
691
|
+
return s === 'partial' || s === 'denied' || s === 'syncing' || s === 'error'
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
rows,
|
|
696
|
+
attentionCount: rows.filter((r) => r.attention).length,
|
|
697
|
+
categoryCounts,
|
|
698
|
+
incompleteKinds,
|
|
699
|
+
}
|
|
700
|
+
}
|