@skyhook-io/k8s-ui 1.16.2 → 1.17.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.
Files changed (152) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationDetail.test.tsx +15 -0
  3. package/src/components/applications/ApplicationDetail.tsx +17 -15
  4. package/src/components/checks/types.ts +2 -13
  5. package/src/components/cnpg/CNPGBackupSummary.tsx +223 -0
  6. package/src/components/cnpg/CNPGClusterSummary.tsx +222 -0
  7. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +264 -0
  8. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +80 -0
  9. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +176 -0
  10. package/src/components/cnpg/CNPGObjectSummary.test.tsx +236 -0
  11. package/src/components/cnpg/CNPGPoolerSummary.tsx +70 -0
  12. package/src/components/cnpg/CNPGSharedSummary.tsx +88 -0
  13. package/src/components/cnpg/index.ts +14 -0
  14. package/src/components/cnpg/primitives.tsx +134 -0
  15. package/src/components/cnpg/relations.test.ts +304 -0
  16. package/src/components/cnpg/relations.ts +417 -0
  17. package/src/components/cnpg/workspace.test.ts +285 -0
  18. package/src/components/cnpg/workspace.ts +700 -0
  19. package/src/components/gitops/GitOpsTableView.tsx +9 -24
  20. package/src/components/gitops/detail-helpers.ts +1 -1
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +5 -12
  22. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +2 -5
  23. package/src/components/issues/IssuesView.tsx +10 -1
  24. package/src/components/issues/issue-timing.ts +2 -2
  25. package/src/components/issues/issues.test.ts +1 -1
  26. package/src/components/issues/types.ts +24 -0
  27. package/src/components/logs/WorkloadLogsViewer.notice.test.tsx +59 -0
  28. package/src/components/logs/WorkloadLogsViewer.tsx +17 -3
  29. package/src/components/logs/useLogBuffer.test.ts +17 -0
  30. package/src/components/logs/useLogStream.ts +11 -0
  31. package/src/components/resources/ResourcesSidebar.test.tsx +76 -0
  32. package/src/components/resources/ResourcesSidebar.tsx +183 -11
  33. package/src/components/resources/ResourcesView.tsx +12 -9
  34. package/src/components/resources/index.ts +1 -1
  35. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +3 -3
  36. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +6 -5
  37. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +2 -2
  38. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +2 -2
  42. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +2 -1
  44. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +25 -0
  45. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +16 -7
  46. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/CNPGShared.tsx +2 -2
  48. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  49. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -2
  50. package/src/components/resources/renderers/CronJobRenderer.tsx +6 -4
  51. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +24 -0
  53. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +1 -1
  54. package/src/components/resources/renderers/EventRenderer.tsx +3 -3
  55. package/src/components/resources/renderers/ExperimentRenderer.tsx +2 -2
  56. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -2
  57. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +2 -2
  58. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -4
  59. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +11 -24
  60. package/src/components/resources/renderers/GatewayRouteReferences.test.tsx +63 -0
  61. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -3
  62. package/src/components/resources/renderers/HPARenderer.tsx +2 -2
  63. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +12 -25
  64. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -3
  65. package/src/components/resources/renderers/IngressClassReferences.test.tsx +52 -0
  66. package/src/components/resources/renderers/IngressClassRenderer.tsx +10 -3
  67. package/src/components/resources/renderers/IngressRenderer.tsx +6 -2
  68. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -14
  69. package/src/components/resources/renderers/KedaCredentials.test.tsx +85 -0
  70. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  71. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +6 -3
  72. package/src/components/resources/renderers/KustomizationRenderer.tsx +2 -2
  73. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +4 -3
  75. package/src/components/resources/renderers/LeaseRenderer.tsx +3 -15
  76. package/src/components/resources/renderers/NamespaceRenderer.test.ts +69 -2
  77. package/src/components/resources/renderers/NamespaceRenderer.tsx +6 -1
  78. package/src/components/resources/renderers/NodeRenderer.test.tsx +81 -1
  79. package/src/components/resources/renderers/NodeRenderer.tsx +56 -15
  80. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -3
  81. package/src/components/resources/renderers/PodClassReferences.test.tsx +42 -0
  82. package/src/components/resources/renderers/PodNomination.test.tsx +57 -0
  83. package/src/components/resources/renderers/PodRenderer.tsx +21 -7
  84. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  85. package/src/components/resources/renderers/RolloutRenderer.tsx +3 -2
  86. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  87. package/src/components/resources/renderers/SecretRenderer.test.tsx +88 -0
  88. package/src/components/resources/renderers/SecretRenderer.tsx +34 -5
  89. package/src/components/resources/renderers/ServiceRenderer.test.tsx +43 -0
  90. package/src/components/resources/renderers/ServiceRenderer.tsx +11 -3
  91. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +8 -21
  92. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +5 -7
  93. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +3 -3
  94. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +2 -2
  95. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +3 -3
  96. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +2 -2
  97. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  98. package/src/components/resources/renderers/WorkflowRenderer.tsx +4 -3
  99. package/src/components/resources/renderers/kyverno-modern-cells.tsx +1 -1
  100. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +2 -2
  101. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +1 -1
  102. package/src/components/resources/resource-utils-cnpg.ts +3 -7
  103. package/src/components/resources/resource-utils-keda.ts +1 -7
  104. package/src/components/resources/resource-utils-kyverno.ts +1 -7
  105. package/src/components/resources/resource-utils-velero.ts +0 -12
  106. package/src/components/resources/resource-utils.ts +17 -33
  107. package/src/components/resources/status-overclaim.test.ts +1 -1
  108. package/src/components/shared/DrainPlanDialog.tsx +1 -0
  109. package/src/components/shared/GitOpsWriteWarning.test.tsx +86 -0
  110. package/src/components/shared/GitOpsWriteWarning.tsx +135 -0
  111. package/src/components/shared/ResourceActionsBar.tsx +9 -20
  112. package/src/components/shared/ResourceRendererDispatch.tsx +1 -1
  113. package/src/components/shared/SetImageDialog.test.ts +75 -6
  114. package/src/components/shared/SetImageDialog.tsx +92 -84
  115. package/src/components/shared/index.ts +2 -1
  116. package/src/components/timeline/TimelineList.tsx +187 -38
  117. package/src/components/timeline/TimelineListFold.test.tsx +56 -0
  118. package/src/components/timeline/TimelineSwimlanes.tsx +2 -10
  119. package/src/components/timeline/TimelineToolbarState.test.tsx +98 -0
  120. package/src/components/timeline/index.ts +1 -0
  121. package/src/components/timeline/timeline-filters.ts +7 -0
  122. package/src/components/topology/TopologyGraph.tsx +5 -3
  123. package/src/components/ui/ScheduleValue.test.tsx +5 -0
  124. package/src/components/ui/ScheduleValue.tsx +3 -2
  125. package/src/components/ui/drawer-components.test.tsx +66 -1
  126. package/src/components/ui/drawer-components.tsx +43 -29
  127. package/src/components/workload/WorkloadView.deleted.test.tsx +58 -0
  128. package/src/components/workload/WorkloadView.deletedDialog.test.tsx +45 -0
  129. package/src/components/workload/WorkloadView.secretSave.test.tsx +62 -0
  130. package/src/components/workload/WorkloadView.tsx +216 -48
  131. package/src/components/workload/index.ts +1 -0
  132. package/src/components/workload/workload-logs-availability.test.ts +6 -0
  133. package/src/index.ts +3 -0
  134. package/src/types/capacity.ts +10 -0
  135. package/src/types/core.ts +32 -1
  136. package/src/utils/format.test.ts +40 -2
  137. package/src/utils/format.ts +5 -1
  138. package/src/utils/gateway-references.test.ts +17 -0
  139. package/src/utils/gateway-references.ts +29 -0
  140. package/src/utils/gitops-write-guard.test.ts +401 -0
  141. package/src/utils/gitops-write-guard.ts +509 -0
  142. package/src/utils/index.ts +2 -0
  143. package/src/utils/ingress-class-references.ts +22 -0
  144. package/src/utils/log-level.ts +14 -0
  145. package/src/utils/navigation.test.ts +13 -0
  146. package/src/utils/navigation.ts +5 -0
  147. package/src/utils/node-lifecycle.test.ts +36 -0
  148. package/src/utils/node-lifecycle.ts +77 -0
  149. package/src/utils/resource-hierarchy.test.ts +32 -0
  150. package/src/utils/resource-hierarchy.ts +28 -2
  151. package/src/utils/resource-refs.test.ts +23 -0
  152. package/src/utils/resource-refs.ts +30 -0
@@ -0,0 +1,417 @@
1
+ // Pure relationship lookups between CloudNativePG objects in the workspace
2
+ // payload. Each helper answers only from what the objects record; a relation
3
+ // that cannot be established returns null or an empty list, never a guess.
4
+
5
+ import type { BadgeSeverity } from '../ui/Badge'
6
+ import type { HealthLevel } from '../resources/resource-utils'
7
+ import {
8
+ CNPG_BARMAN_PLUGIN_NAME,
9
+ CNPG_GROUP,
10
+ getCNPGClusterBarmanPlugin,
11
+ getCNPGObjectStoreRecoveryWindows,
12
+ isApiGroup,
13
+ type CNPGObjectStoreRecoveryWindow,
14
+ } from '../resources/resource-utils-cnpg'
15
+ import {
16
+ cnpgIssueCategory,
17
+ coverageReadable,
18
+ type CNPGFact,
19
+ type CNPGProblem,
20
+ type CNPGWorkspaceIssue,
21
+ type CNPGWorkspaceKey,
22
+ type CNPGWorkspaceResponse,
23
+ } from './workspace'
24
+
25
+ export interface CNPGObjectRef {
26
+ kind: string
27
+ group: string
28
+ namespace: string
29
+ name: string
30
+ }
31
+
32
+ const SCHEDULED_BACKUP_LABEL = 'cnpg.io/scheduled-backup'
33
+
34
+ function nsOf(obj: any): string {
35
+ return obj?.metadata?.namespace ?? ''
36
+ }
37
+
38
+ function nameOf(obj: any): string {
39
+ return obj?.metadata?.name ?? ''
40
+ }
41
+
42
+ function specCluster(obj: any): string | undefined {
43
+ const n = obj?.spec?.cluster?.name
44
+ return typeof n === 'string' && n ? n : undefined
45
+ }
46
+
47
+ function parseTime(t: unknown): number {
48
+ const ms = typeof t === 'string' ? Date.parse(t) : NaN
49
+ return Number.isFinite(ms) ? ms : 0
50
+ }
51
+
52
+ /** Kind and API group both match. Kind alone is not identity: Velero also ships `Backup`. */
53
+ export function isCNPGKind(obj: any, kind: string, group: string = CNPG_GROUP): boolean {
54
+ return obj?.kind === kind && isApiGroup(obj?.apiVersion, group)
55
+ }
56
+
57
+ export function refOf(obj: any, kind: string, group: string = CNPG_GROUP): CNPGObjectRef {
58
+ return { kind, group, namespace: nsOf(obj), name: nameOf(obj) }
59
+ }
60
+
61
+ export function healthSeverity(level: HealthLevel): BadgeSeverity {
62
+ switch (level) {
63
+ case 'healthy':
64
+ return 'success'
65
+ case 'unhealthy':
66
+ return 'error'
67
+ case 'alert':
68
+ return 'alert'
69
+ case 'degraded':
70
+ return 'warning'
71
+ default:
72
+ return 'neutral'
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Workspace access
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export function workspaceList(ws: CNPGWorkspaceResponse | null | undefined, key: CNPGWorkspaceKey): any[] {
81
+ return ws?.objects?.[key] ?? []
82
+ }
83
+
84
+ /**
85
+ * Why the workspace cannot answer for `key` in `namespace`, or null when it can.
86
+ * A null workspace means the aggregate could not be read at all.
87
+ */
88
+ export function relationUnavailable(
89
+ ws: CNPGWorkspaceResponse | null | undefined,
90
+ key: CNPGWorkspaceKey,
91
+ namespace: string | undefined,
92
+ what: string,
93
+ ): string | null {
94
+ if (!ws) return `${what} could not be read`
95
+ const cov = ws.coverage?.[key] ?? { state: 'notInstalled' as const }
96
+ if (coverageReadable(cov, namespace)) return null
97
+ switch (cov.state) {
98
+ case 'denied':
99
+ case 'partial':
100
+ return `No access to ${what}`
101
+ case 'syncing':
102
+ return 'Loading…'
103
+ case 'error':
104
+ return `Could not read ${what}`
105
+ default:
106
+ return `${what} are not installed`
107
+ }
108
+ }
109
+
110
+ export function clustersIn(ws: CNPGWorkspaceResponse | null | undefined): any[] {
111
+ return workspaceList(ws, 'clusters').filter((c) => isCNPGKind(c, 'Cluster'))
112
+ }
113
+
114
+ /** The Cluster an object declares itself against (`spec.cluster.name`), if visible. */
115
+ export function targetCluster(obj: any, clusters: any[]): any | null {
116
+ const name = specCluster(obj)
117
+ if (!name) return null
118
+ return clusters.find((c) => isCNPGKind(c, 'Cluster') && nsOf(c) === nsOf(obj) && nameOf(c) === name) ?? null
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Issues
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export function issuesForObject(issues: CNPGWorkspaceIssue[] | undefined, ref: CNPGObjectRef): CNPGWorkspaceIssue[] {
126
+ const rank = { critical: 0, warning: 1 } as const
127
+ return (issues ?? [])
128
+ .filter(
129
+ (i) =>
130
+ i.kind === ref.kind &&
131
+ (i.group ?? '') === ref.group &&
132
+ (i.namespace ?? '') === ref.namespace &&
133
+ i.name === ref.name,
134
+ )
135
+ .sort((a, b) => rank[a.severity] - rank[b.severity])
136
+ }
137
+
138
+ export function problemsForObject(issues: CNPGWorkspaceIssue[] | undefined, ref: CNPGObjectRef): CNPGProblem[] {
139
+ return issuesForObject(issues, ref).map((issue) => ({
140
+ id: `${issue.id}:${issue.kind}/${issue.name}`,
141
+ severity: issue.severity,
142
+ category: cnpgIssueCategory(issue),
143
+ title: issue.message || issue.reason,
144
+ detail: issue.cause || undefined,
145
+ subject: { kind: issue.kind, group: issue.group ?? '', namespace: issue.namespace ?? '', name: issue.name },
146
+ source: 'issue',
147
+ }))
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Backups and schedules
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function scheduleOwnerRefs(backup: any): any[] {
155
+ const refs = backup?.metadata?.ownerReferences
156
+ if (!Array.isArray(refs)) return []
157
+ return refs.filter((r: any) => r?.kind === 'ScheduledBackup' && isApiGroup(r?.apiVersion, CNPG_GROUP))
158
+ }
159
+
160
+ /**
161
+ * The name of the ScheduledBackup that created a Backup. The owner reference
162
+ * is only set when the schedule's `backupOwnerReference` is `self`; the
163
+ * operator labels every Backup it creates from a schedule regardless, so the
164
+ * label is the fallback when no such owner reference exists.
165
+ */
166
+ export function scheduledBackupOf(backup: any): string | null {
167
+ const owner = scheduleOwnerRefs(backup)[0]
168
+ if (owner?.name) return owner.name
169
+ const label = backup?.metadata?.labels?.[SCHEDULED_BACKUP_LABEL]
170
+ return typeof label === 'string' && label ? label : null
171
+ }
172
+
173
+ /**
174
+ * Whether this schedule created the Backup. An owner reference must match by
175
+ * uid: a schedule deleted and recreated under the same name did not create the
176
+ * old one's Backups. The label, which carries only a name, is read only when
177
+ * no ScheduledBackup owner reference exists.
178
+ */
179
+ export function isBackupFromSchedule(backup: any, schedule: any): boolean {
180
+ if (!isCNPGKind(backup, 'Backup') || nsOf(backup) !== nsOf(schedule)) return false
181
+ const owners = scheduleOwnerRefs(backup)
182
+ if (owners.length > 0) {
183
+ const uid = schedule?.metadata?.uid
184
+ return owners.some((r: any) => r?.name === nameOf(schedule) && !!uid && r?.uid === uid)
185
+ }
186
+ return backup?.metadata?.labels?.[SCHEDULED_BACKUP_LABEL] === nameOf(schedule)
187
+ }
188
+
189
+ /** Backups a ScheduledBackup created, newest first. */
190
+ export function backupsForScheduledBackup(schedule: any, backups: any[]): any[] {
191
+ return backups.filter((b) => isBackupFromSchedule(b, schedule)).sort((a, b) => backupTime(b) - backupTime(a))
192
+ }
193
+
194
+ export function backupTime(backup: any): number {
195
+ return parseTime(backup?.status?.startedAt) || parseTime(backup?.metadata?.creationTimestamp)
196
+ }
197
+
198
+ /**
199
+ * The ObjectStore a barman-cloud plugin Backup wrote to. The Backup's own
200
+ * plugin parameters are a record of that run; the target Cluster's plugin
201
+ * configuration is only what it is configured with now, so a store taken from
202
+ * there is marked inferred.
203
+ */
204
+ export function objectStoreForBackup(backup: any, clusters: any[]): { name: string; inferred: boolean } | null {
205
+ const method = backup?.status?.method || backup?.spec?.method
206
+ if (method !== 'plugin') return null
207
+ const cfg = backup?.spec?.pluginConfiguration
208
+ if (cfg?.name !== CNPG_BARMAN_PLUGIN_NAME) return null
209
+ const own = cfg?.parameters?.barmanObjectName
210
+ if (typeof own === 'string' && own) return { name: own, inferred: false }
211
+ const cluster = targetCluster(backup, clusters)
212
+ const current = cluster ? getCNPGClusterBarmanPlugin(cluster)?.barmanObjectName : undefined
213
+ return current ? { name: current, inferred: true } : null
214
+ }
215
+
216
+ export type CNPGBackupDestination =
217
+ | { type: 'objectStore'; name: string; inferred: boolean }
218
+ | { type: 'path'; path: string }
219
+ | { type: 'volumeSnapshot' }
220
+ | { type: 'unknown' }
221
+
222
+ export function backupDestination(backup: any, clusters: any[]): CNPGBackupDestination {
223
+ const store = objectStoreForBackup(backup, clusters)
224
+ if (store) return { type: 'objectStore', ...store }
225
+ const method = backup?.status?.method || backup?.spec?.method
226
+ if (method === 'volumeSnapshot') return { type: 'volumeSnapshot' }
227
+ const path = backup?.status?.destinationPath
228
+ if (typeof path === 'string' && path) return { type: 'path', path }
229
+ return { type: 'unknown' }
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // ObjectStore
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export interface CNPGObjectStoreUser {
237
+ cluster: any
238
+ /** Key of this cluster's archive inside the store's recovery windows. */
239
+ serverName: string
240
+ }
241
+
242
+ /** Clusters in the store's namespace whose barman-cloud plugin archives to it. */
243
+ export function usersOfObjectStore(store: any, clusters: any[]): CNPGObjectStoreUser[] {
244
+ const ns = nsOf(store)
245
+ const name = nameOf(store)
246
+ const out: CNPGObjectStoreUser[] = []
247
+ for (const c of clusters) {
248
+ if (!isCNPGKind(c, 'Cluster') || nsOf(c) !== ns) continue
249
+ const plugin = getCNPGClusterBarmanPlugin(c)
250
+ if (plugin?.barmanObjectName !== name) continue
251
+ out.push({ cluster: c, serverName: plugin.serverName || nameOf(c) })
252
+ }
253
+ return out.sort((a, b) => nameOf(a.cluster).localeCompare(nameOf(b.cluster)))
254
+ }
255
+
256
+ export interface CNPGObjectStoreEvidence {
257
+ cluster: CNPGObjectRef
258
+ serverName: string
259
+ archiving: CNPGFact
260
+ window: CNPGObjectStoreRecoveryWindow | null
261
+ }
262
+
263
+ export interface CNPGObjectStoreHealth {
264
+ summary: CNPGFact
265
+ evidence: CNPGObjectStoreEvidence[]
266
+ }
267
+
268
+ function archivingFact(cluster: any): CNPGFact {
269
+ const conds = cluster?.status?.conditions
270
+ const c = Array.isArray(conds) ? conds.find((x: any) => x?.type === 'ContinuousArchiving') : null
271
+ if (!c) return { text: 'WAL archiving not reported', tone: 'unknown' }
272
+ if (c.status === 'True') return { text: 'WAL archiving', tone: 'healthy', at: c.lastTransitionTime }
273
+ if (c.status === 'False') {
274
+ return { text: c.message ? `WAL archiving failing · ${c.message}` : 'WAL archiving failing', tone: 'unhealthy', at: c.lastTransitionTime }
275
+ }
276
+ return { text: 'WAL archiving unknown', tone: 'unknown' }
277
+ }
278
+
279
+ /**
280
+ * Upload health for an ObjectStore, inferred from the clusters that use it.
281
+ * ObjectStore publishes no health of its own, so the only evidence is each
282
+ * user cluster's ContinuousArchiving condition and whether the store's
283
+ * recovery window for that cluster records a failure newer than its last
284
+ * success.
285
+ */
286
+ export function inferredObjectStoreHealth(store: any, users: CNPGObjectStoreUser[]): CNPGObjectStoreHealth {
287
+ const windows = getCNPGObjectStoreRecoveryWindows(store)
288
+ const evidence: CNPGObjectStoreEvidence[] = users.map((u) => ({
289
+ cluster: refOf(u.cluster, 'Cluster'),
290
+ serverName: u.serverName,
291
+ archiving: archivingFact(u.cluster),
292
+ window: windows.find((w) => w.server === u.serverName) ?? null,
293
+ }))
294
+ if (evidence.length === 0) {
295
+ return { summary: { text: 'No visible cluster uses this store', tone: 'unknown' }, evidence }
296
+ }
297
+ const failing = evidence.filter((e) => e.archiving.tone === 'unhealthy' || e.window?.failingSinceLastSuccess)
298
+ if (failing.length > 0) {
299
+ const latestFailure = failing
300
+ .map((e) => e.window?.lastFailedBackupTime ?? (e.archiving.tone === 'unhealthy' ? e.archiving.at : undefined))
301
+ .filter((t): t is string => !!t)
302
+ .sort((a, b) => parseTime(b) - parseTime(a))[0]
303
+ return {
304
+ summary: {
305
+ text: failing.length === evidence.length ? 'Uploads failing' : `Uploads failing for ${failing.length} of ${evidence.length} clusters`,
306
+ tone: 'unhealthy',
307
+ at: latestFailure,
308
+ },
309
+ evidence,
310
+ }
311
+ }
312
+ if (evidence.every((e) => e.archiving.tone === 'healthy')) {
313
+ return { summary: { text: 'Uploads succeeding', tone: 'healthy' }, evidence }
314
+ }
315
+ return { summary: { text: 'No failures reported', tone: 'unknown' }, evidence }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Declarative objects
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export function appliedFact(obj: any): CNPGFact {
323
+ const applied = obj?.status?.applied
324
+ if (applied === true) return { text: 'Applied', tone: 'healthy' }
325
+ if (applied === false) return { text: 'Not applied', tone: 'unhealthy' }
326
+ return { text: 'Pending · the operator has not reported a result yet', tone: 'unknown' }
327
+ }
328
+
329
+ export function observedGenerationFact(obj: any): CNPGFact {
330
+ const observed = obj?.status?.observedGeneration
331
+ const generation = obj?.metadata?.generation
332
+ if (typeof observed !== 'number') return { text: 'Not reported', tone: 'unknown' }
333
+ if (typeof generation !== 'number') return { text: `Generation ${observed}`, tone: 'neutral' }
334
+ if (observed >= generation) return { text: `Current · generation ${generation}`, tone: 'neutral' }
335
+ return { text: `Behind · observed ${observed}, declared ${generation}`, tone: 'degraded' }
336
+ }
337
+
338
+ /**
339
+ * A role the operator says is missing, when that role is also absent from the
340
+ * target Cluster's managed roles. Only an adjacent fact: roles may be created
341
+ * outside `spec.managed.roles`.
342
+ */
343
+ export function missingManagedRole(obj: any, cluster: any | null): string | null {
344
+ if (!cluster) return null
345
+ const msg = obj?.status?.message
346
+ if (typeof msg !== 'string') return null
347
+ const m = msg.match(/role "([^"]+)" does not exist/)
348
+ if (!m) return null
349
+ const roles = cluster?.spec?.managed?.roles
350
+ const names = Array.isArray(roles) ? roles.map((r: any) => r?.name) : []
351
+ return names.includes(m[1]) ? null : m[1]
352
+ }
353
+
354
+ export { cnpgGitOpsSource as gitopsSourceOf } from './workspace'
355
+
356
+ /** Publications and Subscriptions on the same Cluster and PostgreSQL database. */
357
+ export function replicationForDatabase(
358
+ database: any,
359
+ publications: any[],
360
+ subscriptions: any[],
361
+ ): { publications: any[]; subscriptions: any[] } {
362
+ const ns = nsOf(database)
363
+ const cluster = specCluster(database)
364
+ const dbname = database?.spec?.name
365
+ const match = (o: any, kind: string) =>
366
+ isCNPGKind(o, kind) && !!cluster && nsOf(o) === ns && specCluster(o) === cluster && !!dbname && o?.spec?.dbname === dbname
367
+ return {
368
+ publications: publications.filter((p) => match(p, 'Publication')),
369
+ subscriptions: subscriptions.filter((s) => match(s, 'Subscription')),
370
+ }
371
+ }
372
+
373
+ /** The Database object declaring the PostgreSQL database a Publication/Subscription runs in. */
374
+ export function databaseForDeclaration(obj: any, databases: any[]): any | null {
375
+ const cluster = specCluster(obj)
376
+ const dbname = obj?.spec?.dbname
377
+ if (!cluster || !dbname) return null
378
+ return (
379
+ databases.find(
380
+ (d) => isCNPGKind(d, 'Database') && nsOf(d) === nsOf(obj) && specCluster(d) === cluster && d?.spec?.name === dbname,
381
+ ) ?? null
382
+ )
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Image catalogs
387
+ // ---------------------------------------------------------------------------
388
+
389
+ export interface CNPGImageCatalogUser {
390
+ cluster: any
391
+ major: number | null
392
+ }
393
+
394
+ /**
395
+ * Clusters pinned to a catalog through `spec.imageCatalogRef`. An ImageCatalog
396
+ * is namespace-local; a ClusterImageCatalog is referenceable from any
397
+ * namespace. A ref without `kind` means ImageCatalog.
398
+ */
399
+ export function clustersUsingCatalog(catalog: any, clusters: any[]): CNPGImageCatalogUser[] {
400
+ const kind = catalog?.kind
401
+ if (kind !== 'ImageCatalog' && kind !== 'ClusterImageCatalog') return []
402
+ if (!isApiGroup(catalog?.apiVersion, CNPG_GROUP)) return []
403
+ const name = nameOf(catalog)
404
+ return clusters
405
+ .filter((c) => {
406
+ if (!isCNPGKind(c, 'Cluster')) return false
407
+ const ref = c?.spec?.imageCatalogRef
408
+ if (!ref?.name || ref.name !== name) return false
409
+ if ((ref.kind || 'ImageCatalog') !== kind) return false
410
+ return kind === 'ClusterImageCatalog' || nsOf(c) === nsOf(catalog)
411
+ })
412
+ .map((c) => {
413
+ const major = c?.spec?.imageCatalogRef?.major
414
+ return { cluster: c, major: typeof major === 'number' ? major : null }
415
+ })
416
+ .sort((a, b) => nsOf(a.cluster).localeCompare(nsOf(b.cluster)) || nameOf(a.cluster).localeCompare(nameOf(b.cluster)))
417
+ }