@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
@@ -0,0 +1,131 @@
1
+ import type { CNPGBackend } from '../../api/cnpg-sessions'
2
+
3
+ export interface BlockingNode {
4
+ session: CNPGBackend
5
+ children: BlockingNode[]
6
+ /** Blockers of this session other than the parent it is shown under. */
7
+ alsoWaitsOn: number[]
8
+ /** This session waits on one of its own ancestors: a lock cycle (deadlock). */
9
+ cycle?: boolean
10
+ }
11
+
12
+ /**
13
+ * Blocker → victim trees from pg_blocking_pids. A root blocks others and
14
+ * waits on nobody listed; a victim appears under each blocker it waits on.
15
+ * Sessions left over after walking from the roots are in a cycle (a deadlock
16
+ * PostgreSQL has not resolved yet) and become roots of their own, marked.
17
+ */
18
+ export function buildBlockingTree(sessions: CNPGBackend[]): BlockingNode[] {
19
+ const byPid = new Map(sessions.map((s) => [s.pid, s]))
20
+ const victimsOf = new Map<number, CNPGBackend[]>()
21
+ for (const s of sessions) {
22
+ for (const b of s.blockedBy) {
23
+ if (!byPid.has(b)) continue
24
+ victimsOf.set(b, [...(victimsOf.get(b) ?? []), s])
25
+ }
26
+ }
27
+ const seen = new Set<number>()
28
+ const build = (s: CNPGBackend, parent: number | undefined, path: Set<number>): BlockingNode => {
29
+ seen.add(s.pid)
30
+ const node: BlockingNode = { session: s, children: [], alsoWaitsOn: s.blockedBy.filter((b) => b !== parent) }
31
+ const nextPath = new Set(path).add(s.pid)
32
+ for (const v of victimsOf.get(s.pid) ?? []) {
33
+ if (nextPath.has(v.pid)) {
34
+ node.cycle = true
35
+ continue
36
+ }
37
+ node.children.push(build(v, s.pid, nextPath))
38
+ }
39
+ return node
40
+ }
41
+ const roots = sessions
42
+ .filter((s) => s.blockedBy.every((b) => !byPid.has(b)) && (victimsOf.get(s.pid)?.length ?? 0) > 0)
43
+ .map((s) => build(s, undefined, new Set()))
44
+ // Waiting on a backend that is not listed (capped, or ended between reads).
45
+ for (const s of sessions) {
46
+ if (!seen.has(s.pid) && s.blockedBy.length > 0 && s.blockedBy.every((b) => !byPid.has(b))) roots.push(build(s, undefined, new Set()))
47
+ }
48
+ for (const s of sessions) {
49
+ if (!seen.has(s.pid)) {
50
+ const node = build(s, undefined, new Set())
51
+ node.cycle = true
52
+ roots.push(node)
53
+ }
54
+ }
55
+ return roots
56
+ }
57
+
58
+ /** How many sessions wait behind this one, directly or transitively. */
59
+ export function countVictims(node: BlockingNode): number {
60
+ const pids = new Set<number>()
61
+ const walk = (n: BlockingNode) => {
62
+ for (const c of n.children) {
63
+ pids.add(c.session.pid)
64
+ walk(c)
65
+ }
66
+ }
67
+ walk(node)
68
+ return pids.size
69
+ }
70
+
71
+ export interface CNPGConnectionFigure {
72
+ /** e.g. "6 in use" */
73
+ value: string
74
+ /** What it is out of, e.g. "of 97 usable (max_connections 100, 3 reserved)", shown beneath. */
75
+ limit: string
76
+ /** What the figure is measured against, and from where. */
77
+ detail: string
78
+ /** used / usable, for a headroom bar; undefined when the limit is unknown. */
79
+ ratio?: number
80
+ tone?: 'degraded' | 'unhealthy'
81
+ }
82
+
83
+ function connectionTone(ratio: number | undefined): CNPGConnectionFigure['tone'] {
84
+ if (ratio === undefined) return undefined
85
+ return ratio >= 0.95 ? 'unhealthy' : ratio >= 0.8 ? 'degraded' : undefined
86
+ }
87
+
88
+ /**
89
+ * The one connections figure the Sessions section shows. pg_stat_activity
90
+ * (read over exec) knows the superuser reserve, so it gives usable headroom;
91
+ * the exporter's count stands in when that read is unavailable and says what
92
+ * it cannot account for.
93
+ */
94
+ export function cnpgConnectionFigure(
95
+ exec: { maxConnections?: number; superuserReservedConnections?: number; clientBackends?: number } | undefined,
96
+ exporter?: { sessionsTotal?: number; maxConnections?: number },
97
+ ): CNPGConnectionFigure | undefined {
98
+ if (exec?.maxConnections !== undefined && exec.clientBackends !== undefined) {
99
+ const reserved = exec.superuserReservedConnections ?? 0
100
+ const usable = exec.maxConnections - reserved
101
+ const ratio = usable > 0 ? exec.clientBackends / usable : 1
102
+ return {
103
+ value: `${exec.clientBackends} in use`,
104
+ limit: `of ${usable} usable (max_connections ${exec.maxConnections}, ${reserved} reserved)`,
105
+ detail: `Client backends from pg_stat_activity; ${reserved} of max_connections ${exec.maxConnections} are reserved for superusers`,
106
+ ratio,
107
+ tone: connectionTone(ratio),
108
+ }
109
+ }
110
+ if (exporter?.sessionsTotal === undefined) return undefined
111
+ if (exporter.maxConnections === undefined) {
112
+ return { value: `${exporter.sessionsTotal} in use`, limit: 'max_connections not reported', detail: 'Sessions from the metrics exporter' }
113
+ }
114
+ const ratio = exporter.maxConnections > 0 ? exporter.sessionsTotal / exporter.maxConnections : 1
115
+ return {
116
+ value: `${exporter.sessionsTotal} in use`,
117
+ limit: `of max_connections ${exporter.maxConnections} (superuser reserve not read)`,
118
+ detail: 'Sessions from the metrics exporter; the superuser reserve is read only with exec into the instance',
119
+ ratio,
120
+ tone: connectionTone(ratio),
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Whether no instance has metrics readings (null = none for that Pod,
126
+ * undefined = not answered yet), so the panel says so once. That can be a
127
+ * missing metrics API or Pods not sampled yet; it never claims which.
128
+ */
129
+ export function cnpgNoMetricsReadings(results: (unknown | null | undefined)[]): boolean {
130
+ return results.length > 0 && results.every((r) => r === null)
131
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { coverageEmpty, coverageLabel, incompleteKindsText } from './shared'
3
+
4
+ describe('coverage wording', () => {
5
+ it('names every cause the server named, whatever the overall state', () => {
6
+ const mixed = { state: 'uncached' as const, uncachedNamespaces: ['b'], deniedNamespaces: ['c'] }
7
+ expect(coverageEmpty(mixed, 'Pods')).toBe('No Pods visible: no access in c; Radar does not cache Pods in b.')
8
+ expect(coverageLabel(mixed)).toBe('no access in c; not cached by Radar in b')
9
+ })
10
+ it('falls back to the state when no namespace is named', () => {
11
+ expect(coverageEmpty({ state: 'uncached' }, 'Pods')).toBe('Radar does not cache Pods in this scope.')
12
+ expect(coverageEmpty({ state: 'partial' }, 'Pods')).toBe('No Pods visible. Some namespaces were not read.')
13
+ expect(coverageLabel({ state: 'denied' })).toBe('no access')
14
+ })
15
+ it('says each reason once for the kinds it applies to', () => {
16
+ const text = incompleteKindsText(['clusters', 'backups', 'clusterImageCatalogs'], {
17
+ clusters: { state: 'partial' },
18
+ backups: { state: 'partial' },
19
+ clusterImageCatalogs: { state: 'uncached' },
20
+ })
21
+ expect(text).toBe('Cluster, Backup (not read in some namespaces); ClusterImageCatalog (not cached by Radar)')
22
+ })
23
+ it("lists Jobs it could not read, since a Cluster's Job Pods are read only where they are", () => {
24
+ expect(incompleteKindsText(['clusters'], { clusters: { state: 'partial' } }, { state: 'denied' })).toBe('Cluster (not read in some namespaces); Job (no access)')
25
+ expect(incompleteKindsText([], {}, { state: 'full' })).toBe('')
26
+ })
27
+ })
@@ -0,0 +1,110 @@
1
+ import { useId, type InputHTMLAttributes } from 'react'
2
+ import { Input } from '@skyhook-io/k8s-ui'
3
+ import type { CNPGTarget } from './targetModel'
4
+ import { CNPG_FORM_FIELD as FIELD } from '../formFields'
5
+
6
+ export function CNPGTargetFields({
7
+ value,
8
+ onChange,
9
+ idPrefix,
10
+ namespaces = [],
11
+ storageClasses = [],
12
+ namespaceFixed = false,
13
+ imageDescription,
14
+ imageRequired = false,
15
+ defaultClassDisabled = false,
16
+ }: {
17
+ value: CNPGTarget
18
+ onChange: (value: CNPGTarget) => void
19
+ idPrefix: string
20
+ namespaces?: string[]
21
+ storageClasses?: string[]
22
+ namespaceFixed?: boolean
23
+ imageDescription?: string
24
+ imageRequired?: boolean
25
+ defaultClassDisabled?: boolean
26
+ }) {
27
+ const listId = useId()
28
+ const set = (key: keyof CNPGTarget, next: string) => onChange({ ...value, [key]: next })
29
+ const input = (key: keyof CNPGTarget, label: string, props: InputHTMLAttributes<HTMLInputElement> = {}) => (
30
+ <label className="block" htmlFor={`${idPrefix}-${key}`}>
31
+ <span className="text-xs font-medium text-theme-text-secondary">{label}</span>
32
+ <Input
33
+ id={`${idPrefix}-${key}`}
34
+ value={value[key]}
35
+ onChange={(event) => set(key, event.target.value)}
36
+ autoComplete="off"
37
+ spellCheck={false}
38
+ className={FIELD}
39
+ {...props}
40
+ />
41
+ </label>
42
+ )
43
+ return (
44
+ <div className="space-y-4">
45
+ <div className="grid gap-3 sm:grid-cols-2">
46
+ {input('name', 'Cluster name', { placeholder: 'orders-db' })}
47
+ {namespaceFixed ? (
48
+ <div>
49
+ <span className="text-xs font-medium text-theme-text-secondary">Namespace</span>
50
+ <div className="mt-2 font-mono text-sm text-theme-text-primary">{value.namespace}</div>
51
+ </div>
52
+ ) : (
53
+ input('namespace', 'Namespace', { list: `${listId}-namespaces`, placeholder: 'Choose a namespace' })
54
+ )}
55
+ {input('instances', 'Instances', { type: 'number', min: 1, step: 1 })}
56
+ {input('size', 'Data volume size · per instance', { placeholder: '20Gi' })}
57
+ </div>
58
+ <p className="text-xs text-theme-text-tertiary">
59
+ One instance is the primary; the others are standbys. Choose storage for the data you expect to keep.
60
+ </p>
61
+ <div className="grid items-start gap-3 sm:grid-cols-2">
62
+ <div>
63
+ {input('storageClass', 'Storage class · optional', {
64
+ list: `${listId}-storage`,
65
+ placeholder: defaultClassDisabled ? 'No default class' : 'Cluster default',
66
+ })}
67
+ {defaultClassDisabled && !value.storageClass && (
68
+ <p className="mt-1 text-xs text-theme-text-tertiary">
69
+ The PVC template opts out of the default class. Keep empty to preserve that choice, or choose a class.
70
+ </p>
71
+ )}
72
+ </div>
73
+ <div>
74
+ {imageDescription ? (
75
+ <div>
76
+ <span className="text-xs font-medium text-theme-text-secondary">Recovery image</span>
77
+ <p className="mt-1 break-all font-mono text-sm text-theme-text-primary">{imageDescription}</p>
78
+ <p className="mt-1 text-xs text-theme-text-tertiary">
79
+ Physical recovery requires the PostgreSQL major used by the backup. Advanced YAML exposes the full configuration.
80
+ </p>
81
+ </div>
82
+ ) : (
83
+ <>
84
+ {input(
85
+ 'image',
86
+ imageRequired ? 'PostgreSQL image · required for recovery' : 'PostgreSQL image · optional',
87
+ { placeholder: imageRequired ? 'Image matching the source PostgreSQL major' : 'Operator default' },
88
+ )}
89
+ <p className="text-xs text-theme-text-tertiary">
90
+ {imageRequired
91
+ ? 'Choose an image for the PostgreSQL major used by the backup before restoring.'
92
+ : 'Leave this empty to use the image selected by the operator.'}
93
+ </p>
94
+ </>
95
+ )}
96
+ </div>
97
+ </div>
98
+ <datalist id={`${listId}-namespaces`}>
99
+ {namespaces.map((name) => (
100
+ <option key={name} value={name} />
101
+ ))}
102
+ </datalist>
103
+ <datalist id={`${listId}-storage`}>
104
+ {storageClasses.map((name) => (
105
+ <option key={name} value={name} />
106
+ ))}
107
+ </datalist>
108
+ </div>
109
+ )
110
+ }
@@ -0,0 +1,137 @@
1
+ import { expect, it } from 'vitest'
2
+ import yaml from 'yaml'
3
+ import {
4
+ cnpgTargetDraft,
5
+ cnpgTargetFromManifest,
6
+ cnpgTargetIssue,
7
+ onlyCNPGTargetChanged,
8
+ sameCNPGRecoveryIdentity,
9
+ updateCNPGTargetYaml,
10
+ withCNPGTarget,
11
+ } from './targetModel'
12
+
13
+ const cluster = {
14
+ apiVersion: 'postgresql.cnpg.io/v1',
15
+ kind: 'Cluster',
16
+ metadata: { name: 'orders', namespace: 'db', labels: { app: 'orders' } },
17
+ spec: {
18
+ instances: 3,
19
+ storage: { size: '20Gi' },
20
+ imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 17 },
21
+ bootstrap: { recovery: { source: 'archive' } },
22
+ externalClusters: [{ name: 'archive' }],
23
+ postgresql: { parameters: { max_connections: '200' } },
24
+ walStorage: { size: '2Gi' },
25
+ },
26
+ }
27
+
28
+ it('updates common inputs without flattening inherited or advanced configuration', () => {
29
+ const target = {
30
+ ...cnpgTargetFromManifest(cluster),
31
+ name: 'restored',
32
+ instances: '2',
33
+ size: '40Gi',
34
+ storageClass: 'fast',
35
+ }
36
+ const next = withCNPGTarget(cluster, target)
37
+ expect(next).toMatchObject({
38
+ metadata: { name: 'restored', labels: { app: 'orders' } },
39
+ spec: {
40
+ instances: 2,
41
+ storage: { size: '40Gi', storageClass: 'fast' },
42
+ imageCatalogRef: cluster.spec.imageCatalogRef,
43
+ bootstrap: cluster.spec.bootstrap,
44
+ postgresql: cluster.spec.postgresql,
45
+ walStorage: cluster.spec.walStorage,
46
+ },
47
+ })
48
+ expect(cluster.spec.storage.size).toBe('20Gi')
49
+ expect(sameCNPGRecoveryIdentity(next, cluster)).toBe(true)
50
+ expect(onlyCNPGTargetChanged(cluster, next, target)).toBe(true)
51
+ expect(onlyCNPGTargetChanged(cluster, { ...next, spec: { ...next.spec, walStorage: { size: '5Gi' } } }, target)).toBe(
52
+ false,
53
+ )
54
+ })
55
+
56
+ it('preserves complex PVC templates and explicit no-default-class semantics', () => {
57
+ const source = {
58
+ ...cluster,
59
+ spec: {
60
+ ...cluster.spec,
61
+ storage: {
62
+ pvcTemplate: {
63
+ accessModes: ['ReadWriteOnce'],
64
+ storageClassName: '',
65
+ volumeMode: 'Block',
66
+ resources: { requests: { storage: '30Gi' } },
67
+ },
68
+ },
69
+ },
70
+ }
71
+ const target = { ...cnpgTargetFromManifest(source), size: '60Gi' }
72
+ const next = withCNPGTarget(source, target)
73
+ expect(next.spec.storage).toEqual({
74
+ pvcTemplate: {
75
+ accessModes: ['ReadWriteOnce'],
76
+ storageClassName: '',
77
+ volumeMode: 'Block',
78
+ resources: { requests: { storage: '60Gi' } },
79
+ },
80
+ })
81
+ const selected = withCNPGTarget(next, { ...target, storageClass: 'fast' })
82
+ expect(selected.spec.storage.storageClass).toBe('fast')
83
+ expect(selected.spec.storage.pvcTemplate.storageClassName).toBe('')
84
+ const cleared = withCNPGTarget(selected, { ...target, storageClass: '' })
85
+ expect(cleared.spec.storage.storageClass).toBeUndefined()
86
+ expect(cleared.spec.storage.pvcTemplate.storageClassName).toBe('')
87
+ })
88
+
89
+ it('retains comments and arbitrary settings through a form round-trip', () => {
90
+ const text = `# archive must stay isolated\n${yaml.stringify(cluster, { version: '1.1' })}`
91
+ const next = updateCNPGTargetYaml(text, { ...cnpgTargetFromManifest(cluster), name: 'new-orders', size: '50Gi' })!
92
+ expect(next).toContain('archive must stay isolated')
93
+ expect(cnpgTargetDraft(next)).toMatchObject({
94
+ metadata: { name: 'new-orders' },
95
+ spec: { storage: { size: '50Gi' }, postgresql: { parameters: { max_connections: '200' } } },
96
+ })
97
+ })
98
+
99
+ it('adds missing storage parents as Kubernetes mappings in YAML 1.1', () => {
100
+ const source = { ...cluster, spec: { instances: 1 } }
101
+ const text = yaml.stringify(source, { version: '1.1' })
102
+ const next = updateCNPGTargetYaml(text, { ...cnpgTargetFromManifest(source), size: '20Gi', storageClass: 'fast' })!
103
+ expect(next).not.toContain('!!omap')
104
+ expect(cnpgTargetDraft(next)?.spec.storage).toEqual({ size: '20Gi', storageClass: 'fast' })
105
+ })
106
+
107
+ it.each([
108
+ '',
109
+ 'kind: [',
110
+ yaml.stringify({ ...cluster, apiVersion: 'cluster.x-k8s.io/v1beta1' }),
111
+ `${yaml.stringify(cluster)}---\nkind: Secret\n`,
112
+ yaml.stringify({ ...cluster, spec: 'bad' }),
113
+ yaml.stringify({ ...cluster, spec: { storage: 'bad' } }),
114
+ yaml.stringify({ ...cluster, spec: { storage: { pvcTemplate: [] } } }),
115
+ ])('keeps unsupported YAML outside the form projection: %s', (text) => {
116
+ expect(cnpgTargetDraft(text)).toBeNull()
117
+ expect(updateCNPGTargetYaml(text, cnpgTargetFromManifest(cluster))).toBeNull()
118
+ })
119
+
120
+ it('requires actual storage and a known recovery image while allowing standard quantities', () => {
121
+ const target = cnpgTargetFromManifest(cluster)
122
+ expect(cnpgTargetIssue(target)).toBeUndefined()
123
+ for (const size of ['20Gi', '1e9', '2G']) expect(cnpgTargetIssue({ ...target, size })).toBeUndefined()
124
+ for (const size of ['', '0', '-2Gi', 'banana']) expect(cnpgTargetIssue({ ...target, size })).toContain('volume size')
125
+ for (const instances of ['0', '1.5', '-2', '9007199254740993'])
126
+ expect(cnpgTargetIssue({ ...target, instances })).toContain('whole number')
127
+ expect(cnpgTargetIssue(target, true)).toContain('matching the source')
128
+ expect(cnpgTargetIssue({ ...target, image: 'postgres:17' }, true)).toBeUndefined()
129
+ })
130
+
131
+ it('changes an image override without inventing a catalog or losing the recovery source', () => {
132
+ const source = { ...cluster, spec: { ...cluster.spec, imageCatalogRef: undefined, imageName: 'postgres:17' } }
133
+ const next = withCNPGTarget(source, { ...cnpgTargetFromManifest(source), image: 'postgres:17.6' })
134
+ expect(next.spec.imageName).toBe('postgres:17.6')
135
+ expect(next.spec.bootstrap).toEqual(source.spec.bootstrap)
136
+ expect(sameCNPGRecoveryIdentity(source, next)).toBe(false)
137
+ })
@@ -0,0 +1,149 @@
1
+ import yaml from 'yaml'
2
+ import { isApiGroup, parseQuantityToNumber } from '@skyhook-io/k8s-ui'
3
+ import { validateRFC1123Label } from '@skyhook-io/k8s-ui/utils/validators'
4
+
5
+ export interface CNPGTarget {
6
+ name: string
7
+ namespace: string
8
+ instances: string
9
+ size: string
10
+ storageClass: string
11
+ image: string
12
+ }
13
+
14
+ export function cnpgTargetFromManifest(manifest: Record<string, any>): CNPGTarget {
15
+ const storage = manifest.spec?.storage
16
+ return {
17
+ name: manifest.metadata?.name ?? '',
18
+ namespace: manifest.metadata?.namespace ?? '',
19
+ instances: String(manifest.spec?.instances ?? 1),
20
+ size: storage?.size ?? storage?.pvcTemplate?.resources?.requests?.storage ?? '',
21
+ storageClass: storage?.storageClass ?? storage?.pvcTemplate?.storageClassName ?? '',
22
+ image: manifest.spec?.imageName ?? '',
23
+ }
24
+ }
25
+
26
+ export function cnpgTargetIssue(target: CNPGTarget, requireImage = false): string | undefined {
27
+ const name = validateRFC1123Label(target.name.trim())
28
+ if (!name.valid) return `Cluster name ${name.error}.`
29
+ const namespace = validateRFC1123Label(target.namespace.trim())
30
+ if (!namespace.valid) return `Namespace ${namespace.error}.`
31
+ if (
32
+ !/^\d+$/.test(target.instances) ||
33
+ Number(target.instances) < 1 ||
34
+ !Number.isSafeInteger(Number(target.instances))
35
+ )
36
+ return 'Choose a positive whole number of instances.'
37
+ if (!(parseQuantityToNumber(target.size.trim()) > 0)) return 'Enter a positive data volume size, such as 20Gi.'
38
+ if (requireImage && !target.image.trim()) return 'Enter the image matching the source PostgreSQL major version.'
39
+ }
40
+
41
+ export function withCNPGTarget(manifest: Record<string, any>, target: CNPGTarget): Record<string, any> {
42
+ const out = structuredClone(manifest)
43
+ out.metadata = { ...out.metadata, name: target.name.trim(), namespace: target.namespace.trim() }
44
+ out.spec.instances = Number(target.instances)
45
+ const storage = out.spec.storage ?? (out.spec.storage = {})
46
+ if (storage.size !== undefined || !storage.pvcTemplate?.resources?.requests?.storage)
47
+ storage.size = target.size.trim()
48
+ else storage.pvcTemplate.resources.requests.storage = target.size.trim()
49
+ if (target.storageClass.trim() !== cnpgTargetFromManifest(manifest).storageClass) {
50
+ if (target.storageClass.trim()) storage.storageClass = target.storageClass.trim()
51
+ else {
52
+ delete storage.storageClass
53
+ if (storage.pvcTemplate && storage.pvcTemplate.storageClassName !== '')
54
+ delete storage.pvcTemplate.storageClassName
55
+ }
56
+ }
57
+ if (!out.spec.imageCatalogRef) {
58
+ if (target.image.trim()) out.spec.imageName = target.image.trim()
59
+ else delete out.spec.imageName
60
+ }
61
+ return out
62
+ }
63
+
64
+ export function cnpgTargetDraft(text: string): Record<string, any> | null {
65
+ try {
66
+ const documents = yaml.parseAllDocuments(text, { version: '1.1' })
67
+ if (documents.length !== 1 || documents[0].errors.length > 0) return null
68
+ const document = documents[0]
69
+ for (const path of [
70
+ ['spec'],
71
+ ['spec', 'storage'],
72
+ ['spec', 'storage', 'pvcTemplate'],
73
+ ['spec', 'storage', 'pvcTemplate', 'resources'],
74
+ ['spec', 'storage', 'pvcTemplate', 'resources', 'requests'],
75
+ ]) {
76
+ const node = document.getIn(path, true)
77
+ if (node !== undefined && !yaml.isMap(node)) return null
78
+ }
79
+ const manifest = documents[0].toJS()
80
+ if (
81
+ manifest?.kind !== 'Cluster' ||
82
+ !isApiGroup(manifest.apiVersion, 'postgresql.cnpg.io') ||
83
+ !manifest.spec ||
84
+ typeof manifest.metadata?.name !== 'string' ||
85
+ typeof manifest.metadata?.namespace !== 'string'
86
+ )
87
+ return null
88
+ const target = cnpgTargetFromManifest(manifest)
89
+ if ([target.size, target.storageClass, target.image].some((value) => typeof value !== 'string')) return null
90
+ return manifest
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ export function updateCNPGTargetYaml(text: string, target: CNPGTarget): string | null {
97
+ const current = cnpgTargetDraft(text)
98
+ if (!current) return null
99
+ const next = withCNPGTarget(current, target)
100
+ const document = yaml.parseDocument(text, { version: '1.1' })
101
+ const paths = [
102
+ ['metadata', 'name'],
103
+ ['metadata', 'namespace'],
104
+ ['spec', 'instances'],
105
+ ['spec', 'storage', 'size'],
106
+ ['spec', 'storage', 'storageClass'],
107
+ ['spec', 'storage', 'pvcTemplate', 'resources', 'requests', 'storage'],
108
+ ['spec', 'storage', 'pvcTemplate', 'storageClassName'],
109
+ ['spec', 'imageName'],
110
+ ]
111
+ for (const path of paths) {
112
+ const at = (object: Record<string, any>) => path.reduce<any>((value, key) => value?.[key], object)
113
+ if (at(current) === at(next)) continue
114
+ if (at(next) === undefined) document.deleteIn(path)
115
+ else {
116
+ // YAML 1.1 creates !!omap for missing setIn parents; Kubernetes expects plain mappings.
117
+ for (let depth = 1; depth < path.length; depth++) {
118
+ const parent = path.slice(0, depth)
119
+ if (!document.hasIn(parent)) document.setIn(parent, document.createNode({}))
120
+ }
121
+ document.setIn(path, at(next))
122
+ }
123
+ }
124
+ return document.toString()
125
+ }
126
+
127
+ export function sameCNPGRecoveryIdentity(left: Record<string, any>, right: Record<string, any>): boolean {
128
+ const identity = (manifest: Record<string, any>) => ({
129
+ bootstrap: manifest.spec.bootstrap,
130
+ externalClusters: manifest.spec.externalClusters,
131
+ imageName: manifest.spec.imageName,
132
+ imageCatalogRef: manifest.spec.imageCatalogRef,
133
+ })
134
+ return (
135
+ yaml.stringify(identity(left), { sortMapEntries: true }) ===
136
+ yaml.stringify(identity(right), { sortMapEntries: true })
137
+ )
138
+ }
139
+
140
+ export function onlyCNPGTargetChanged(
141
+ before: Record<string, any>,
142
+ after: Record<string, any>,
143
+ target: CNPGTarget,
144
+ ): boolean {
145
+ return (
146
+ yaml.stringify(withCNPGTarget(before, target), { sortMapEntries: true }) ===
147
+ yaml.stringify(after, { sortMapEntries: true })
148
+ )
149
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildCNPGFleet, type CNPGFleetRow, type CNPGProblem } from '@skyhook-io/k8s-ui'
3
+ import { cnpgInstancePillLabel, cnpgPillsToShow, cnpgRowStatus } from './fleetStatus'
4
+
5
+ const problem = (severity: CNPGProblem['severity'], title: string) => ({ id: title, severity, title }) as CNPGProblem
6
+ const row = (over: Partial<CNPGFleetRow>) =>
7
+ ({ attention: false, problems: [], controllerStatus: { text: 'Cluster in healthy state', level: 'healthy' }, ...over }) as CNPGFleetRow
8
+
9
+ describe('cnpgRowStatus', () => {
10
+ it('leads with the most severe problem and counts the rest', () => {
11
+ expect(cnpgRowStatus(row({ attention: true, problems: [problem('warning', 'Backup failed: disk'), problem('critical', 'WAL archiving failing')] }))).toEqual({
12
+ tone: 'unhealthy',
13
+ label: 'Critical: WAL archiving failing (+1 more)',
14
+ })
15
+ expect(cnpgRowStatus(row({ attention: true, problems: [problem('warning', 'Backup failed: disk'), problem('posture', 'No backup schedule')] }))).toEqual({
16
+ tone: 'degraded',
17
+ label: 'Needs attention: Backup failed: disk (+1 more)',
18
+ })
19
+ })
20
+ it('says no problems only with a readable phase, never healthy when unknown', () => {
21
+ expect(cnpgRowStatus(row({}))).toEqual({ tone: 'healthy', label: 'No concerns detected in what Radar read · CNPG phase: Cluster in healthy state' })
22
+ const unknown = cnpgRowStatus(row({ controllerStatus: { text: '', level: 'unknown' } }))
23
+ expect(unknown).toEqual({ tone: 'unknown', label: 'Status unknown · CNPG phase: not reported' })
24
+ expect(unknown.label).not.toContain('No concerns')
25
+ expect(cnpgRowStatus(row({ controllerStatus: { text: 'Upgrading cluster', level: 'degraded' } })).tone).toBe('degraded')
26
+ })
27
+ })
28
+
29
+ describe('cnpgInstancePillLabel', () => {
30
+ it('names a replica as a standby too', () => {
31
+ expect(cnpgInstancePillLabel({ name: 'pg-wal-failing-1', role: 'replica', ready: false })).toBe('pg-wal-failing-1 · replica (standby) · Pod not ready')
32
+ expect(cnpgInstancePillLabel({ name: 'pg-1', role: 'primary', ready: null })).toBe('pg-1 · primary · Pod readiness unknown')
33
+ })
34
+ })
35
+
36
+ describe('cnpgPillsToShow', () => {
37
+ it('shows every pill up to the bound, and otherwise leaves room for "+N"', () => {
38
+ expect(cnpgPillsToShow([1, 2, 3, 4, 5], 5)).toEqual({ shown: [1, 2, 3, 4, 5], hidden: [] })
39
+ expect(cnpgPillsToShow([1, 2, 3, 4, 5, 6, 7], 5)).toEqual({ shown: [1, 2, 3, 4], hidden: [5, 6, 7] })
40
+ })
41
+ })
42
+
43
+ it('gives a destination-blocked schedule an amber fleet status', () => {
44
+ const fleet = buildCNPGFleet({ installed: true, context: 'test', namespaces: null, coverage: { clusters: { state: 'full' }, pods: { state: 'full' }, scheduledBackups: { state: 'full' } }, objects: { clusters: [{ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'payments', namespace: 'db' }, spec: { instances: 1 }, status: { phase: 'Cluster in healthy state', readyInstances: 1 } }], scheduledBackups: [{ metadata: { name: 'payments-nightly', namespace: 'db' }, spec: { cluster: { name: 'payments' } } }] }, issues: [{ id: 'blocked-schedule', reason: 'CNPGScheduleDestinationMissing', severity: 'warning', kind: 'ScheduledBackup', group: 'postgresql.cnpg.io', namespace: 'db', name: 'payments-nightly', message: 'Backup schedule payments-nightly cannot run: no backup destination' }], audit: [], backupsOmitted: 0 })
45
+ expect(cnpgRowStatus(fleet.rows[0])).toMatchObject({ tone: 'degraded', label: 'Needs attention: Backup schedule payments-nightly cannot run: no backup destination' })
46
+ })
@@ -0,0 +1,43 @@
1
+ import type { CNPGFleetRow, HealthLevel } from '@skyhook-io/k8s-ui'
2
+
3
+ /**
4
+ * A fleet row's status dot and the sentence behind it. Problems (warning or
5
+ * worse) decide it first; without any, the operator's phase does, and a phase
6
+ * Radar cannot read stays unknown rather than healthy.
7
+ */
8
+ export function cnpgRowStatus(row: Pick<CNPGFleetRow, 'attention' | 'problems' | 'controllerStatus'>): { tone: HealthLevel; label: string } {
9
+ const phase = `CNPG phase: ${row.controllerStatus.text || 'not reported'}`
10
+ const counted = row.problems.filter((p) => p.severity !== 'posture')
11
+ if (row.attention && counted.length > 0) {
12
+ const critical = counted.filter((p) => p.severity === 'critical')
13
+ const lead = critical[0] ?? counted[0]
14
+ // The same count the Needs attention cell shows: every other problem, posture included.
15
+ const more = row.problems.length - 1
16
+ return {
17
+ tone: critical.length > 0 ? 'unhealthy' : 'degraded',
18
+ label: `${critical.length > 0 ? 'Critical' : 'Needs attention'}: ${lead.title}${more > 0 ? ` (+${more} more)` : ''}`,
19
+ }
20
+ }
21
+ const level = row.controllerStatus.level
22
+ switch (level) {
23
+ case 'healthy':
24
+ case 'neutral':
25
+ return { tone: level, label: `No concerns detected in what Radar read · ${phase}` }
26
+ case 'unknown':
27
+ return { tone: 'unknown', label: `Status unknown · ${phase}` }
28
+ default:
29
+ return { tone: level, label: `No concerns detected, but the cluster is not healthy · ${phase}` }
30
+ }
31
+ }
32
+
33
+ /** "pg-1 · replica (standby) · not ready": CNPG's docs say standby where the role label says replica. */
34
+ export function cnpgInstancePillLabel(pod: CNPGFleetRow['pods'][number]): string {
35
+ const role = pod.role === 'primary' ? 'primary' : pod.role === 'replica' ? 'replica (standby)' : 'role unknown'
36
+ const ready = pod.ready === true ? 'Pod ready' : pod.ready === false ? 'Pod not ready' : 'Pod readiness unknown'
37
+ return `${pod.name} · ${role} · ${ready}`
38
+ }
39
+
40
+ /** The instance pills a fleet row shows (primary first, as listed) and those left for the "+N" hover. */
41
+ export function cnpgPillsToShow<T>(pods: T[], max: number): { shown: T[]; hidden: T[] } {
42
+ return pods.length <= max ? { shown: pods, hidden: [] } : { shown: pods.slice(0, max - 1), hidden: pods.slice(max - 1) }
43
+ }
@@ -0,0 +1,2 @@
1
+ export const CNPG_FORM_FIELD =
2
+ 'mt-1 w-full rounded-lg border border-theme-border bg-theme-base px-3 py-1.5 text-sm text-theme-text-primary'
@@ -0,0 +1,11 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { GrantText } from '../workspace/layout'
4
+
5
+ describe('GrantText', () => {
6
+ it('keeps the verb and resource together, with the scope as plain text', () => {
7
+ const html = renderToStaticMarkup(<GrantText grant={{ verb: 'get', group: 'admissionregistration.k8s.io', resource: 'mutatingwebhookconfigurations' }} />)
8
+ expect(html).toMatch(/<code[^>]*whitespace-nowrap[^>]*>get mutatingwebhookconfigurations.admissionregistration.k8s.io<\/code> cluster-wide/)
9
+ expect(renderToStaticMarkup(<GrantText grant={{ verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'pgrt' }} />)).toMatch(/>get pods\/proxy<\/code> in namespace pgrt/)
10
+ })
11
+ })