@skyhook-io/radar-app 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 (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. package/src/utils/viewMemory.ts +54 -0
@@ -1,18 +1,23 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import type { UseQueryResult } from '@tanstack/react-query'
3
- import { clsx } from 'clsx'
4
- import { Database, X } from 'lucide-react'
3
+ import { Database } from 'lucide-react'
5
4
  import {
6
5
  CNPG_KIND_BY_KEY,
6
+ FoldSection,
7
+ coverageReadable,
7
8
  PaneLoader,
9
+ RadarUpgradeAction,
10
+ getRadarUpgradeRequirement,
11
+ radarUpgradeDetail,
12
+ radarUpgradeHeadline,
8
13
  type CNPGFleet,
9
14
  type CNPGKindCoverage,
10
15
  type CNPGWorkspaceResponse,
16
+ type CNPGWorkspaceKey,
11
17
  } from '@skyhook-io/k8s-ui'
12
18
  import type { SelectedResource } from '../../types'
13
19
  import { useConnection } from '../../context/ConnectionContext'
14
- import { EmptyState, Notice, ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from '../capacity/shared'
15
- import { sameResource } from './routes'
20
+ import { Notice, RefreshFailedNotice, ScreenEmptyState } from '../workspace/layout'
16
21
 
17
22
  export interface CNPGScreenProps {
18
23
  data: CNPGWorkspaceResponse
@@ -23,25 +28,33 @@ export interface CNPGScreenProps {
23
28
  onInspect: (resource: SelectedResource) => void
24
29
  inspected: SelectedResource | null
25
30
  onClearNamespaces: () => void
31
+ /** Opens the create dialog for this screen's kind (the Clusters view's Create). */
32
+ onCreate?: () => void
26
33
  }
27
34
 
28
35
  const COVERAGE_LABEL: Record<string, string> = {
29
36
  denied: 'no access',
30
- partial: 'no access in some namespaces',
37
+ partial: 'not read in some namespaces',
38
+ uncached: 'not cached by Radar',
31
39
  syncing: 'still loading',
32
40
  error: 'could not be read',
33
41
  }
34
42
 
43
+ // The workspace name sits on the title line, as on the CloudNativePG detail
44
+ // pages; it is not a link, since on Clusters it would lead to itself.
35
45
  export function CNPGWorkspaceHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {
36
46
  return (
37
47
  <div className="px-5 pt-4 xl:px-7">
38
- <div className="flex items-center gap-2 text-xs text-theme-text-tertiary">
39
- <Database className="h-3.5 w-3.5" />
40
- <span>CloudNativePG</span>
41
- </div>
42
- <div className="mt-1 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
48
+ <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
43
49
  <div className="min-w-0">
44
- <h1 className="text-lg font-semibold text-theme-text-primary">{title}</h1>
50
+ <div className="flex flex-wrap items-center gap-x-2">
51
+ <span className="flex items-center gap-1.5 text-sm text-theme-text-tertiary">
52
+ <Database className="h-3.5 w-3.5" />
53
+ CloudNativePG
54
+ <span>/</span>
55
+ </span>
56
+ <h1 className="text-lg font-semibold text-theme-text-primary">{title}</h1>
57
+ </div>
45
58
  {subtitle && <div className="mt-0.5 text-sm text-theme-text-secondary">{subtitle}</div>}
46
59
  </div>
47
60
  {actions}
@@ -50,16 +63,52 @@ export function CNPGWorkspaceHeader({ title, subtitle, actions }: { title: strin
50
63
  )
51
64
  }
52
65
 
53
- export function CoverageNotice({ fleet, data }: { fleet: CNPGFleet; data: CNPGWorkspaceResponse }) {
54
- if (fleet.incompleteKinds.length === 0) return null
55
- const parts = fleet.incompleteKinds.map((k) => {
56
- const cov = data.coverage[k]
57
- const label = COVERAGE_LABEL[cov?.state ?? ''] ?? cov?.state
58
- return `${CNPG_KIND_BY_KEY[k].kind} (${label})`
59
- })
66
+ // The causes the server named for the namespaces it left unread. A partial or
67
+ // uncached read can name both kinds of namespace at once.
68
+ function namedCauses(cov: CNPGKindCoverage | undefined, uncachedNoun: string): { denied?: string; uncached?: string } {
69
+ return {
70
+ denied: cov?.deniedNamespaces?.length ? cov.deniedNamespaces.join(', ') : undefined,
71
+ uncached: cov?.uncachedNamespaces?.length ? `${uncachedNoun} in ${cov.uncachedNamespaces.join(', ')}` : undefined,
72
+ }
73
+ }
74
+
75
+ /** How much of a kind was read, in a few words ("not cached by Radar in pg"). */
76
+ export function coverageLabel(cov: CNPGKindCoverage | undefined): string {
77
+ if (!cov) return 'not read by this Radar'
78
+ const { denied, uncached } = namedCauses(cov, 'not cached by Radar')
79
+ const named = [denied && `no access in ${denied}`, uncached].filter(Boolean)
80
+ if (named.length > 0) return named.join('; ')
81
+ return COVERAGE_LABEL[cov?.state ?? ''] ?? cov?.state ?? 'unknown'
82
+ }
83
+
84
+ const INCOMPLETE_STATES = new Set(['partial', 'denied', 'syncing', 'error', 'uncached'])
85
+
86
+ /**
87
+ * The incomplete kinds grouped by what was left unread, each reason said once.
88
+ * Jobs count when their coverage is incomplete: a Cluster's initdb, join and
89
+ * restore Job Pods are read only where they are.
90
+ */
91
+ export function incompleteKindsText(kinds: CNPGFleet['incompleteKinds'], coverage: CNPGWorkspaceResponse['coverage'], jobCoverage?: CNPGKindCoverage): string {
92
+ const byLabel = new Map<string, string[]>()
93
+ const add = (kind: string, cov: CNPGKindCoverage | undefined) => {
94
+ const label = coverageLabel(cov)
95
+ byLabel.set(label, [...(byLabel.get(label) ?? []), kind])
96
+ }
97
+ for (const k of kinds) add(CNPG_KIND_BY_KEY[k].kind, coverage[k])
98
+ if (jobCoverage && INCOMPLETE_STATES.has(jobCoverage.state)) add('Job', jobCoverage)
99
+ return [...byLabel].map(([label, names]) => `${names.join(', ')} (${label})`).join('; ')
100
+ }
101
+
102
+ export function CoverageNotice({ fleet, data, kinds, namespace, includeJobs = false }: { fleet: CNPGFleet; data: CNPGWorkspaceResponse; kinds: readonly CNPGWorkspaceKey[]; namespace?: string; includeJobs?: boolean }) {
103
+ const incomplete = fleet.incompleteKinds.filter((k) => { const coverage = data.coverage[k]; return kinds.includes(k) && (!coverage || !coverageReadable(coverage, namespace)) })
104
+ const jobsIncomplete = includeJobs && !!data.jobCoverage && INCOMPLETE_STATES.has(data.jobCoverage.state) && !coverageReadable(data.jobCoverage, namespace)
105
+ if (incomplete.length === 0 && !jobsIncomplete) return null
60
106
  return (
61
107
  <Notice>
62
- Some CloudNativePG data is not readable: {parts.join(', ')}. Facts built on it read “No access” or “unknown” rather than none, and counts are lower bounds.
108
+ Some data used on this screen was not read.
109
+ <FoldSection title="Coverage details" summary="Kinds and reasons" attention={false}>
110
+ {incompleteKindsText(incomplete, data.coverage, jobsIncomplete ? data.jobCoverage : undefined)}. Counts over incomplete data are lower bounds.
111
+ </FoldSection>
63
112
  </Notice>
64
113
  )
65
114
  }
@@ -81,172 +130,44 @@ export function CNPGScreenGate({
81
130
  const data = query.data
82
131
  if (!data && query.isLoading) return <PaneLoader label="Loading CloudNativePG…" className="flex-1" />
83
132
  if (!data) {
133
+ const upgrade = getRadarUpgradeRequirement(query.error)
134
+ if (upgrade) {
135
+ return (
136
+ <ScreenEmptyState
137
+ icon={Database}
138
+ title={radarUpgradeHeadline('CloudNativePG')}
139
+ detail={radarUpgradeDetail(upgrade)}
140
+ action={
141
+ <div className="mt-3 text-sm">
142
+ <RadarUpgradeAction requirement={upgrade} />
143
+ </div>
144
+ }
145
+ />
146
+ )
147
+ }
84
148
  return (
85
- <EmptyState
149
+ <ScreenEmptyState
86
150
  icon={Database}
87
- title="CloudNativePG workspace unavailable"
88
- detail={query.error instanceof Error ? query.error.message : 'The workspace could not be loaded.'}
151
+ title="CloudNativePG data unavailable"
152
+ detail={query.error instanceof Error ? query.error.message : 'Radar could not load the CloudNativePG data.'}
153
+ action={
154
+ <button type="button" onClick={() => void query.refetch()} disabled={query.isFetching} className="btn-secondary mt-3 px-3 py-1.5 text-sm">
155
+ {query.isFetching ? 'Retrying…' : 'Retry'}
156
+ </button>
157
+ }
89
158
  />
90
159
  )
91
160
  }
92
161
  if (!data.installed || !fleet) {
93
162
  return (
94
- <EmptyState
163
+ <ScreenEmptyState
95
164
  icon={Database}
96
165
  title="CloudNativePG is not installed"
97
166
  detail={`No postgresql.cnpg.io resources are served in ${connection.context || 'this cluster'}.`}
98
167
  />
99
168
  )
100
169
  }
101
- return <>{children(data, fleet)}</>
102
- }
103
-
104
- export function ScreenBody({ children }: { children: ReactNode }) {
105
- return (
106
- <div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
107
- <div className="space-y-5 px-5 pb-6 pt-3 xl:px-7">{children}</div>
108
- </div>
109
- )
110
- }
111
-
112
- export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) {
113
- if (chips.length === 0) return null
114
- return (
115
- <div className="flex flex-wrap gap-1.5">
116
- {chips.map((c) => (
117
- <span key={c.label} className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs text-theme-text-secondary">
118
- {c.label}
119
- <button type="button" onClick={c.onClear} aria-label={`Remove ${c.label}`} className="rounded-full p-0.5 hover:bg-theme-hover">
120
- <X className="h-3 w-3" />
121
- </button>
122
- </span>
123
- ))}
124
- </div>
125
- )
126
- }
127
-
128
- export function namespaceChip(namespaces: string[], onClear: () => void) {
129
- return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : []
130
- }
131
-
132
- export function Segments<T extends string>({
133
- value,
134
- options,
135
- onChange,
136
- label,
137
- }: {
138
- value: T
139
- options: { id: T; label: string; count?: number }[]
140
- onChange: (id: T) => void
141
- label: string
142
- }) {
143
- return (
144
- <div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-theme-elevated p-0.5">
145
- {options.map((o) => {
146
- const on = o.id === value
147
- return (
148
- <button
149
- key={o.id}
150
- type="button"
151
- role="tab"
152
- aria-selected={on}
153
- onClick={() => onChange(o.id)}
154
- className={clsx(
155
- 'inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium transition-colors',
156
- on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
157
- )}
158
- >
159
- {o.label}
160
- {o.count !== undefined && <span className="font-mono text-xs text-theme-text-tertiary">{o.count}</span>}
161
- </button>
162
- )
163
- })}
164
- </div>
165
- )
166
- }
167
-
168
- export interface TableColumn<T> {
169
- header: ReactNode
170
- width?: string
171
- cell: (row: T) => ReactNode
172
- className?: string
173
- }
174
-
175
- /** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */
176
- export function SectionTable<T>({
177
- title,
178
- subtitle,
179
- columns,
180
- rows,
181
- rowKey,
182
- rowResource,
183
- onInspect,
184
- inspected,
185
- empty,
186
- minWidth = 760,
187
- footer,
188
- }: {
189
- title: ReactNode
190
- subtitle?: ReactNode
191
- columns: TableColumn<T>[]
192
- rows: T[]
193
- rowKey: (row: T) => string
194
- rowResource?: (row: T) => SelectedResource | null
195
- onInspect?: (resource: SelectedResource) => void
196
- inspected?: SelectedResource | null
197
- empty: ReactNode
198
- minWidth?: number
199
- footer?: ReactNode
200
- }) {
201
- return (
202
- <section>
203
- <div className="mb-2 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
204
- <h2 className="text-sm font-semibold text-theme-text-primary">{title}</h2>
205
- {subtitle && <span className="text-xs text-theme-text-tertiary">{subtitle}</span>}
206
- </div>
207
- <div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
208
- {rows.length === 0 ? (
209
- <div className="px-4 py-5 text-sm text-theme-text-tertiary">{empty}</div>
210
- ) : (
211
- <div className={TABLE_WRAP}>
212
- <table className="w-full table-fixed" style={{ minWidth }}>
213
- <colgroup>
214
- {columns.map((c, i) => (
215
- <col key={i} style={c.width ? { width: c.width } : undefined} />
216
- ))}
217
- </colgroup>
218
- <thead className={TABLE_HEAD}>
219
- <tr>
220
- {columns.map((c, i) => (
221
- <th key={i} className={TH}>{c.header}</th>
222
- ))}
223
- </tr>
224
- </thead>
225
- <tbody className={TBODY}>
226
- {rows.map((row) => {
227
- const res = rowResource?.(row) ?? null
228
- const active = !!res && sameResource(inspected, res)
229
- return (
230
- <tr
231
- key={rowKey(row)}
232
- onClick={res && onInspect ? () => onInspect(res) : undefined}
233
- className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')}
234
- aria-selected={res ? active : undefined}
235
- >
236
- {columns.map((c, i) => (
237
- <td key={i} className={clsx(TD, c.className)}>{c.cell(row)}</td>
238
- ))}
239
- </tr>
240
- )
241
- })}
242
- </tbody>
243
- </table>
244
- </div>
245
- )}
246
- </div>
247
- {footer && <div className="mt-1.5 text-xs text-theme-text-tertiary">{footer}</div>}
248
- </section>
249
- )
170
+ return <><RefreshFailedNotice queries={[query]} className="px-5 pt-3 xl:px-7" />{children(data, fleet)}</>
250
171
  }
251
172
 
252
173
  /** Empty-state text for a collection, derived from how much of it was readable. */
@@ -255,7 +176,12 @@ export function coverageEmpty(cov: CNPGKindCoverage | undefined, noun: string):
255
176
  case 'full':
256
177
  return `No ${noun} in this scope.`
257
178
  case 'partial':
258
- return `No ${noun} visible. Some namespaces are not readable with your access.`
179
+ case 'uncached': {
180
+ const { denied, uncached } = namedCauses(cov, `Radar does not cache ${noun}`)
181
+ const causes = [denied && `no access in ${denied}`, uncached].filter(Boolean)
182
+ if (causes.length > 0) return `No ${noun} visible: ${causes.join('; ')}.`
183
+ return cov.state === 'uncached' ? `Radar does not cache ${noun} in this scope.` : `No ${noun} visible. Some namespaces were not read.`
184
+ }
259
185
  case 'denied':
260
186
  return `No access to ${noun}.`
261
187
  case 'syncing':
@@ -269,18 +195,10 @@ export function coverageEmpty(cov: CNPGKindCoverage | undefined, noun: string):
269
195
 
270
196
  /** The less complete of two coverages, for collections built from several kinds. */
271
197
  export function worstCoverage(...covs: (CNPGKindCoverage | undefined)[]): CNPGKindCoverage | undefined {
272
- const rank: Record<string, number> = { error: 0, denied: 1, syncing: 2, partial: 3, full: 4, notInstalled: 5 }
198
+ const rank: Record<string, number> = { error: 0, denied: 1, uncached: 2, syncing: 3, partial: 4, full: 5, notInstalled: 6 }
273
199
  return covs.filter(Boolean).sort((a, b) => (rank[a!.state] ?? 9) - (rank[b!.state] ?? 9))[0]
274
200
  }
275
201
 
276
- export function Mono({ children }: { children: ReactNode }) {
277
- return <span className="font-mono text-[12.5px] break-all">{children}</span>
278
- }
279
-
280
- export function Sub({ children }: { children: ReactNode }) {
281
- return <div className="mt-0.5 text-xs text-theme-text-tertiary break-words">{children}</div>
282
- }
283
-
284
202
  export function clusterResource(namespace: string, name: string): SelectedResource {
285
203
  return { kind: 'clusters', group: 'postgresql.cnpg.io', namespace, name }
286
204
  }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildResizeManifest, cnpgFloorTone, cnpgInstanceDiskTone, cnpgSharedExpansionGap, cnpgSlotRetentionText, cnpgWALUsageFloor } from './storageModel'
3
+ import type { CNPGStorageVolume, CNPGStorageWAL } from '../../api/cnpg-storage'
4
+
5
+ const cluster = {
6
+ metadata: { name: 'pg', namespace: 'db' },
7
+ spec: {
8
+ storage: { size: '10Gi', storageClass: 'fast' },
9
+ tablespaces: [
10
+ { name: 'hot', storage: { size: '5Gi' }, owner: { name: 'app' } },
11
+ { name: 'cold', storage: { pvcTemplate: { resources: { requests: { storage: '50Gi' } }, storageClassName: 'slow' } } },
12
+ ],
13
+ },
14
+ }
15
+
16
+ describe('buildResizeManifest', () => {
17
+ it('applies only the data size', () => {
18
+ const m = buildResizeManifest(cluster, { role: 'PG_DATA', field: 'spec.storage.size', declared: '10Gi' }, '20Gi')
19
+ expect(m).toEqual({ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg', namespace: 'db' }, spec: { storage: { size: '20Gi' } } })
20
+ })
21
+
22
+ it('writes WAL sizes under walStorage, following a pvcTemplate', () => {
23
+ const m = buildResizeManifest(cluster, { role: 'PG_WAL', field: 'spec.walStorage.pvcTemplate.resources.requests.storage' }, '4Gi')
24
+ expect(m.spec).toEqual({ walStorage: { pvcTemplate: { resources: { requests: { storage: '4Gi' } } } } })
25
+ })
26
+
27
+ it('sends every tablespace, changing only the one named', () => {
28
+ const m = buildResizeManifest(cluster, { role: 'PG_TABLESPACE', tablespace: 'cold', field: 'spec.tablespaces[name=cold].storage.pvcTemplate.resources.requests.storage' }, '80Gi')
29
+ expect(m.spec.tablespaces[0]).toEqual(cluster.spec.tablespaces[0])
30
+ expect(m.spec.tablespaces[1].storage).toEqual({ pvcTemplate: { resources: { requests: { storage: '80Gi' } }, storageClassName: 'slow' } })
31
+ expect(cluster.spec.tablespaces[1]!.storage.pvcTemplate?.resources.requests.storage).toBe('50Gi')
32
+ })
33
+ })
34
+
35
+ describe('cnpgSlotRetentionText', () => {
36
+ it('shows the largest slot, never a sum, and 0 B when nothing is held', () => {
37
+ expect(cnpgSlotRetentionText([])).toBe('No slots')
38
+ expect(cnpgSlotRetentionText([{ name: 'a', retainedBytes: 0 }])).toBe('0 B')
39
+ expect(cnpgSlotRetentionText([{ name: 'a', retainedBytes: 1024 }])).toBe('1.0 KiB')
40
+ const two = cnpgSlotRetentionText([{ name: 'a', retainedBytes: 10 * 1024 * 1024 }, { name: 'b', retainedBytes: 2 * 1024 * 1024 }])
41
+ expect(two).toBe('10 MiB (largest slot)')
42
+ expect(two).not.toContain('up to')
43
+ })
44
+ })
45
+
46
+ describe('cnpgSharedExpansionGap', () => {
47
+ const vol = (reason?: string, allow?: boolean) => ({ storageClass: { name: 'standard', allowVolumeExpansion: allow, reason } })
48
+ it('names one shared reason for every unknown volume, so the page says it once', () => {
49
+ expect(cnpgSharedExpansionGap([vol('cannot list StorageClasses'), vol('cannot list StorageClasses')])).toBe('cannot list StorageClasses')
50
+ expect(cnpgSharedExpansionGap([vol('a'), vol('b')])).toBeUndefined()
51
+ expect(cnpgSharedExpansionGap([vol('a')])).toBeUndefined()
52
+ expect(cnpgSharedExpansionGap([vol(undefined, true), vol(undefined, true)])).toBeUndefined()
53
+ })
54
+ })
55
+
56
+ describe('cnpgWALUsageFloor', () => {
57
+ const vol = (state: string, claim = 'pg-1') => ({ claim, role: 'PG_DATA', capacity: '1Gi', capacityBytes: 1024 ** 3, storageClass: {}, resize: {}, usage: { state } }) as CNPGStorageVolume
58
+ const wal = (sizeBytes: number, volume = 'pg-1') => ({ status: { state: 'ok' }, metrics: { state: 'ok' }, volume, sizeBytes }) as CNPGStorageWAL
59
+ it('is the WAL size on the claim that holds it, only when kubelet did not measure', () => {
60
+ expect(cnpgWALUsageFloor(vol('noSeries'), wal(512 * 1024 ** 2))).toEqual({ bytes: 512 * 1024 ** 2, ratio: 0.5 })
61
+ expect(cnpgWALUsageFloor(vol('ok'), wal(512 * 1024 ** 2))).toBeUndefined()
62
+ expect(cnpgWALUsageFloor(vol('noSeries', 'pg-1-wal'), wal(512 * 1024 ** 2))).toBeUndefined()
63
+ expect(cnpgWALUsageFloor(vol('noSeries'), { ...wal(1), metrics: { state: 'denied' } } as CNPGStorageWAL)).toBeUndefined()
64
+ })
65
+ })
66
+
67
+ describe('cnpgFloorTone', () => {
68
+ it('never reads a lower bound below the warning line as healthy', () => {
69
+ expect(cnpgFloorTone(0.5)).toBe('unknown')
70
+ expect(cnpgFloorTone(0.85)).toBe('degraded')
71
+ expect(cnpgFloorTone(3.3)).toBe('unhealthy')
72
+ expect(cnpgFloorTone(undefined)).toBe('unknown')
73
+ })
74
+ })
75
+
76
+ describe('cnpgInstanceDiskTone', () => {
77
+ const measured = (ratio: number, claim = 'pg-1') => ({ claim, role: 'PG_DATA', capacity: '1Gi', capacityBytes: 1024 ** 3, storageClass: {}, resize: {}, usage: { state: 'ok', ratio } }) as CNPGStorageVolume
78
+ const unmeasured = (claim: string) => ({ claim, role: 'PG_WAL', capacity: '1Gi', capacityBytes: 1024 ** 3, storageClass: {}, resize: {}, usage: { state: 'noSeries' } }) as CNPGStorageVolume
79
+ const wal = (sizeBytes: number, volume: string) => ({ status: { state: 'ok' }, metrics: { state: 'ok' }, volume, sizeBytes }) as CNPGStorageWAL
80
+ it('lets a dangerous WAL bound on one volume outrank a calm measurement on another', () => {
81
+ expect(cnpgInstanceDiskTone([measured(0.2), unmeasured('pg-1-wal')], wal(0.95 * 1024 ** 3, 'pg-1-wal'))).toBe('unhealthy')
82
+ })
83
+ it('stays unknown, not healthy, while any volume is unmeasured', () => {
84
+ expect(cnpgInstanceDiskTone([measured(0.2), unmeasured('pg-1-wal')], wal(0.1 * 1024 ** 3, 'pg-1-wal'))).toBe('unknown')
85
+ expect(cnpgInstanceDiskTone([measured(0.2)], undefined)).toBe('healthy')
86
+ })
87
+ })
88
+
89
+ it('does not infer slot absence from absent byte readings', () => {
90
+ expect(cnpgSlotRetentionText(undefined)).toBe('Slot inventory not read')
91
+ expect(cnpgSlotRetentionText(null)).toBe('Slot inventory not read')
92
+ expect(cnpgSlotRetentionText([], false)).toBe('Slot inventory partly read')
93
+ expect(cnpgSlotRetentionText([{ name: 'a', active: false }], false)).toBe('≥1 inactive slot; retained WAL not reported')
94
+ expect(cnpgSlotRetentionText([{ name: '_cnpg_orders_2', active: false }])).toBe('1 inactive slot; retained WAL not reported')
95
+ expect(cnpgSlotRetentionText([{ name: 'a', retainedBytes: 10 }, { name: 'b' }])).toContain('≥10 B reported; some slots unmeasured')
96
+ })
97
+
98
+ it('keeps measured retained WAL when slot inventory is unavailable', () => {
99
+ expect(cnpgSlotRetentionText(null, true, [{ slot: 'a', bytes: 2048 }])).toBe('≥2.0 KiB retained WAL reported; slot inventory not read')
100
+ })
101
+
102
+ it('can separate an unmeasured retention note from slot counts without changing the default', () => {
103
+ const slots = [{ name: '_cnpg_orders_2', active: false }]
104
+ expect(cnpgSlotRetentionText(slots, true, [], { separateMissingBytes: true })).toBe('1 inactive slot')
105
+ expect(cnpgSlotRetentionText(slots, false, [], { separateMissingBytes: true })).toBe('≥1 inactive slot')
106
+ expect(cnpgSlotRetentionText(slots)).toBe('1 inactive slot; retained WAL not reported')
107
+ })
@@ -0,0 +1,101 @@
1
+ import { formatBytes } from './lsn'
2
+ import { cnpgDiskTone, type HealthLevel } from '@skyhook-io/k8s-ui'
3
+ import type { CNPGStorageTarget, CNPGStorageVolume, CNPGStorageWAL } from '../../api/cnpg-storage'
4
+
5
+ function setPath(obj: Record<string, any>, path: string[], value: unknown) {
6
+ let cur = obj
7
+ for (const p of path.slice(0, -1)) {
8
+ cur[p] = cur[p] && typeof cur[p] === 'object' ? { ...cur[p] } : {}
9
+ cur = cur[p]
10
+ }
11
+ cur[path[path.length - 1]] = value
12
+ }
13
+
14
+ // Where the size lives under the storage block the target names.
15
+ function sizePath(t: CNPGStorageTarget): string[] {
16
+ return t.field.endsWith('.pvcTemplate.resources.requests.storage') ? ['pvcTemplate', 'resources', 'requests', 'storage'] : ['size']
17
+ }
18
+
19
+ /**
20
+ * The manifest server-side applies only the size. Tablespaces are a list the
21
+ * CRD does not merge by key, so the whole list is sent with one size changed.
22
+ */
23
+ export function buildResizeManifest(cluster: any, target: CNPGStorageTarget, size: string): Record<string, any> {
24
+ const spec: Record<string, any> = {}
25
+ if (target.role === 'PG_TABLESPACE') {
26
+ spec.tablespaces = (cluster?.spec?.tablespaces ?? []).map((ts: any) => {
27
+ if (ts?.name !== target.tablespace) return ts
28
+ const storage = { ...(ts.storage ?? {}) }
29
+ setPath(storage, sizePath(target), size)
30
+ return { ...ts, storage }
31
+ })
32
+ } else {
33
+ const block: Record<string, any> = {}
34
+ setPath(block, sizePath(target), size)
35
+ spec[target.role === 'PG_WAL' ? 'walStorage' : 'storage'] = block
36
+ }
37
+ return {
38
+ apiVersion: 'postgresql.cnpg.io/v1',
39
+ kind: 'Cluster',
40
+ metadata: { name: cluster?.metadata?.name, namespace: cluster?.metadata?.namespace },
41
+ spec,
42
+ }
43
+ }
44
+
45
+ /**
46
+ * WAL held by replication slots on one instance. Slots overlap (they can
47
+ * hold the same segments), so the figure is the largest slot, never a sum.
48
+ */
49
+ export function cnpgSlotRetentionText(slots: { name: string; active?: boolean; retainedBytes?: number }[] | null | undefined, complete = true, retained: { slot: string; bytes: number }[] = [], options: { separateMissingBytes?: boolean } = {}): string {
50
+ if (!slots) return retained.length ? `≥${formatBytes(Math.max(...retained.map((s) => s.bytes)))} retained WAL reported; slot inventory not read` : 'Slot inventory not read'
51
+ if (slots.length === 0) return complete ? 'No slots' : 'Slot inventory partly read'
52
+ const measured = slots.filter((s) => s.retainedBytes !== undefined)
53
+ if (measured.length < slots.length) {
54
+ const inactive = slots.filter((s) => s.active === false).length
55
+ const count = inactive === slots.length ? `${inactive} inactive slot${inactive === 1 ? '' : 's'}` : `${slots.length} slot${slots.length === 1 ? '' : 's'}`
56
+ if (options.separateMissingBytes && measured.length === 0) return `${complete ? '' : '≥'}${count}`
57
+ return `${complete ? '' : '≥'}${count}; retained WAL ${measured.length ? `≥${formatBytes(Math.max(...measured.map((s) => s.retainedBytes!)))} reported; some slots unmeasured` : 'not reported'}`
58
+ }
59
+ const max = Math.max(...measured.map((s) => s.retainedBytes!))
60
+ return !complete ? `${formatBytes(max)} (largest reported slot)` : max === 0 || slots.length === 1 ? formatBytes(max) : `${formatBytes(max)} (largest slot)`
61
+ }
62
+
63
+ /**
64
+ * Why StorageClass expansion is unknown, when it is the same reason for
65
+ * every volume whose class could not be read (and more than one), so it can be
66
+ * said once for the page instead of under each volume.
67
+ */
68
+ export function cnpgSharedExpansionGap(volumes: { storageClass: { name?: string; allowVolumeExpansion?: boolean; reason?: string } }[]): string | undefined {
69
+ const unknown = volumes.filter((v) => v.storageClass.name && v.storageClass.allowVolumeExpansion === undefined)
70
+ if (unknown.length < 2) return undefined
71
+ const reasons = new Set(unknown.map((v) => v.storageClass.reason ?? ''))
72
+ return reasons.size === 1 ? [...reasons][0] || undefined : undefined
73
+ }
74
+
75
+ /**
76
+ * A lower bound on a volume's used space when kubelet didn't measure it: the
77
+ * WAL the instance reports lives on this claim, so at least that much is used.
78
+ */
79
+ export function cnpgWALUsageFloor(v: CNPGStorageVolume, wal: CNPGStorageWAL | undefined): { bytes: number; ratio?: number } | undefined {
80
+ if (v.usage.state === 'ok' || !wal || wal.metrics.state !== 'ok' || wal.sizeBytes === undefined || wal.volume !== v.claim) return undefined
81
+ return { bytes: wal.sizeBytes, ratio: v.capacityBytes ? wal.sizeBytes / v.capacityBytes : undefined }
82
+ }
83
+
84
+ /** A lower bound can prove a volume is filling, never that it's fine. */
85
+ export function cnpgFloorTone(ratio: number | undefined): HealthLevel {
86
+ if (ratio === undefined) return 'unknown'
87
+ const tone = cnpgDiskTone(ratio)
88
+ return tone === 'healthy' ? 'unknown' : tone
89
+ }
90
+
91
+ /**
92
+ * An instance's disk tone across its volumes: the worst measured or bounded
93
+ * reading that crosses a line, otherwise healthy only when every volume was
94
+ * measured below it. One unmeasured volume leaves a calm verdict unknown.
95
+ */
96
+ export function cnpgInstanceDiskTone(volumes: CNPGStorageVolume[], wal: CNPGStorageWAL | undefined): HealthLevel {
97
+ const rank: Record<string, number> = { unhealthy: 3, degraded: 2, unknown: 1, healthy: 0 }
98
+ const tones = volumes.map((v) => (v.usage.ratio !== undefined ? cnpgDiskTone(v.usage.ratio) : cnpgFloorTone(cnpgWALUsageFloor(v, wal)?.ratio)))
99
+ if (tones.length === 0) return 'unknown'
100
+ return tones.reduce((a, b) => ((rank[b] ?? 1) > (rank[a] ?? 1) ? b : a))
101
+ }