@skyhook-io/k8s-ui 1.17.0 → 1.17.1

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 (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -1,9 +1,9 @@
1
+ import { normalizeURLForComparison } from '../../utils/url-path'
2
+ import { cnpgBackupDeclaration, cnpgBackupDestinationBlocker, cnpgBackupBlockerText } from '../../utils/cnpg-backup'
1
3
  // Pure relationship lookups between CloudNativePG objects in the workspace
2
4
  // payload. Each helper answers only from what the objects record; a relation
3
5
  // that cannot be established returns null or an empty list, never a guess.
4
6
 
5
- import type { BadgeSeverity } from '../ui/Badge'
6
- import type { HealthLevel } from '../resources/resource-utils'
7
7
  import {
8
8
  CNPG_BARMAN_PLUGIN_NAME,
9
9
  CNPG_GROUP,
@@ -12,15 +12,8 @@ import {
12
12
  isApiGroup,
13
13
  type CNPGObjectStoreRecoveryWindow,
14
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'
15
+ import { cnpgIssueCategory, cnpgIssueOrigin, cnpgIssueText, cnpgCoverageGap, coverageReadable, type CNPGProblem, type CNPGWorkspaceIssue, type CNPGWorkspaceKey, type CNPGWorkspaceResponse } from './workspace'
16
+ import { type Fact } from '../facts'
24
17
 
25
18
  export interface CNPGObjectRef {
26
19
  kind: string
@@ -58,21 +51,6 @@ export function refOf(obj: any, kind: string, group: string = CNPG_GROUP): CNPGO
58
51
  return { kind, group, namespace: nsOf(obj), name: nameOf(obj) }
59
52
  }
60
53
 
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
54
  // ---------------------------------------------------------------------------
77
55
  // Workspace access
78
56
  // ---------------------------------------------------------------------------
@@ -94,17 +72,7 @@ export function relationUnavailable(
94
72
  if (!ws) return `${what} could not be read`
95
73
  const cov = ws.coverage?.[key] ?? { state: 'notInstalled' as const }
96
74
  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
- }
75
+ return cnpgCoverageGap(cov, what, namespace, `${what} are not installed`)
108
76
  }
109
77
 
110
78
  export function clustersIn(ws: CNPGWorkspaceResponse | null | undefined): any[] {
@@ -115,7 +83,8 @@ export function clustersIn(ws: CNPGWorkspaceResponse | null | undefined): any[]
115
83
  export function targetCluster(obj: any, clusters: any[]): any | null {
116
84
  const name = specCluster(obj)
117
85
  if (!name) return null
118
- return clusters.find((c) => isCNPGKind(c, 'Cluster') && nsOf(c) === nsOf(obj) && nameOf(c) === name) ?? null
86
+ const cluster = clusters.find((c) => isCNPGKind(c, 'Cluster') && nsOf(c) === nsOf(obj) && nameOf(c) === name) ?? null
87
+ return isCNPGKind(obj, 'Backup') && !cnpgBackupMatchesCluster(obj, cluster) ? null : cluster
119
88
  }
120
89
 
121
90
  // ---------------------------------------------------------------------------
@@ -140,10 +109,10 @@ export function problemsForObject(issues: CNPGWorkspaceIssue[] | undefined, ref:
140
109
  id: `${issue.id}:${issue.kind}/${issue.name}`,
141
110
  severity: issue.severity,
142
111
  category: cnpgIssueCategory(issue),
143
- title: issue.message || issue.reason,
144
- detail: issue.cause || undefined,
112
+ ...cnpgIssueText(issue),
145
113
  subject: { kind: issue.kind, group: issue.group ?? '', namespace: issue.namespace ?? '', name: issue.name },
146
114
  source: 'issue',
115
+ origin: cnpgIssueOrigin(issue),
147
116
  }))
148
117
  }
149
118
 
@@ -195,20 +164,50 @@ export function backupTime(backup: any): number {
195
164
  return parseTime(backup?.status?.startedAt) || parseTime(backup?.metadata?.creationTimestamp)
196
165
  }
197
166
 
167
+ export function cnpgBackupMatchesCluster(backup: any, cluster: any): boolean {
168
+ if (!cluster || backup?.spec?.cluster?.name !== cluster.metadata?.name || backup.metadata?.namespace !== cluster.metadata?.namespace) return false
169
+ const owner = backup.metadata?.ownerReferences?.find((ref: any) => ref.kind === 'Cluster' && isApiGroup(ref.apiVersion, CNPG_GROUP))
170
+ const recordedUID = backup.status?.pluginMetadata?.clusterUID || owner?.uid
171
+ if (recordedUID && recordedUID !== cluster.metadata?.uid) return false
172
+ const began = Date.parse(backup.status?.startedAt ?? backup.metadata?.creationTimestamp ?? '')
173
+ const created = Date.parse(cluster.metadata?.creationTimestamp ?? '')
174
+ return !(Number.isFinite(began) && Number.isFinite(created) && began < created)
175
+ }
176
+
177
+ export type CNPGArchiveSource =
178
+ | { kind: 'objectStore'; objectStore: string; serverName: string }
179
+ | { kind: 'inTree'; barmanObjectStore: Record<string, unknown>; serverName: string }
180
+
181
+ export function cnpgArchiveMatchesCluster(cluster: any, source: CNPGArchiveSource): boolean {
182
+ if (source.kind === 'objectStore') {
183
+ const plugin = getCNPGClusterBarmanPlugin(cluster)
184
+ return plugin?.barmanObjectName === source.objectStore &&
185
+ (plugin?.serverName || cluster.metadata?.name) === source.serverName
186
+ }
187
+ const archive = cluster.spec?.backup?.barmanObjectStore
188
+ const path = source.barmanObjectStore.destinationPath
189
+ const endpoint = source.barmanObjectStore.endpointURL
190
+ const destination = typeof path === 'string' ? normalizeURLForComparison(path) : null
191
+ const endpointKey = normalizeURLForComparison(typeof endpoint === 'string' ? endpoint : '')
192
+ return !!archive?.destinationPath && typeof path === 'string' && !!path &&
193
+ destination !== null && endpointKey !== null &&
194
+ (archive.serverName || cluster.metadata?.name) === source.serverName &&
195
+ normalizeURLForComparison(archive.destinationPath) === destination &&
196
+ normalizeURLForComparison(archive.endpointURL || '') === endpointKey
197
+ }
198
+
198
199
  /**
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.
200
+ * Barman-cloud ignores Backup and ScheduledBackup parameters. The current
201
+ * Cluster plugin chooses the ObjectStore; it may have changed since a Backup
202
+ * ran, so the historical destination remains inferred.
203
203
  */
204
204
  export function objectStoreForBackup(backup: any, clusters: any[]): { name: string; inferred: boolean } | null {
205
205
  const method = backup?.status?.method || backup?.spec?.method
206
206
  if (method !== 'plugin') return null
207
207
  const cfg = backup?.spec?.pluginConfiguration
208
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
209
  const cluster = targetCluster(backup, clusters)
210
+ if (backup.kind === 'Backup' && !cnpgBackupMatchesCluster(backup, cluster)) return null
212
211
  const current = cluster ? getCNPGClusterBarmanPlugin(cluster)?.barmanObjectName : undefined
213
212
  return current ? { name: current, inferred: true } : null
214
213
  }
@@ -229,6 +228,14 @@ export function backupDestination(backup: any, clusters: any[]): CNPGBackupDesti
229
228
  return { type: 'unknown' }
230
229
  }
231
230
 
231
+ /** Whether the Cluster declares a destination for this schedule's method; unread Clusters stay unknown. */
232
+ export function cnpgScheduleDestinationBlocker(schedule: any, clusters: any[]): string | null {
233
+ const cluster = targetCluster(schedule, clusters)
234
+ if (!cluster) return null
235
+ const blocker = cnpgBackupDestinationBlocker(cnpgBackupDeclaration(cluster), schedule.spec?.method || 'barmanObjectStore', schedule.spec?.pluginConfiguration?.name)
236
+ return blocker ? cnpgBackupBlockerText(blocker) : null
237
+ }
238
+
232
239
  // ---------------------------------------------------------------------------
233
240
  // ObjectStore
234
241
  // ---------------------------------------------------------------------------
@@ -256,16 +263,16 @@ export function usersOfObjectStore(store: any, clusters: any[]): CNPGObjectStore
256
263
  export interface CNPGObjectStoreEvidence {
257
264
  cluster: CNPGObjectRef
258
265
  serverName: string
259
- archiving: CNPGFact
266
+ archiving: Fact
260
267
  window: CNPGObjectStoreRecoveryWindow | null
261
268
  }
262
269
 
263
270
  export interface CNPGObjectStoreHealth {
264
- summary: CNPGFact
271
+ summary: Fact
265
272
  evidence: CNPGObjectStoreEvidence[]
266
273
  }
267
274
 
268
- function archivingFact(cluster: any): CNPGFact {
275
+ function archivingFact(cluster: any): Fact {
269
276
  const conds = cluster?.status?.conditions
270
277
  const c = Array.isArray(conds) ? conds.find((x: any) => x?.type === 'ContinuousArchiving') : null
271
278
  if (!c) return { text: 'WAL archiving not reported', tone: 'unknown' }
@@ -319,14 +326,17 @@ export function inferredObjectStoreHealth(store: any, users: CNPGObjectStoreUser
319
326
  // Declarative objects
320
327
  // ---------------------------------------------------------------------------
321
328
 
322
- export function appliedFact(obj: any): CNPGFact {
329
+ export function appliedFact(obj: any): Fact {
330
+ if (observedGenerationFact(obj).tone === 'degraded') {
331
+ return { text: 'Pending · awaiting the operator for the current spec', tone: 'unknown' }
332
+ }
323
333
  const applied = obj?.status?.applied
324
334
  if (applied === true) return { text: 'Applied', tone: 'healthy' }
325
335
  if (applied === false) return { text: 'Not applied', tone: 'unhealthy' }
326
336
  return { text: 'Pending · the operator has not reported a result yet', tone: 'unknown' }
327
337
  }
328
338
 
329
- export function observedGenerationFact(obj: any): CNPGFact {
339
+ export function observedGenerationFact(obj: any): Fact {
330
340
  const observed = obj?.status?.observedGeneration
331
341
  const generation = obj?.metadata?.generation
332
342
  if (typeof observed !== 'number') return { text: 'Not reported', tone: 'unknown' }
@@ -351,7 +361,6 @@ export function missingManagedRole(obj: any, cluster: any | null): string | null
351
361
  return names.includes(m[1]) ? null : m[1]
352
362
  }
353
363
 
354
- export { cnpgGitOpsSource as gitopsSourceOf } from './workspace'
355
364
 
356
365
  /** Publications and Subscriptions on the same Cluster and PostgreSQL database. */
357
366
  export function replicationForDatabase(
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The server's reading of a ScheduledBackup schedule (see
3
+ * /api/cnpg/scheduledbackups/{ns}/{name}/schedule-preview): parsed as
4
+ * CloudNativePG parses it, with the next runs counted as the operator counts
5
+ * them. Times are RFC 3339 UTC.
6
+ */
7
+ export interface CNPGSchedulePreview {
8
+ schedule: string
9
+ valid: boolean
10
+ error?: string
11
+ description?: string
12
+ nextRuns?: string[]
13
+ /** The first run is due already: the operator creates a backup as soon as it reconciles. */
14
+ runsImmediately?: boolean
15
+ basis: 'lastCheckTime' | 'now'
16
+ lastCheckTime?: string
17
+ suspended?: boolean
18
+ clock?: { zone: string; declared: boolean; source: string }
19
+ }
20
+
21
+ export function formatCNPGRunTime(iso: string): { utc: string; local: string } {
22
+ const d = new Date(iso)
23
+ const pad = (n: number) => String(n).padStart(2, '0')
24
+ const utc = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
25
+ return { utc, local: d.toLocaleString() }
26
+ }
27
+
28
+ /** One line for the runs list: why the first run is when it is. */
29
+ export function cnpgScheduleBasisNote(p: CNPGSchedulePreview): string {
30
+ const clock = p.clock?.source ?? 'Operator clock is not established; upcoming times assume UTC.'
31
+ if (p.suspended) return `Suspended: nothing runs until it is resumed. ${clock}`
32
+ const basis = p.basis === 'lastCheckTime' ? "Counted from the operator's last check (status.lastCheckTime)." : 'Counted from now; the operator starts counting at its first check.'
33
+ const due = p.runsImmediately ? ` ${p.clock?.declared ? 'A run is due on the declared clock.' : 'A run is due in this UTC estimate.'} The operator takes at most one catch-up backup.` : ''
34
+ return `${basis} ${clock}${due}`
35
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { applyCNPGDisk, buildCNPGFleet, cnpgDiskFact, CNPG_WORKSPACE_KEYS, type CNPGDiskReading, type CNPGWorkspaceResponse } from './workspace'
3
+
4
+ function cluster(name: string): any {
5
+ return {
6
+ apiVersion: 'postgresql.cnpg.io/v1',
7
+ kind: 'Cluster',
8
+ metadata: { name, namespace: 'db' },
9
+ spec: { instances: 1 },
10
+ status: { phase: 'Cluster in healthy state', readyInstances: 1, currentPrimary: `${name}-1` },
11
+ }
12
+ }
13
+
14
+ function fleetOf(...names: string[]) {
15
+ const coverage: CNPGWorkspaceResponse['coverage'] = {}
16
+ for (const k of CNPG_WORKSPACE_KEYS) coverage[k] = { state: 'full' }
17
+ return buildCNPGFleet({
18
+ installed: true,
19
+ context: 'test',
20
+ namespaces: null,
21
+ coverage,
22
+ objects: { clusters: names.map(cluster) },
23
+ issues: [],
24
+ audit: [],
25
+ backupsOmitted: 0,
26
+ })
27
+ }
28
+
29
+ function reading(name: string, over: Partial<CNPGDiskReading> = {}): CNPGDiskReading {
30
+ return { namespace: 'db', name, state: 'ok', claims: 1, measured: 1, ...over }
31
+ }
32
+
33
+ const max = (ratio: number) => ({ claim: 'pg-1', instance: 'pg-1', role: 'PG_DATA', usedBytes: ratio * 10 * 1024 ** 3, capacityBytes: 10 * 1024 ** 3, ratio })
34
+
35
+ describe('cnpgDiskFact', () => {
36
+ it('never reads an unmeasured cluster as zero or healthy', () => {
37
+ for (const state of ['noSeries', 'noPrometheus', 'denied', 'error', 'notRead']) {
38
+ const f = cnpgDiskFact(reading('pg', { state, measured: 0 }))
39
+ expect(f.tone).toBe('unknown')
40
+ expect(f.text).not.toMatch(/\d/)
41
+ }
42
+ expect(cnpgDiskFact(undefined).tone).toBe('unknown')
43
+ })
44
+
45
+ it('names the fullest volume and its source, and labels partial coverage', () => {
46
+ const f = cnpgDiskFact(reading('pg', { state: 'partial', claims: 2, measured: 1, max: max(0.5) }))
47
+ expect(f.text).toBe('50% used')
48
+ expect(f.tone).toBe('healthy')
49
+ expect(f.source).toContain('data volume of pg-1')
50
+ expect(f.source).toContain('kubelet volume stats')
51
+ expect(f.source).toContain('1 of 2 volumes measured')
52
+ })
53
+
54
+ it('names the missing grant when denied', () => {
55
+ expect(cnpgDiskFact(reading('pg', { state: 'denied', grant: { verb: 'list', resource: 'persistentvolumeclaims', namespace: 'db' }, measured: 0 })).source).toBe('Needs list persistentvolumeclaims in namespace db')
56
+ })
57
+ })
58
+
59
+ describe('applyCNPGDisk', () => {
60
+ it('says when the volume stats were matched to the cluster by claim name only', () => {
61
+ const note = "Matched by namespace and claim names. Radar couldn't confirm these volume stats belong to this exact cluster (no cluster label it could check)"
62
+ const fleet = applyCNPGDisk(fleetOf('pg-a'), [reading('pg-a', { max: max(0.95), isolation: { mode: 'unverified', note } })])
63
+ const p = fleet.rows[0].problems[0]
64
+ expect(p).toMatchObject({ measuredBy: 'kubelet, matched by claim name', unverifiedMatch: true })
65
+ expect(p.detail).toContain(note)
66
+ expect(fleet.rows[0].disk?.source).toContain(note)
67
+ })
68
+
69
+ it('puts low-disk clusters into Needs attention by severity', () => {
70
+ const fleet = applyCNPGDisk(fleetOf('pg-a', 'pg-b', 'pg-c'), [
71
+ reading('pg-a', { max: max(0.85) }),
72
+ reading('pg-b', { max: max(0.95) }),
73
+ reading('pg-c', { max: max(0.4) }),
74
+ ])
75
+ expect(fleet.attentionCount).toBe(2)
76
+ const a = fleet.rows.find((r) => r.name === 'pg-a')!
77
+ const b = fleet.rows.find((r) => r.name === 'pg-b')!
78
+ const c = fleet.rows.find((r) => r.name === 'pg-c')!
79
+ expect(a.problems[0].severity).toBe('warning')
80
+ expect(b.problems[0].severity).toBe('critical')
81
+ expect(b.problems[0].source).toBe('measurement')
82
+ expect(b.problems[0].title).toBe('The data volume of pg-1 is 95% full')
83
+ expect(c.attention).toBe(false)
84
+ expect(fleet.categoryCounts.availability).toBe(2)
85
+ })
86
+
87
+ it('raises nothing without a measurement', () => {
88
+ const fleet = applyCNPGDisk(fleetOf('pg-a'), [reading('pg-a', { state: 'noPrometheus', measured: 0, reason: 'Radar is not connected to Prometheus: x' })])
89
+ expect(fleet.attentionCount).toBe(0)
90
+ expect(fleet.rows[0].disk).toMatchObject({ text: 'No usage metrics', source: 'Prometheus not connected', detail: 'Radar is not connected to Prometheus: x' })
91
+ })
92
+
93
+ it('leaves the fleet as built when no reading was requested', () => {
94
+ const base = fleetOf('pg-a')
95
+ expect(applyCNPGDisk(base, undefined)).toBe(base)
96
+ })
97
+ })
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { applyCNPGFleetMetrics, buildCNPGFleet, CNPG_WORKSPACE_KEYS, type CNPGFleetMetricsReading, type CNPGWorkspaceResponse } from './workspace'
3
+
4
+ function pod(cluster: string, n: number, role: 'primary' | 'replica'): any {
5
+ return {
6
+ metadata: { name: `${cluster}-${n}`, namespace: 'db', labels: { 'cnpg.io/cluster': cluster, 'cnpg.io/instanceRole': role } },
7
+ status: { conditions: [{ type: 'Ready', status: 'True' }] },
8
+ }
9
+ }
10
+
11
+ function fleet() {
12
+ const coverage: CNPGWorkspaceResponse['coverage'] = {}
13
+ for (const k of CNPG_WORKSPACE_KEYS) coverage[k] = { state: 'full' }
14
+ const cluster = (name: string, instances: number) => ({
15
+ apiVersion: 'postgresql.cnpg.io/v1',
16
+ kind: 'Cluster',
17
+ metadata: { name, namespace: 'db' },
18
+ spec: { instances },
19
+ status: { phase: 'Cluster in healthy state', readyInstances: instances, currentPrimary: `${name}-1` },
20
+ })
21
+ return buildCNPGFleet({
22
+ installed: true,
23
+ context: 'test',
24
+ namespaces: null,
25
+ coverage,
26
+ objects: {
27
+ clusters: [cluster('ha', 2), cluster('solo', 1), cluster('dark', 2)],
28
+ pods: [pod('ha', 1, 'primary'), pod('ha', 2, 'replica'), pod('solo', 1, 'primary'), pod('dark', 1, 'primary'), pod('dark', 2, 'replica')],
29
+ },
30
+ issues: [],
31
+ audit: [],
32
+ backupsOmitted: 0,
33
+ })
34
+ }
35
+
36
+ const reading = (name: string, lag: CNPGFleetMetricsReading['lag'], growth: CNPGFleetMetricsReading['growth'] = { state: 'noSeries' }): CNPGFleetMetricsReading => ({
37
+ namespace: 'db',
38
+ name,
39
+ lag,
40
+ growth,
41
+ })
42
+
43
+ const row = (f: ReturnType<typeof fleet>, name: string) => f.rows.find((r) => r.name === name)!
44
+
45
+ describe('applyCNPGFleetMetrics', () => {
46
+ it('replaces "lag unknown" with the measured standby lag and names its source', () => {
47
+ const f = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'ok', seconds: 7.2, pod: 'ha-2' })], { source: 'prometheus', lagSource: 'Prometheus cnpg_pg_replication_lag' })
48
+ const r = row(f, 'ha')
49
+ expect(r.replication.text).toBe('1/1 Pods ready · lag 7.2 s')
50
+ expect(r.replication.tone).toBe('degraded')
51
+ expect(r.replication.source).toContain('ha-2')
52
+ expect(r.replication.source).toContain('cnpg_pg_replication_lag')
53
+ })
54
+
55
+ it('says why lag is unknown instead of zero, and leaves non-replica facts alone', () => {
56
+ const f = applyCNPGFleetMetrics(fleet(), [reading('dark', { state: 'noSeries', reason: 'no exporter series' })], { source: 'prometheus' })
57
+ expect(row(f, 'dark').replication.text).toBe('1/1 Pods ready · lag unknown')
58
+ expect(row(f, 'dark').replication.source).toBeTruthy()
59
+ expect(row(f, 'dark').replication.tone).toBe('unknown')
60
+ expect(row(f, 'solo').replication.text).toBe('Single instance')
61
+
62
+ const denied = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'denied', grant: { verb: 'get', resource: 'pods', namespace: 'db' } })], { source: 'prometheus' })
63
+ expect(row(denied, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown')
64
+ expect(row(denied, 'ha').replication.source).toBe('Needs get pods in namespace db')
65
+
66
+ const none = applyCNPGFleetMetrics(fleet(), undefined, { source: 'none', reason: 'Radar is not connected to Prometheus' })
67
+ expect(row(none, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown')
68
+ expect(row(none, 'ha').replication.source).toBe('Prometheus not connected')
69
+ expect(row(none, 'ha').replication.detail).toBe('Radar is not connected to Prometheus')
70
+ expect(row(none, 'ha').diskGrowth).toBeUndefined()
71
+ })
72
+
73
+ it('keeps the fleet untouched when no reading was requested', () => {
74
+ const base = fleet()
75
+ expect(applyCNPGFleetMetrics(base, undefined, undefined)).toBe(base)
76
+ expect(row(base, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown')
77
+ })
78
+
79
+ it('reports disk growth only when measured', () => {
80
+ const f = applyCNPGFleetMetrics(
81
+ fleet(),
82
+ [reading('ha', { state: 'ok', seconds: 0.2, pod: 'ha-2' }, { state: 'ok', bytesPerHour: 1024 ** 3 / 24, claim: 'ha-1', instance: 'ha-1' })],
83
+ { source: 'prometheus', growthSource: 'deriv' },
84
+ )
85
+ expect(row(f, 'ha').diskGrowth?.text).toBe('+1.0 GiB/day')
86
+ expect(row(f, 'ha').replication.tone).toBe('healthy')
87
+ expect(row(f, 'dark').diskGrowth).toBeUndefined()
88
+ })
89
+ })
90
+
91
+ describe('sustained replication lag', () => {
92
+ const src = { source: 'prometheus' as const, lagSource: 'Prometheus cnpg_pg_replication_lag' }
93
+
94
+ it('puts a cluster into Needs attention only when lag stayed high for the whole window', () => {
95
+ const f = applyCNPGFleetMetrics(
96
+ fleet(),
97
+ [
98
+ reading('ha', { state: 'ok', seconds: 95, pod: 'ha-2', sustainedSeconds: 40, sustainedPod: 'ha-2', sustainedWindow: '10m0s' }),
99
+ reading('dark', { state: 'ok', seconds: 120, pod: 'dark-2' }),
100
+ ],
101
+ src,
102
+ )
103
+ const ha = row(f, 'ha')
104
+ expect(ha.attention).toBe(true)
105
+ expect(ha.problems[0]).toMatchObject({ severity: 'warning', category: 'availability', source: 'measurement' })
106
+ expect(ha.problems[0].title).toBe('ha-2 ≥ 40 s behind in every sample for 10 min')
107
+ expect(ha.problems[0]).toMatchObject({ measuredBy: 'Prometheus' })
108
+ expect(ha.problems[0].detail).not.toMatch(/cnpg_/)
109
+ expect(ha.problems[0].detail).toContain("If Prometheus missed some scrapes, those moments aren't included")
110
+ expect(ha.problems[0].shortTitle).toBe('ha-2: all samples ≥ 40 s behind (10 min)')
111
+ expect(ha.problems[0].detail).not.toContain('whole window')
112
+ expect(row(f, 'dark').attention).toBe(false)
113
+ expect(f.attentionCount).toBe(1)
114
+ })
115
+
116
+ it('says when the series were matched to the cluster by Pod name only', () => {
117
+ const note = "Matched by namespace and Pod names. Radar couldn't confirm these series belong to this exact cluster (no cluster label it could check)"
118
+ const f = applyCNPGFleetMetrics(
119
+ fleet(),
120
+ [reading('ha', { state: 'ok', seconds: 95, pod: 'ha-2', sustainedSeconds: 40, sustainedPod: 'ha-2', sustainedWindow: '10m0s', isolation: { mode: 'unverified', note } })],
121
+ src,
122
+ )
123
+ const p = row(f, 'ha').problems[0]
124
+ expect(p.measuredBy).toBe('Prometheus, matched by Pod name')
125
+ expect(p.unverifiedMatch).toBe(true)
126
+ expect(p.detail).toContain(note)
127
+ const verified = applyCNPGFleetMetrics(
128
+ fleet(),
129
+ [reading('ha', { state: 'ok', seconds: 95, pod: 'ha-2', sustainedSeconds: 40, sustainedPod: 'ha-2', sustainedWindow: '10m0s', isolation: { mode: 'verified', note: 'x' } })],
130
+ src,
131
+ )
132
+ expect(row(verified, 'ha').problems[0].measuredBy).toBe('Prometheus')
133
+ })
134
+
135
+ it('escalates to critical past five minutes and ignores a floor under 30 s', () => {
136
+ const f = applyCNPGFleetMetrics(
137
+ fleet(),
138
+ [
139
+ reading('ha', { state: 'ok', seconds: 400, pod: 'ha-2', sustainedSeconds: 360, sustainedPod: 'ha-2', sustainedWindow: '10m0s' }),
140
+ reading('dark', { state: 'ok', seconds: 20, pod: 'dark-2', sustainedSeconds: 12, sustainedPod: 'dark-2', sustainedWindow: '10m0s' }),
141
+ ],
142
+ src,
143
+ )
144
+ expect(row(f, 'ha').problems[0]).toMatchObject({ severity: 'critical', title: 'ha-2 ≥ 6 min behind in every sample for 10 min' })
145
+ expect(row(f, 'dark').problems).toHaveLength(0)
146
+ })
147
+ })
148
+
149
+ describe('standbys that receive nothing, and the WAL slots hold', () => {
150
+ const src = { source: 'prometheus' as const, lagSource: 'Prometheus cnpg_pg_replication_lag' }
151
+ it('a standby whose WAL receiver is down is a problem, never "lag 0 s"', () => {
152
+ const f = applyCNPGFleetMetrics(
153
+ fleet(),
154
+ [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: ['ha-2'], receiverDownWindow: '5m0s' })],
155
+ src,
156
+ )
157
+ const r = row(f, 'ha')
158
+ expect(r.replication.text).toBe('1/1 Pods ready · ha-2 not receiving WAL')
159
+ expect(r.replication.tone).toBe('unhealthy')
160
+ expect(r.attention).toBe(true)
161
+ const p = r.problems.find((x) => x.id === 'standby:db/ha:ha-2')!
162
+ expect(p).toMatchObject({ severity: 'critical', title: 'ha-2 is not receiving WAL from the primary', subject: { kind: 'Pod', name: 'ha-2' }, measuredBy: 'Prometheus' })
163
+ expect(p.detail).toContain('Its WAL receiver was down in every sample Prometheus recorded over the last 5 minutes')
164
+ })
165
+ it('a receiver down for less than the window is shown, not raised: a restarting standby reconnects on its own', () => {
166
+ const f = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: [], receiverDownWindow: '5m0s' })], src)
167
+ expect(row(f, 'ha').replication.text).toBe('1/1 Pods ready · ha-2 not receiving WAL')
168
+ expect(row(f, 'ha').problems.some((p) => p.id.startsWith('standby:'))).toBe(false)
169
+ })
170
+ it('lag alone does not establish streaming when the exporter reports no receiver state', () => {
171
+ const f = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiverUnknown: true })], src)
172
+ expect(row(f, 'ha').replication).toMatchObject({ text: '1/1 Pods ready · lag 0 s · streaming unverified', tone: 'unknown' })
173
+ expect(row(f, 'ha').attention).toBe(false)
174
+ })
175
+ it('raises an inactive slot only from the primary, at 1 GiB or more, naming the standby it serves', () => {
176
+ const big = 4.9e9
177
+ const f = applyCNPGFleetMetrics(
178
+ fleet(),
179
+ [
180
+ {
181
+ ...reading('ha', { state: 'ok', seconds: 0.1, pod: 'ha-2', standbys: 1, receiving: 1 }),
182
+ slots: {
183
+ state: 'ok',
184
+ inactive: [
185
+ { slot: '_cnpg_ha_2', pod: 'ha-1', role: 'primary', bytes: big },
186
+ { slot: '_cnpg_ha_2', pod: 'ha-2', role: 'standby', bytes: big },
187
+ ],
188
+ },
189
+ },
190
+ { ...reading('dark', { state: 'ok', seconds: 0.1, pod: 'dark-2', standbys: 1, receiving: 1 }), slots: { state: 'ok', inactive: [{ slot: '_cnpg_dark_9', pod: 'dark-2', role: 'standby', bytes: big }] } },
191
+ ],
192
+ src,
193
+ )
194
+ const slots = row(f, 'ha').problems.filter((p) => p.id.startsWith('slot:'))
195
+ expect(slots).toHaveLength(1)
196
+ expect(slots[0].title).toBe('Inactive slot _cnpg_ha_2 holds 4.6 GiB of WAL on ha-1 for ha-2')
197
+ expect(row(f, 'dark').problems.some((p) => p.id.startsWith('slot:'))).toBe(false)
198
+ const small = applyCNPGFleetMetrics(fleet(), [{ ...reading('ha', { state: 'ok', seconds: 0.1, pod: 'ha-2' }), slots: { state: 'ok', inactive: [{ slot: '_cnpg_ha_2', pod: 'ha-1', role: 'primary', bytes: 5e8 }] } }], src)
199
+ expect(row(small, 'ha').problems.some((p) => p.id.startsWith('slot:'))).toBe(false)
200
+ })
201
+ })
202
+
203
+ describe('none receiving needs every expected standby accounted for', () => {
204
+ it('one standby down with another unreported is a warning, not "none receive"', () => {
205
+ const f = applyCNPGFleetMetrics(
206
+ fleet(),
207
+ [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: ['ha-2'], receiverDownWindow: '5m0s' })],
208
+ { source: 'prometheus' },
209
+ )
210
+ expect(row(f, 'ha').problems.find((p) => p.id === 'standby:db/ha:ha-2')?.severity).toBe('critical')
211
+ const three = applyCNPGFleetMetrics(
212
+ fleet(),
213
+ [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 2, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: ['ha-2'], receiverDownWindow: '5m0s' })],
214
+ { source: 'prometheus' },
215
+ )
216
+ // spec.instances 2 expects one standby; two reporting with one down is not "all down".
217
+ expect(row(three, 'ha').problems.find((p) => p.id === 'standby:db/ha:ha-2')?.severity).toBe('warning')
218
+ })
219
+ })
220
+
221
+ describe('fleet lag covers the standbys that report', () => {
222
+ it('says how many standbys the lag covers, and is not healthy while one is unreported', () => {
223
+ const three = fleet()
224
+ three.rows.find((r) => r.name === 'ha')!.instances.desired = 3
225
+ const f = applyCNPGFleetMetrics(three, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', lagStandbys: 1, standbys: 1, receiving: 1 })], { source: 'prometheus' })
226
+ expect(row(f, 'ha').replication).toMatchObject({ text: expect.stringContaining('lag 0 s (1 of 2 standbys reporting)'), tone: 'unknown' })
227
+ })
228
+
229
+ it('counts the standbys whose lag was read, not those whose receiver was', () => {
230
+ const three = fleet()
231
+ three.rows.find((r) => r.name === 'ha')!.instances.desired = 3
232
+ const f = applyCNPGFleetMetrics(three, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', lagStandbys: 1, standbys: 2, receiving: 2 })], { source: 'prometheus' })
233
+ expect(row(f, 'ha').replication).toMatchObject({ text: expect.stringContaining('(1 of 2 standbys reporting)'), tone: 'unknown' })
234
+ })
235
+
236
+ it('expects every instance of a replica cluster to report, its designated primary included', () => {
237
+ const replica = fleet()
238
+ const r = replica.rows.find((x) => x.name === 'ha')!
239
+ r.instances.desired = 3
240
+ r.replicaCluster = { source: 'pg-origin' }
241
+ const f = applyCNPGFleetMetrics(replica, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-1', lagStandbys: 2, standbys: 2, receiving: 2 })], { source: 'prometheus' })
242
+ expect(row(f, 'ha').replication).toMatchObject({ text: expect.stringContaining('(2 of 3 instances reporting)'), tone: 'unknown' })
243
+ const all = applyCNPGFleetMetrics(replica, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-1', lagStandbys: 3, standbys: 3, receiving: 3 })], { source: 'prometheus' })
244
+ expect(row(all, 'ha').replication.text).not.toContain('reporting')
245
+ expect(row(all, 'ha').replication).toMatchObject({ text: expect.stringContaining('upstream feed not assessed'), tone: 'unknown' })
246
+ r.cluster = { ...r.cluster, status: { ...r.cluster?.status, currentPrimary: undefined } }
247
+ const unnamed = applyCNPGFleetMetrics(replica, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-1', lagStandbys: 3, standbys: 3, receiving: 3 })], { source: 'prometheus' })
248
+ expect(row(unnamed, 'ha').replication).toMatchObject({ text: expect.stringContaining('upstream feed not assessed'), tone: 'unknown' })
249
+ })
250
+ })