@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
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cnpgLogicalLocation, cnpgLogicalPaths, cnpgLogicalSlotFact, cnpgResolvePublisher } from './logicalReplication'
3
+
4
+ const G = 'postgresql.cnpg.io/v1'
5
+ const cluster = (name: string, ns: string, spec: any = {}, status: any = {}) => ({ apiVersion: G, kind: 'Cluster', metadata: { name, namespace: ns }, spec: { instances: 3, ...spec }, status })
6
+ const sub = (spec: any, status: any = {}) => ({ apiVersion: G, kind: 'Subscription', metadata: { name: 'orders-sub', namespace: 'dst' }, spec: { cluster: { name: 'dst' }, name: 'orders_sub', dbname: 'app', publicationName: 'orders_pub', externalClusterName: 'src', ...spec }, status })
7
+ const pub = { apiVersion: G, kind: 'Publication', metadata: { name: 'orders-pub', namespace: 'src' }, spec: { cluster: { name: 'src' }, name: 'orders_pub', dbname: 'app', target: { allTables: true } }, status: { applied: true } }
8
+ const subscriber = (host: string) => cluster('dst', 'dst', { externalClusters: [{ name: 'src', connectionParameters: { host, dbname: 'app', user: 'app' } }] })
9
+ const synced = (major: number) => cluster('src', 'src', { replicationSlots: { highAvailability: { synchronizeLogicalDecoding: true } } }, { pgDataImageInfo: { majorVersion: major } })
10
+
11
+ describe('cnpgResolvePublisher', () => {
12
+ const clusters = [cluster('src', 'src'), cluster('dst', 'dst')]
13
+ it('resolves -rw/-ro/-r Service hosts in any spelling to the visible Cluster', () => {
14
+ for (const host of ['src-rw.src.svc', 'src-rw.src.svc.cluster.local', 'src-ro.src', 'SRC-R.src.svc']) {
15
+ const p = cnpgResolvePublisher(host, 'dst', clusters, [])
16
+ expect(p.kind === 'cluster' && p.name).toBe('src')
17
+ }
18
+ })
19
+ it('resolves through a Pooler Service, and a bare name in the subscriber namespace', () => {
20
+ const p = cnpgResolvePublisher('src-pooler.src.svc', 'dst', clusters, [{ metadata: { name: 'src-pooler', namespace: 'src' }, spec: { cluster: { name: 'src' } } }])
21
+ expect(p.kind === 'cluster' && p.via).toBe('Pooler src-pooler')
22
+ expect(cnpgResolvePublisher('dst-rw', 'dst', clusters, []).kind).toBe('cluster')
23
+ })
24
+ it('never guesses a cluster from an external host', () => {
25
+ expect(cnpgResolvePublisher('src-rw.example.com', 'dst', clusters, []).kind).toBe('external')
26
+ expect(cnpgResolvePublisher('other-rw.src.svc', 'dst', clusters, []).kind).toBe('external')
27
+ expect(cnpgResolvePublisher(undefined, 'dst', clusters, []).kind).toBe('external')
28
+ })
29
+ })
30
+
31
+ describe('cnpgLogicalPaths', () => {
32
+ it('walks subscription → external cluster → publisher → publication object → slot', () => {
33
+ const [p] = cnpgLogicalPaths([sub({})], [synced(17), subscriber('src-rw.src.svc')], [pub], [])
34
+ expect(p.publisher.kind === 'cluster' && `${p.publisher.namespace}/${p.publisher.name}`).toBe('src/src')
35
+ expect(p.publication.object?.name).toBe('orders-pub')
36
+ expect(p.publication.dbname).toBe('app')
37
+ expect(p.slot.name).toBe('orders_sub')
38
+ })
39
+ it('says when the external cluster is not declared or the publication is not a CNPG object', () => {
40
+ const [missing] = cnpgLogicalPaths([sub({ externalClusterName: 'nope' })], [synced(17), subscriber('src-rw.src.svc')], [pub], [])
41
+ expect(missing.publisher.kind).toBe('external')
42
+ expect(missing.externalCluster.declared).toBe(false)
43
+ const [sqlOnly] = cnpgLogicalPaths([sub({ publicationName: 'made_in_sql' })], [synced(17), subscriber('src-rw.src.svc')], [pub], [])
44
+ expect(sqlOnly.publication.object).toBeUndefined()
45
+ })
46
+ it('says a Publication is unknown when the publisher namespace\'s Publications are unreadable', () => {
47
+ const [p] = cnpgLogicalPaths([sub({})], [synced(17), subscriber('src-rw.src.svc')], [], [], (ns) => (ns === 'src' ? 'No access to Publications' : null))
48
+ expect(p.publication.object).toBeUndefined()
49
+ expect(p.publication.unavailable).toBe('No access to Publications')
50
+ const [readable] = cnpgLogicalPaths([sub({})], [synced(17), subscriber('src-rw.src.svc')], [], [], () => null)
51
+ expect(readable.publication.unavailable).toBeUndefined()
52
+ })
53
+ it('names the slot from slot_name, and none for slot_name = NONE', () => {
54
+ expect(cnpgLogicalPaths([sub({ parameters: { slot_name: 'custom' } })], [], [], [])[0].slot.name).toBe('custom')
55
+ expect(cnpgLogicalPaths([sub({ parameters: { slot_name: 'NONE' } })], [], [], [])[0].slot.name).toBeUndefined()
56
+ })
57
+ })
58
+
59
+ describe('slot failover', () => {
60
+ const failoverOf = (publisher: any, params?: any) =>
61
+ cnpgLogicalPaths([sub(params ? { parameters: params } : {})], [publisher, subscriber('src-rw.src.svc')], [], [])[0].failover
62
+ it('is lost when the publisher does not synchronize logical slots', () => {
63
+ const f = failoverOf(cluster('src', 'src'))
64
+ expect(f.tone).toBe('degraded')
65
+ expect(f.text).toContain('synchronizeLogicalDecoding is off')
66
+ })
67
+ it('on PostgreSQL 17 needs the subscription to request failover', () => {
68
+ expect(failoverOf(synced(17)).tone).toBe('degraded')
69
+ expect(failoverOf(synced(17), { failover: 'true' }).tone).toBe('healthy')
70
+ })
71
+ it('is lost when HA slots are disabled, whatever synchronizeLogicalDecoding says', () => {
72
+ const off = cluster('src', 'src', { replicationSlots: { highAvailability: { enabled: false, synchronizeLogicalDecoding: true } } }, { pgDataImageInfo: { majorVersion: 17 } })
73
+ const f = failoverOf(off, { failover: 'true' })
74
+ expect(f.tone).toBe('degraded')
75
+ expect(f.text).toContain('HA replication slots are disabled')
76
+ })
77
+ it('is unknown before 17 (pg_failover_slots), without a major, or outside Radar', () => {
78
+ expect(failoverOf(synced(16)).tone).toBe('unknown')
79
+ expect(failoverOf(cluster('src', 'src', { replicationSlots: { highAvailability: { synchronizeLogicalDecoding: true } } })).tone).toBe('unknown')
80
+ expect(cnpgLogicalPaths([sub({})], [subscriber('db.example.com')], [], [])[0].failover.tone).toBe('unknown')
81
+ })
82
+ })
83
+
84
+ describe('cnpgLogicalSlotFact', () => {
85
+ const [path] = cnpgLogicalPaths([sub({}, { applied: true })], [synced(17), subscriber('src-rw.src.svc')], [pub], [])
86
+ it('never reads a denied or unread runtime as a missing slot', () => {
87
+ expect(cnpgLogicalSlotFact(path, { state: 'denied' }).tone).toBe('unknown')
88
+ expect(cnpgLogicalSlotFact(path, { state: 'unavailable', reason: 'unreachable' }).text).toContain('not read (unreachable)')
89
+ })
90
+ it('reports the observed slot, and a missing one only from a readable report', () => {
91
+ const ok = cnpgLogicalSlotFact(path, { state: 'ok', slots: [{ name: 'orders_sub', type: 'logical', active: true, retainedBytes: 2048, walStatus: 'reserved' }] })
92
+ expect(ok.text).toBe('Slot orders_sub · logical · active · retains 2.0 KiB of WAL · WAL reserved')
93
+ expect(ok.tone).toBe('healthy')
94
+ expect(cnpgLogicalSlotFact(path, { state: 'ok', slots: [{ name: 'orders_sub', active: false }] }).tone).toBe('degraded')
95
+ const capped = cnpgLogicalSlotFact(path, { state: 'partial', reason: '250 replication slots; the first 200 are shown', slots: [] })
96
+ expect(capped.text).toContain('not in the reported slots (report incomplete: 250 replication slots')
97
+ expect(capped.tone).toBe('unknown')
98
+ const stale = cnpgLogicalSlotFact(path, { state: 'ok', stale: true, slots: [{ name: 'orders_sub', type: 'logical', active: true }] })
99
+ expect(stale.tone).toBe('unknown')
100
+ expect(stale.text).toContain('from an earlier read')
101
+ const gone = cnpgLogicalSlotFact(path, { state: 'ok', slots: [] })
102
+ expect(gone.text).toContain('not found')
103
+ expect(gone.tone).toBe('degraded')
104
+ })
105
+ })
106
+
107
+ describe('cnpgLogicalLocation', () => {
108
+ it('says an unknown database in words', () => {
109
+ expect(cnpgLogicalLocation('upstream', 'app')).toBe('upstream/app')
110
+ expect(cnpgLogicalLocation('external cluster upstream', undefined)).toBe('external cluster upstream · database unknown')
111
+ expect(cnpgLogicalLocation('x', undefined)).not.toContain('?')
112
+ })
113
+ })
114
+
115
+ it('keeps stale Publication and Subscription results pending on the logical path', () => {
116
+ const staleSub = { ...sub({}, { applied: true, observedGeneration: 1 }), metadata: { ...sub({}).metadata, generation: 2 } }
117
+ const stalePub = { ...pub, metadata: { ...pub.metadata, generation: 2 }, status: { applied: true, observedGeneration: 1 } }
118
+ const [path] = cnpgLogicalPaths([staleSub], [synced(17), subscriber('src-rw.src.svc')], [stalePub], [])
119
+ expect(path.subscription.applied).toBeNull()
120
+ expect(path.publication.object?.applied).toBeNull()
121
+ })
@@ -0,0 +1,252 @@
1
+ import { getCNPGPostgresMajor } from '../resources/resource-utils-cnpg'
2
+ import { cnpgRoleState } from './databaseRole'
3
+ import { cnpgFormatBytes } from './workspace'
4
+ import { type Fact } from '../facts'
5
+
6
+ /** Where a Subscription's publisher lives, as far as the subscriber's spec shows. */
7
+ export type CNPGPublisher =
8
+ | { kind: 'cluster'; namespace: string; name: string; via: string; cluster: any }
9
+ | { kind: 'external'; host?: string; reason: string }
10
+
11
+ export interface CNPGLogicalPath {
12
+ subscription: { namespace: string; name: string; sqlName?: string; dbname?: string; cluster?: string; applied: boolean | null; message?: string }
13
+ externalCluster: { name?: string; declared: boolean; host?: string; dbname?: string }
14
+ publisher: CNPGPublisher
15
+ publication: {
16
+ name?: string
17
+ dbname?: string
18
+ /** The Publication object on the publisher Cluster declaring it, when one is visible. */
19
+ object?: { namespace: string; name: string; applied: boolean | null }
20
+ /** Why Publication objects in the publisher's namespace could not be read; absence then proves nothing. */
21
+ unavailable?: string
22
+ }
23
+ /** The slot PostgreSQL creates for the subscription: `slot_name`, else the subscription's name. */
24
+ slot: { name?: string; reason?: string }
25
+ failover: Fact
26
+ }
27
+
28
+ const SERVICE_SUFFIXES = ['-rw', '-ro', '-r']
29
+
30
+ function stripHost(host: string): { name: string; namespace?: string } {
31
+ const h = host.trim().toLowerCase().replace(/\.$/, '').replace(/\.svc\.cluster\.local$/, '').replace(/\.svc$/, '')
32
+ const [name, namespace] = h.split('.')
33
+ return { name, namespace }
34
+ }
35
+
36
+ /**
37
+ * Resolves an external cluster's host to a visible CNPG Cluster through its
38
+ * -rw / -ro / -r Service or a Pooler's Service. Anything else stays external:
39
+ * the host is never guessed to be a cluster it only resembles.
40
+ */
41
+ export function cnpgResolvePublisher(host: string | undefined, subscriberNs: string, clusters: any[], poolers: any[]): CNPGPublisher {
42
+ if (!host) return { kind: 'external', reason: 'the external cluster names no host' }
43
+ const { name, namespace = subscriberNs } = stripHost(host)
44
+ if (host.includes('.') && !/\.svc(\.cluster\.local)?\.?$/i.test(host.trim()) && host.split('.').length > 2) {
45
+ return { kind: 'external', host, reason: 'the host is not a Kubernetes Service name' }
46
+ }
47
+ const inNs = (list: any[]) => list.filter((o) => o?.metadata?.namespace === namespace)
48
+ for (const suffix of SERVICE_SUFFIXES) {
49
+ if (!name.endsWith(suffix)) continue
50
+ const base = name.slice(0, -suffix.length)
51
+ const c = inNs(clusters).find((x) => x.metadata?.name === base)
52
+ if (c) return { kind: 'cluster', namespace, name: base, via: `${name}.${namespace}`, cluster: c }
53
+ }
54
+ const pooler = inNs(poolers).find((p) => p.metadata?.name === name)
55
+ if (pooler?.spec?.cluster?.name) {
56
+ const c = inNs(clusters).find((x) => x.metadata?.name === pooler.spec.cluster.name)
57
+ if (c) return { kind: 'cluster', namespace, name: c.metadata.name, via: `Pooler ${name}`, cluster: c }
58
+ }
59
+ return { kind: 'external', host, reason: 'no visible CloudNativePG Cluster serves this host' }
60
+ }
61
+
62
+ /**
63
+ * The namespace a Subscription's publisher host names, so a host can read that
64
+ * namespace too before resolving the publisher. Undefined when the subscriber
65
+ * or its external cluster is not visible, or the host names none.
66
+ */
67
+ export function cnpgSubscriptionHostNamespace(subscription: any, clusters: any[]): string | undefined {
68
+ const ns = subscription?.metadata?.namespace
69
+ const subscriber = clusters.find((c) => c?.metadata?.namespace === ns && c?.metadata?.name === subscription?.spec?.cluster?.name)
70
+ const ext = (subscriber?.spec?.externalClusters ?? []).find((e: any) => e?.name === subscription?.spec?.externalClusterName)
71
+ const host = ext?.connectionParameters?.host
72
+ return host ? stripHost(host).namespace ?? ns : undefined
73
+ }
74
+
75
+
76
+ function truthy(v: unknown): boolean | undefined {
77
+ if (v === undefined || v === null) return undefined
78
+ const s = String(v).trim().toLowerCase()
79
+ if (['true', 'on', 'yes', '1'].includes(s)) return true
80
+ if (['false', 'off', 'no', '0'].includes(s)) return false
81
+ return undefined
82
+ }
83
+
84
+ const FAILOVER_SOURCE = "Publisher's spec.replicationSlots.highAvailability (enabled and synchronizeLogicalDecoding), its PostgreSQL major and the Subscription's failover parameter; /pg/status does not report a slot's failover flag"
85
+
86
+ /**
87
+ * Whether the publisher's slot for this subscription is kept on its standbys,
88
+ * so a failover of the publisher does not lose it. Declared configuration
89
+ * only: CloudNativePG does not report which slots were actually synchronized.
90
+ */
91
+ export function cnpgSlotFailover(publisher: CNPGPublisher, subscription: any): Fact {
92
+ if (publisher.kind !== 'cluster') {
93
+ return { text: 'Unknown: the publisher is not a CloudNativePG Cluster Radar can see', tone: 'unknown', source: FAILOVER_SOURCE }
94
+ }
95
+ const c = publisher.cluster
96
+ if ((c?.spec?.instances ?? 1) <= 1) {
97
+ return { text: 'No standby to fail over to: a single-instance publisher', tone: 'neutral', source: FAILOVER_SOURCE }
98
+ }
99
+ const ha = c?.spec?.replicationSlots?.highAvailability
100
+ // GetEnabled defaults to true; the operator enables sync only with both.
101
+ if (ha?.synchronizeLogicalDecoding === true && ha?.enabled === false) {
102
+ return {
103
+ text: 'Lost on failover: synchronizeLogicalDecoding is on, but HA replication slots are disabled, so standbys have no physical slot to synchronize through',
104
+ tone: 'degraded',
105
+ source: FAILOVER_SOURCE,
106
+ }
107
+ }
108
+ const sync = ha?.synchronizeLogicalDecoding === true
109
+ if (!sync) {
110
+ return {
111
+ text: 'Lost on failover: the publisher does not synchronize logical slots to standbys (synchronizeLogicalDecoding is off)',
112
+ tone: 'degraded',
113
+ source: FAILOVER_SOURCE,
114
+ }
115
+ }
116
+ const major = getCNPGPostgresMajor(c)
117
+ if (major === undefined) {
118
+ return { text: 'Unknown: synchronization is on, but the publisher\'s PostgreSQL major is not reported', tone: 'unknown', source: FAILOVER_SOURCE }
119
+ }
120
+ if (major < 17) {
121
+ return {
122
+ text: 'Synchronized only if pg_failover_slots is loaded on the publisher (PostgreSQL < 17); Radar cannot tell',
123
+ tone: 'unknown',
124
+ source: FAILOVER_SOURCE,
125
+ }
126
+ }
127
+ const failover = truthy(subscription?.spec?.parameters?.failover)
128
+ if (failover === true) {
129
+ return { text: 'Kept on standbys (declared): synchronization is on and the subscription requests failover', tone: 'healthy', source: FAILOVER_SOURCE }
130
+ }
131
+ return {
132
+ text: "Lost on failover: PostgreSQL 17 synchronizes only slots created with failover = true, and the subscription's parameters do not set it",
133
+ tone: 'degraded',
134
+ source: FAILOVER_SOURCE,
135
+ }
136
+ }
137
+
138
+ export function cnpgLogicalPaths(
139
+ subscriptions: any[],
140
+ clusters: any[],
141
+ publications: any[],
142
+ poolers: any[],
143
+ /** Why Publications in a namespace are not readable (coverage), or null when they are. */
144
+ publicationsUnavailable?: (namespace: string) => string | null,
145
+ ): CNPGLogicalPath[] {
146
+ return subscriptions.map((sub) => {
147
+ const ns: string = sub?.metadata?.namespace ?? ''
148
+ const subscriber = clusters.find((c) => c?.metadata?.namespace === ns && c?.metadata?.name === sub?.spec?.cluster?.name)
149
+ const extName: string | undefined = sub?.spec?.externalClusterName
150
+ const ext = (subscriber?.spec?.externalClusters ?? []).find((e: any) => e?.name === extName)
151
+ const params = ext?.connectionParameters ?? {}
152
+ const publisher: CNPGPublisher = !subscriber
153
+ ? { kind: 'external', reason: 'the subscriber Cluster is not visible, so its external cluster cannot be read' }
154
+ : !ext
155
+ ? { kind: 'external', reason: `external cluster ${extName ?? '(unset)'} is not declared on ${subscriber.metadata?.name}` }
156
+ : cnpgResolvePublisher(params.host, ns, clusters, poolers)
157
+ const pubDb: string | undefined = sub?.spec?.publicationDBName || params.dbname
158
+ const pubName: string | undefined = sub?.spec?.publicationName
159
+ const pubObj =
160
+ publisher.kind === 'cluster'
161
+ ? publications.find(
162
+ (p) =>
163
+ p?.metadata?.namespace === publisher.namespace &&
164
+ p?.spec?.cluster?.name === publisher.name &&
165
+ p?.spec?.name === pubName &&
166
+ (!pubDb || p?.spec?.dbname === pubDb),
167
+ )
168
+ : undefined
169
+ const slotParam: string | undefined = sub?.spec?.parameters?.slot_name
170
+ const createSlot = truthy(sub?.spec?.parameters?.create_slot)
171
+ const slot =
172
+ slotParam && slotParam.toLowerCase() === 'none'
173
+ ? { reason: 'slot_name = NONE: the subscription uses no slot' }
174
+ : slotParam
175
+ ? { name: slotParam }
176
+ : createSlot === false
177
+ ? { name: sub?.spec?.name, reason: 'create_slot = false: the slot must be created by hand' }
178
+ : { name: sub?.spec?.name }
179
+ return {
180
+ subscription: {
181
+ namespace: ns,
182
+ name: sub?.metadata?.name,
183
+ sqlName: sub?.spec?.name,
184
+ dbname: sub?.spec?.dbname,
185
+ cluster: sub?.spec?.cluster?.name,
186
+ applied: cnpgRoleState(sub) === 'pending' ? null : cnpgRoleState(sub) === 'applied',
187
+ message: sub?.status?.message || undefined,
188
+ },
189
+ externalCluster: { name: extName, declared: !!ext, host: params.host, dbname: params.dbname },
190
+ publisher,
191
+ publication: {
192
+ name: pubName,
193
+ dbname: pubDb,
194
+ object: pubObj
195
+ ? { namespace: pubObj.metadata.namespace, name: pubObj.metadata.name, applied: cnpgRoleState(pubObj) === 'pending' ? null : cnpgRoleState(pubObj) === 'applied' }
196
+ : undefined,
197
+ unavailable: !pubObj && publisher.kind === 'cluster' ? publicationsUnavailable?.(publisher.namespace) ?? undefined : undefined,
198
+ },
199
+ slot,
200
+ failover: cnpgSlotFailover(publisher, sub),
201
+ }
202
+ })
203
+ }
204
+
205
+ /** What the publisher primary's instance manager says about one slot, in a shape independent of the runtime API. */
206
+ export interface CNPGPublisherSlots {
207
+ /** partial: the report was capped or incomplete, so a slot missing from it may exist. */
208
+ state: 'ok' | 'partial' | 'denied' | 'unavailable' | 'notRead'
209
+ reason?: string
210
+ /** The latest refresh failed; slots are from an earlier read. */
211
+ stale?: boolean
212
+ slots?: { name: string; type?: string; active?: boolean; walStatus?: string; retainedBytes?: number; database?: string }[]
213
+ }
214
+
215
+ const SLOT_SOURCE = "Publisher primary's instance manager (/pg/status replicationSlotsInfo) and exporter (retained WAL)"
216
+
217
+ export function cnpgLogicalSlotFact(path: CNPGLogicalPath, observed: CNPGPublisherSlots): Fact {
218
+ if (!path.slot.name) return { text: path.slot.reason ?? 'No slot', tone: 'neutral' }
219
+ if (path.publisher.kind !== 'cluster') return { text: `Slot ${path.slot.name}: not observable (publisher outside this cluster's view)`, tone: 'unknown' }
220
+ if (observed.state === 'denied') return { text: `Slot ${path.slot.name}: no access (needs get pods/proxy on the publisher)`, tone: 'unknown', source: SLOT_SOURCE }
221
+ if ((observed.state !== 'ok' && observed.state !== 'partial') || !observed.slots) {
222
+ return { text: `Slot ${path.slot.name}: not read${observed.reason ? ` (${observed.reason})` : ''}`, tone: 'unknown', source: SLOT_SOURCE }
223
+ }
224
+ const s = observed.slots.find((x) => x.name === path.slot.name)
225
+ if (!s && (observed.state === 'partial' || observed.stale)) {
226
+ return {
227
+ text: `Slot ${path.slot.name}: not in the reported slots (${observed.stale ? 'from an earlier read; the latest refresh failed' : `report incomplete${observed.reason ? `: ${observed.reason}` : ''}`})`,
228
+ tone: 'unknown',
229
+ source: SLOT_SOURCE,
230
+ }
231
+ }
232
+ if (!s) {
233
+ return {
234
+ text: `Slot ${path.slot.name} not found on the publisher primary${path.slot.reason ? `: ${path.slot.reason}` : ''}`,
235
+ tone: path.subscription.applied === true ? 'degraded' : 'unknown',
236
+ source: SLOT_SOURCE,
237
+ }
238
+ }
239
+ const parts = [`Slot ${s.name}`, s.type ?? 'type unknown', s.active === undefined ? 'activity unknown' : s.active ? 'active' : 'inactive']
240
+ if (s.retainedBytes !== undefined) parts.push(`retains ${cnpgFormatBytes(s.retainedBytes)} of WAL`)
241
+ if (s.walStatus) parts.push(`WAL ${s.walStatus}`)
242
+ const bad = s.active === false || s.walStatus === 'lost' || s.walStatus === 'unreserved'
243
+ if (observed.stale) {
244
+ return { text: `${parts.join(' · ')} (from an earlier read; the latest refresh failed)`, tone: 'unknown', source: SLOT_SOURCE }
245
+ }
246
+ return { text: parts.join(' · '), tone: s.walStatus === 'lost' ? 'unhealthy' : bad ? 'degraded' : 'healthy', source: SLOT_SOURCE }
247
+ }
248
+
249
+ /** "cluster/database", with an unknown database said in words rather than as "?". */
250
+ export function cnpgLogicalLocation(where: string, dbname: string | undefined): string {
251
+ return dbname ? `${where}/${dbname}` : `${where} · database unknown`
252
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { poolerPressureCoverage, poolerPressureFact, aggregatePoolerPools, observedPause, poolerBackendService, poolerReadiness, poolerPodPressure } from './pooler'
3
+
4
+ describe('poolerReadiness', () => {
5
+ it('reads readiness from the Deployment, never from the scheduled count', () => {
6
+ expect(poolerReadiness({ name: 'p', state: 'ok', replicas: 2, readyReplicas: 2 })).toMatchObject({ text: '2/2 ready', level: 'healthy' })
7
+ expect(poolerReadiness({ name: 'p', state: 'ok', replicas: 2, readyReplicas: 1 }).level).toBe('degraded')
8
+ expect(poolerReadiness({ name: 'p', state: 'ok', replicas: 2, readyReplicas: 0 }).level).toBe('unhealthy')
9
+ })
10
+ it('says unknown, not failed, when the Deployment is unreadable', () => {
11
+ expect(poolerReadiness({ name: 'p', state: 'unreadable' })).toMatchObject({ text: 'Unknown', level: 'unknown' })
12
+ expect(poolerReadiness(undefined).level).toBe('unknown')
13
+ })
14
+ })
15
+
16
+ describe('aggregatePoolerPools', () => {
17
+ it('sums each pool over the Pods that reported it and keeps unreported fields undefined', () => {
18
+ const rows = aggregatePoolerPools([
19
+ { pod: 'a', state: 'ok', pools: [{ database: 'app', user: 'app', clActive: 3, clWaiting: 1, maxwaitSeconds: 0.5, poolMode: 'transaction' }] },
20
+ { pod: 'b', state: 'ok', pools: [{ database: 'app', user: 'app', clActive: 2, clWaiting: 0, maxwaitSeconds: 2, poolMode: 'transaction' }] },
21
+ ])
22
+ expect(rows).toHaveLength(1)
23
+ expect(rows[0]).toMatchObject({ clActive: 5, clWaiting: 1, maxwaitSeconds: 2, pods: 2, poolModes: ['transaction'] })
24
+ expect(rows[0].svActive).toBeUndefined()
25
+ })
26
+ })
27
+
28
+ describe('observedPause', () => {
29
+ it('distinguishes all, some and unreadable', () => {
30
+ expect(observedPause({ state: 'ok', pods: [{ pod: 'a', state: 'ok', paused: true }, { pod: 'b', state: 'ok', paused: true }] })?.text).toBe('Paused on 2 of 2 PgBouncers')
31
+ expect(observedPause({ state: 'ok', pods: [{ pod: 'a', state: 'ok', paused: true }, { pod: 'b', state: 'ok', paused: false }] })?.level).toBe('alert')
32
+ expect(observedPause({ state: 'ok', pods: [{ pod: 'a', state: 'ok', paused: false }, { pod: 'b', state: 'error' }] })?.text).toBe('Serving (not paused) on 1 of 2 PgBouncers · 1 not read: b (error)')
33
+ expect(observedPause({ state: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'x' }, pods: [] })?.text).toContain('create pods/exec')
34
+ })
35
+ })
36
+
37
+ describe('poolerBackendService', () => {
38
+ it('names the Cluster Service by type', () => {
39
+ expect(poolerBackendService('pg', 'rw')).toBe('pg-rw')
40
+ expect(poolerBackendService('pg', 'ro')).toBe('pg-ro')
41
+ expect(poolerBackendService('pg', undefined)).toBeUndefined()
42
+ })
43
+ })
44
+
45
+ describe('poolerPodPressure', () => {
46
+ it('totals each Pod on its own so one queuing Pod is visible', () => {
47
+ const rows = poolerPodPressure([
48
+ { pod: 'pooler-b', state: 'ok', pools: [{ database: 'app', user: 'app', clActive: 2, clWaiting: 0, svActive: 1 }, { database: 'app', user: 'ro', clActive: 1, clWaiting: 0, svActive: 0 }] },
49
+ { pod: 'pooler-a', state: 'ok', pools: [{ database: 'app', user: 'app', clActive: 20, clWaiting: 15, svActive: 20, maxwaitSeconds: 4.2 }] },
50
+ { pod: 'pooler-c', state: 'unreachable', error: 'no answer within 5s' },
51
+ ] as never)
52
+ expect(rows).toEqual([
53
+ { pod: 'pooler-a', state: 'ok', error: undefined, clActive: 20, clWaiting: 15, svActive: 20, maxwaitSeconds: 4.2 },
54
+ { pod: 'pooler-b', state: 'ok', error: undefined, clActive: 3, clWaiting: 0, svActive: 1 },
55
+ { pod: 'pooler-c', state: 'unreachable', error: 'no answer within 5s' },
56
+ ])
57
+ })
58
+ })
59
+
60
+ describe('pooler pressure certainty', () => {
61
+ it('says idle only when every Pod answered in full', () => {
62
+ const a = { pod: 'a', state: 'ok', pools: [] }
63
+ expect(poolerPressureCoverage([a]).empty).toBe('Idle: no client pools open')
64
+ for (const state of ['partial', 'unreachable']) {
65
+ const c = poolerPressureCoverage([a, { pod: 'b', state, reason: 'pool list capped' }])
66
+ expect(c.complete).toBe(false)
67
+ expect(c.empty).toBe('No pools seen in what was read')
68
+ expect(c.limitation).toContain('b:')
69
+ expect(c.limitation).toContain('pool list capped')
70
+ }
71
+ })
72
+ it('shows unknown for partial zero and a lower bound for partial positive counts', () => {
73
+ const p = { pod: 'a', state: 'partial', pools: [{ database: 'app', user: 'app', clWaiting: 0, clActive: 3 }] }
74
+ expect(poolerPressureFact([p], 'clWaiting')).toMatchObject({ text: 'Unknown', tone: 'unknown' })
75
+ expect(poolerPressureFact([p], 'clActive')).toMatchObject({ text: '≥3', tone: 'unknown' })
76
+ expect(poolerPressureFact([{ ...p, state: 'ok' }], 'clWaiting').text).toBe('0')
77
+ expect(poolerPressureFact([{ ...p, state: 'ok' }, { pod: 'b', state: 'unreachable' }], 'clWaiting').text).toBe('Unknown')
78
+ })
79
+ it('qualifies a total when a pool omitted the field, even with complete Pod reads', () => {
80
+ const pods = [{ pod: 'a', state: 'ok', pools: [{ database: 'app', user: 'a', clActive: 2 }, { database: 'app', user: 'b' }] }]
81
+ expect(poolerPressureFact(pods, 'clActive').text).toBe('≥2')
82
+ expect(poolerPressureFact(pods, 'clWaiting').text).toBe('Unknown')
83
+ })
84
+ })