@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.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  3. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  4. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  5. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  6. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  7. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  8. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  9. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  10. package/src/components/cnpg/index.ts +14 -0
  11. package/src/components/cnpg/primitives.tsx +134 -0
  12. package/src/components/cnpg/relations.test.ts +304 -0
  13. package/src/components/cnpg/relations.ts +417 -0
  14. package/src/components/cnpg/workspace.test.ts +285 -0
  15. package/src/components/cnpg/workspace.ts +700 -0
  16. package/src/components/issues/IssuesView.tsx +10 -1
  17. package/src/components/issues/types.ts +24 -0
  18. package/src/components/logs/WorkloadLogsViewer.tsx +12 -3
  19. package/src/components/logs/useLogBuffer.test.ts +17 -0
  20. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  21. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  22. package/src/components/resources/ResourcesView.tsx +5 -1
  23. package/src/components/resources/index.ts +1 -1
  24. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  25. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  26. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  27. package/src/components/resources/resource-utils-cnpg.ts +3 -3
  28. package/src/components/resources/status-overclaim.test.ts +1 -1
  29. package/src/components/timeline/TimelineList.tsx +185 -23
  30. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  31. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  32. package/src/components/timeline/index.ts +1 -0
  33. package/src/components/timeline/timeline-filters.ts +7 -0
  34. package/src/components/workload/WorkloadView.tsx +96 -5
  35. package/src/components/workload/index.ts +1 -0
  36. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  37. package/src/index.ts +3 -0
  38. 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
+ }