@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
package/src/api/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { canonicalResourceGroup } from '@skyhook-io/k8s-ui/utils/api-resources'
2
2
  import { knownKindForPluralWithGroup, pluralToKind } from '@skyhook-io/k8s-ui/utils/navigation'
3
- import { useEffect, useRef } from 'react'
3
+ import { useEffect, useRef, useCallback, useMemo } from 'react'
4
4
  import type { KueueAdmissionResponse } from '@skyhook-io/k8s-ui/types/scheduling'
5
5
  import type { GitOpsWriteEvidence } from '@skyhook-io/k8s-ui/utils/gitops-write-guard'
6
6
  import type {
@@ -21,7 +21,7 @@ import type {
21
21
  YamlDocumentIdentity,
22
22
  YamlSchemaLoadResult,
23
23
  } from '@skyhook-io/k8s-ui'
24
- import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
24
+ import { useQuery, useMutation, useQueryClient, skipToken, type QueryClient } from '@tanstack/react-query'
25
25
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
26
26
  import { useIsAuthEnabled, useNamespacedCapabilities } from '../contexts/CapabilitiesContext'
27
27
  import type {
@@ -42,6 +42,8 @@ import type {
42
42
  ResourceDiff,
43
43
  UpgradeInfo,
44
44
  BatchUpgradeInfo,
45
+ ChartSourceCandidate,
46
+ ChartSourceStatus,
45
47
  ValuesDiff,
46
48
  ValuesPreviewResponse,
47
49
  HelmRepository,
@@ -229,6 +231,16 @@ export function isNotFoundError(error: unknown): boolean {
229
231
  return error instanceof ApiError && error.status === 404;
230
232
  }
231
233
 
234
+ /**
235
+ * The server's explanation when it refused a request because the browser
236
+ * reported it came from another site, or undefined for any other error.
237
+ */
238
+ export function crossOriginRefusalMessage(error: unknown): string | undefined {
239
+ return error instanceof ApiError && error.data?.error_code === "cross_origin_refused"
240
+ ? error.message
241
+ : undefined;
242
+ }
243
+
232
244
  export function isCapacityCursorInvalidError(error: unknown): boolean {
233
245
  return (
234
246
  error instanceof ApiError &&
@@ -287,6 +299,20 @@ export function isKindSyncFailed(error: unknown): boolean {
287
299
  );
288
300
  }
289
301
 
302
+ // timelineDatabaseError returns the driver's error when a timeline read failed
303
+ // because Radar can't connect to its PostgreSQL database, and null for any other
304
+ // failure. The server redacts the password before it sends the error.
305
+ export function timelineDatabaseError(error: unknown): string | null {
306
+ if (
307
+ !(error instanceof ApiError) ||
308
+ error.status !== 503 ||
309
+ error.data?.error_code !== "timeline_database_unreachable"
310
+ )
311
+ return null;
312
+ const detail = error.data.detail;
313
+ return typeof detail === "string" ? detail : "";
314
+ }
315
+
290
316
  // SyncKindState / SyncStatusSnapshot mirror k8score's SyncSnapshot — attached
291
317
  // to the connection-status payload while the initial informer sync is running.
292
318
  export interface SyncKindState {
@@ -515,6 +541,10 @@ export type { AuditFinding, ResourceGroup, CheckMeta, Check };
515
541
 
516
542
  export interface AuditResponse {
517
543
  missingInputs?: string[];
544
+ // Absent on a Radar that predates the per-resource opt-out.
545
+ ignoreChecksAnnotation?: string;
546
+ // checkID → namespace → resources whose findings the annotation hid.
547
+ annotationIgnoredByNamespace?: Record<string, Record<string, number>>;
518
548
  summary: DashboardAudit;
519
549
  findings: AuditFinding[];
520
550
  groups: ResourceGroup[];
@@ -729,9 +759,13 @@ export interface IssuesResponse {
729
759
  // state 'degraded' means core workload reads are denied, so an empty list may
730
760
  // mean "can't see" rather than "nothing broken" — the UI must say so.
731
761
  visibility?: { state?: string; impact?: string };
762
+ // Issue sources Radar couldn't check, so their issues may be missing: an
763
+ // empty list is not an all-clear. Today only "helm"; a newer Radar may send
764
+ // others. Absent when every source was checked, and from older Radars.
765
+ unchecked_sources?: { source: string; reason: string }[];
732
766
  }
733
767
 
734
- export function useIssues(namespaces: string[] = []) {
768
+ export function useIssues(namespaces: string[] = [], enabled = true) {
735
769
  const params =
736
770
  namespaces.length > 0 ? `?namespaces=${namespaces.join(",")}` : "";
737
771
  return useQuery<IssuesResponse>({
@@ -739,6 +773,7 @@ export function useIssues(namespaces: string[] = []) {
739
773
  queryFn: () => fetchJSON(`/issues${params}`),
740
774
  staleTime: 30000,
741
775
  refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
776
+ enabled,
742
777
  });
743
778
  }
744
779
 
@@ -790,6 +825,38 @@ export function useResourceIssues(
790
825
  });
791
826
  }
792
827
 
828
+ // The Issues page narrowed to one subject: the same per-resource lookup with
829
+ // ?coverage=1, which also says whether Radar reads the subject's kind and what
830
+ // the caller's RBAC withheld. Polls like the page list.
831
+ export interface SubjectIssuesResponse {
832
+ issues: Issue[];
833
+ coverage: "ok" | "syncing" | "notWatched" | "unknownKind";
834
+ withheld?: { issues: number };
835
+ visibility?: { state?: string; impact?: string };
836
+ }
837
+
838
+ export function useSubjectIssues(
839
+ subject: { kind: string; group: string; namespace: string; name: string } | null,
840
+ enabled: boolean,
841
+ ) {
842
+ const capabilities = useCapabilities();
843
+ const { support, gatedKey, guard } = useRadarFeature('resourceIssueCoverage');
844
+ const pathNs = subject?.namespace ? encodeURIComponent(subject.namespace) : "_";
845
+ const params = new URLSearchParams({ coverage: "1" });
846
+ if (subject?.group) params.set("group", subject.group);
847
+ return useQuery<SubjectIssuesResponse>({
848
+ queryKey: ["issues", "subject", subject?.kind ?? "", subject?.group ?? "", subject?.namespace ?? "", subject?.name ?? "", support, ...gatedKey],
849
+ queryFn: () => {
850
+ if (!capabilities.data && capabilities.error) throw capabilities.error;
851
+ return guard(() => fetchJSON<SubjectIssuesResponse>(`/issues/resource/${encodeURIComponent(subject!.kind)}/${pathNs}/${encodeURIComponent(subject!.name)}?${params}`));
852
+ },
853
+ staleTime: 30000,
854
+ refetchInterval: ISSUES_REFRESH_INTERVAL_MS,
855
+ // The existing route alone cannot prove support for this opt-in shape.
856
+ enabled: enabled && !!subject && !capabilities.isPending,
857
+ });
858
+ }
859
+
793
860
  import type { Trace as NetworkTrace, InClusterCapability, DrainPlan, DrainPlanPod } from '@skyhook-io/k8s-ui'
794
861
 
795
862
  // useTrace polls the static path-shaped diagnosis for one network entry
@@ -1781,13 +1848,14 @@ export function useRadarFeature(feature: RadarFeature) {
1781
1848
  const currentVersion = versionInfo?.currentVersion || radarVersion
1782
1849
  const latestVersion = versionInfo?.latestVersion
1783
1850
  const support = radarFeatureSupport(feature, capabilities, currentVersion)
1851
+ // Stable across renders, so a callback built on guard keeps its identity.
1852
+ const guard = useCallback(
1853
+ <T,>(request: () => Promise<T>): Promise<T> => guardRadarFeature(feature, support, { currentVersion, latestVersion }, request),
1854
+ [feature, support, currentVersion, latestVersion],
1855
+ )
1856
+ const gatedKey = useMemo(() => (support === 'unsupported' ? ['radar-feature-unsupported'] : []), [support])
1784
1857
 
1785
- return {
1786
- support,
1787
- gatedKey: support === 'unsupported' ? ['radar-feature-unsupported'] : [],
1788
- guard: <T,>(request: () => Promise<T>): Promise<T> =>
1789
- guardRadarFeature(feature, support, { currentVersion, latestVersion }, request),
1790
- }
1858
+ return { support, gatedKey, guard }
1791
1859
  }
1792
1860
 
1793
1861
  // ============================================================================
@@ -2448,6 +2516,11 @@ export function useApplicationHistory(
2448
2516
  });
2449
2517
  }
2450
2518
 
2519
+ // How often a page asks again while Radar is still connecting to argocd-server,
2520
+ // and while it retries a connect that failed.
2521
+ const ARGO_CONNECTING_POLL_MS = 2000;
2522
+ const ARGO_RETRYING_POLL_MS = 10_000;
2523
+
2451
2524
  export function useGitOpsTree(
2452
2525
  kind: string,
2453
2526
  namespace: string,
@@ -2470,6 +2543,14 @@ export function useGitOpsTree(
2470
2543
  ),
2471
2544
  enabled: Boolean(kind && name) && (options?.enabled ?? true),
2472
2545
  staleTime: 5000,
2546
+ // Radar answers at once while it connects to argocd-server; ask again
2547
+ // until Argo's per-resource health is in.
2548
+ refetchInterval: (query) =>
2549
+ query.state.data?.healthApiPending
2550
+ ? ARGO_CONNECTING_POLL_MS
2551
+ : query.state.data?.healthApiRetrying
2552
+ ? ARGO_RETRYING_POLL_MS
2553
+ : false,
2473
2554
  });
2474
2555
  }
2475
2556
 
@@ -2502,8 +2583,15 @@ export function useGitOpsInsights(
2502
2583
  staleTime: 5000,
2503
2584
  refetchInterval: (query) => {
2504
2585
  const phase = query.state.data?.summary?.operationPhase;
2505
- return phase === "Running" || phase === "Terminating"
2506
- ? INSIGHTS_RUNNING_POLL_MS
2586
+ if (phase === "Running" || phase === "Terminating") {
2587
+ return INSIGHTS_RUNNING_POLL_MS;
2588
+ }
2589
+ const summary = query.state.data?.summary;
2590
+ if (summary?.resourceHealthApiPending || query.state.data?.capabilities?.argoConnecting) {
2591
+ return ARGO_CONNECTING_POLL_MS;
2592
+ }
2593
+ return summary?.resourceHealthApiRetrying || query.state.data?.capabilities?.argoRetrying
2594
+ ? ARGO_RETRYING_POLL_MS
2507
2595
  : false;
2508
2596
  },
2509
2597
  });
@@ -3204,23 +3292,31 @@ function retryMetricsQuery(failureCount: number, error: unknown): boolean {
3204
3292
  return !isMetricsUnavailableError(error) && failureCount < 1;
3205
3293
  }
3206
3294
 
3295
+ // The query behind usePodMetrics, for callers reading several Pods at once
3296
+ // (useQueries) that must share its cache and its null-when-unavailable rule.
3297
+ // null means no metrics for this Pod: the metrics API is missing, or the Pod
3298
+ // has not been scraped yet; one Pod's null cannot tell those apart.
3299
+ export function podMetricsQuery(namespace: string, podName: string, enabled = true) {
3300
+ return {
3301
+ queryKey: ["pod-metrics", namespace, podName] as const,
3302
+ queryFn: () =>
3303
+ fetchMetricsOrNull<PodMetrics>(`/metrics/pods/${namespace}/${podName}`),
3304
+ enabled: Boolean(namespace && podName) && enabled,
3305
+ staleTime: 15000,
3306
+ refetchInterval: 30000,
3307
+ refetchOnMount: "always" as const,
3308
+ refetchOnReconnect: "always" as const,
3309
+ retry: retryMetricsQuery,
3310
+ };
3311
+ }
3312
+
3207
3313
  // Fetch metrics for a specific pod
3208
3314
  export function usePodMetrics(
3209
3315
  namespace: string,
3210
3316
  podName: string,
3211
3317
  options?: { enabled?: boolean },
3212
3318
  ) {
3213
- return useQuery<PodMetrics | null>({
3214
- queryKey: ["pod-metrics", namespace, podName],
3215
- queryFn: () =>
3216
- fetchMetricsOrNull<PodMetrics>(`/metrics/pods/${namespace}/${podName}`),
3217
- enabled: Boolean(namespace && podName) && (options?.enabled ?? true),
3218
- staleTime: 15000,
3219
- refetchInterval: 30000,
3220
- refetchOnMount: "always",
3221
- refetchOnReconnect: "always",
3222
- retry: retryMetricsQuery,
3223
- });
3319
+ return useQuery<PodMetrics | null>(podMetricsQuery(namespace, podName, options?.enabled ?? true));
3224
3320
  }
3225
3321
 
3226
3322
  export function usePodEnvironment(namespace: string, podName: string, enabled = true) {
@@ -3287,6 +3383,7 @@ export interface ContainerMetricsHistory {
3287
3383
  }
3288
3384
 
3289
3385
  export interface PodMetricsHistory {
3386
+ metricsAPIReachable?: boolean;
3290
3387
  namespace: string;
3291
3388
  name: string;
3292
3389
  containers: ContainerMetricsHistory[];
@@ -3522,7 +3619,7 @@ export type PrometheusTimeRange =
3522
3619
  // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
3523
3620
  export interface PrometheusPVCUsage {
3524
3621
  // Hub packages the frontend independently from per-cluster agent upgrades.
3525
- status?: "available" | "no_series" | "invalid_data" | "query_failed";
3622
+ status?: "available" | "no_series" | "invalid_data" | "query_failed" | "ambiguous_scope" | "scope_mismatch";
3526
3623
  namespace: string;
3527
3624
  name: string;
3528
3625
  used: number;
@@ -3670,6 +3767,8 @@ export interface ArgoStatus {
3670
3767
  // client is live. The two differ right after a restart (configured, reconnecting).
3671
3768
  configured: boolean;
3672
3769
  connected: boolean;
3770
+ // Not connected yet, and a connection is being made.
3771
+ connecting?: boolean;
3673
3772
  // Connected without a token: the install serves reads to everyone, so
3674
3773
  // there is nothing to add in Settings.
3675
3774
  anonymous?: boolean;
@@ -3682,8 +3781,12 @@ export function useArgoStatus(enabled = true) {
3682
3781
  queryKey: ["argocd-status"],
3683
3782
  queryFn: () => fetchJSON("/integrations/argocd/status"),
3684
3783
  enabled,
3685
- staleTime: 30000,
3686
- refetchInterval: 60000,
3784
+ // Cheap to ask (it never waits on Argo CD): reopening Settings asks
3785
+ // afresh, so "Connecting…" shows only while it's true.
3786
+ staleTime: 0,
3787
+ // While Radar connects, ask again soon so "Connecting…" doesn't linger.
3788
+ refetchInterval: (query) =>
3789
+ query.state.data?.connecting ? ARGO_CONNECTING_POLL_MS : 60000,
3687
3790
  });
3688
3791
  }
3689
3792
 
@@ -4247,7 +4350,7 @@ export function useUpdateResource() {
4247
4350
  });
4248
4351
  }
4249
4352
 
4250
- // Cascade delete preview — shows resources that will be garbage-collected
4353
+ // Cascade delete preview — visible related resources deletion may affect.
4251
4354
  export interface CascadeDeletePreview {
4252
4355
  root: { kind: string; namespace: string; name: string; group?: string };
4253
4356
  rootResolved: boolean;
@@ -4557,6 +4660,7 @@ export interface ApplyResourceResult {
4557
4660
  namespace: string;
4558
4661
  kind: string;
4559
4662
  apiVersion: string;
4663
+ uid?: string;
4560
4664
  created: boolean;
4561
4665
  }
4562
4666
 
@@ -6144,11 +6248,79 @@ export function useHelmOCISources() {
6144
6248
  });
6145
6249
  }
6146
6250
 
6251
+ export function useHelmSourceStatus(namespace: string, releaseName: string, enabled = true) {
6252
+ return useQuery<ChartSourceStatus>({
6253
+ queryKey: ["helm-source-status", namespace, releaseName],
6254
+ queryFn: () => fetchJSON(`/helm/releases/${encodeURIComponent(namespace)}/${encodeURIComponent(releaseName)}/source`),
6255
+ enabled: enabled && Boolean(namespace && releaseName),
6256
+ });
6257
+ }
6258
+
6259
+ async function mutateHelmSource(namespace: string, releaseName: string, source: ChartSourceCandidate): Promise<void> {
6260
+ const response = await apiFetch(apiUrl(`/helm/releases/${encodeURIComponent(namespace)}/${encodeURIComponent(releaseName)}/source`), {
6261
+ method: "PUT",
6262
+ headers: { "Content-Type": "application/json" },
6263
+ body: JSON.stringify(source),
6264
+ });
6265
+ if (!response.ok) {
6266
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
6267
+ throw new Error(error.error || `HTTP ${response.status}`);
6268
+ }
6269
+ }
6270
+
6271
+ export function useSetHelmSource(namespace: string, releaseName: string) {
6272
+ const queryClient = useQueryClient();
6273
+ return useMutation({
6274
+ mutationFn: (source: ChartSourceCandidate) => mutateHelmSource(namespace, releaseName, source),
6275
+ meta: { errorMessage: "Failed to associate chart source", successMessage: "Chart source associated" },
6276
+ onSuccess: () => {
6277
+ queryClient.invalidateQueries({ queryKey: ["helm-source-status", namespace, releaseName] });
6278
+ queryClient.invalidateQueries({ queryKey: ["helm-upgrade-info"] });
6279
+ queryClient.invalidateQueries({ queryKey: ["helm-release-versions"] });
6280
+ queryClient.invalidateQueries({ queryKey: ["helm-batch-upgrade-info"] });
6281
+ },
6282
+ });
6283
+ }
6284
+
6285
+ interface AddHelmRepositoryRequest {
6286
+ name: string
6287
+ url: string
6288
+ namespace?: string
6289
+ releaseName?: string
6290
+ }
6291
+
6292
+ export function useAddHelmRepository() {
6293
+ const queryClient = useQueryClient();
6294
+ return useMutation({
6295
+ mutationFn: async (request: AddHelmRepositoryRequest): Promise<{ status: string; name: string }> => {
6296
+ const response = await apiFetch(apiUrl('/helm/repositories'), {
6297
+ method: "POST",
6298
+ headers: { "Content-Type": "application/json" },
6299
+ body: JSON.stringify(request),
6300
+ });
6301
+ if (!response.ok) {
6302
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
6303
+ throw new Error(error.error || `HTTP ${response.status}`);
6304
+ }
6305
+ return response.json();
6306
+ },
6307
+ meta: { errorMessage: "Failed to add Helm repository", successMessage: "Helm repository added" },
6308
+ // Also on error: the repository can be saved before associating it fails.
6309
+ onSettled: () => {
6310
+ queryClient.invalidateQueries({ queryKey: ["helm-repositories"] });
6311
+ queryClient.invalidateQueries({ queryKey: ["helm-source-status"] });
6312
+ queryClient.invalidateQueries({ queryKey: ["helm-upgrade-info"] });
6313
+ queryClient.invalidateQueries({ queryKey: ["helm-release-versions"] });
6314
+ queryClient.invalidateQueries({ queryKey: ["helm-batch-upgrade-info"] });
6315
+ },
6316
+ });
6317
+ }
6318
+
6147
6319
  async function mutateOCISource(
6148
6320
  method: "POST" | "DELETE",
6149
6321
  source: string,
6150
6322
  ): Promise<string[]> {
6151
- const response = await apiFetch(`${getApiBase()}/helm/oci-sources`, {
6323
+ const response = await apiFetch(apiUrl('/helm/oci-sources'), {
6152
6324
  method,
6153
6325
  headers: { "Content-Type": "application/json" },
6154
6326
  body: JSON.stringify({ source }),
@@ -6160,14 +6332,18 @@ async function mutateOCISource(
6160
6332
  return response.json();
6161
6333
  }
6162
6334
 
6163
- // Invalidate the upgrade-info queries so a newly-registered source is probed
6164
- // immediately and "source not tracked" re-resolves.
6165
- function invalidateHelmAfterSourceChange(
6335
+ // Invalidate source inventory/status and upgrade info so an open source dialog
6336
+ // and upgrade checks immediately reflect source additions or removals.
6337
+ export function invalidateHelmAfterSourceChange(
6166
6338
  queryClient: ReturnType<typeof useQueryClient>,
6167
6339
  ) {
6168
- queryClient.invalidateQueries({ queryKey: ["helm-oci-sources"] });
6169
- queryClient.invalidateQueries({ queryKey: ["helm-upgrade-info"] });
6170
- queryClient.invalidateQueries({ queryKey: ["helm-batch-upgrade-info"] });
6340
+ return Promise.all([
6341
+ queryClient.invalidateQueries({ queryKey: ["helm-oci-sources"] }),
6342
+ queryClient.invalidateQueries({ queryKey: ["helm-source-status"] }),
6343
+ queryClient.invalidateQueries({ queryKey: ["helm-upgrade-info"] }),
6344
+ queryClient.invalidateQueries({ queryKey: ["helm-release-versions"] }),
6345
+ queryClient.invalidateQueries({ queryKey: ["helm-batch-upgrade-info"] }),
6346
+ ])
6171
6347
  }
6172
6348
 
6173
6349
  export function useAddOCISource() {
@@ -6520,13 +6696,16 @@ export const useFluxResume = createGitOpsMutation<FluxResourceVars>({
6520
6696
  export const useFluxSyncWithSource = createGitOpsMutation<FluxResourceVars>({
6521
6697
  getPath: (v) => `/flux/${v.kind}/${v.namespace}/${v.name}/sync-with-source`,
6522
6698
  errorMessage: "Failed to sync with source",
6523
- successMessage: "Sync with source triggered",
6699
+ // The server names what it reconciled, and says when a source was skipped.
6700
+ getSuccessMessage: (data) => data.message,
6524
6701
  getInvalidateKeys: (v) => [
6525
6702
  ...fluxInvalidateKeys(v),
6526
6703
  // Also invalidate source resources as they were reconciled too
6527
6704
  ["resources", "gitrepositories"],
6528
6705
  ["resources", "ocirepositories"],
6529
6706
  ["resources", "helmrepositories"],
6707
+ ["resources", "helmcharts"],
6708
+ ["resources", "buckets"],
6530
6709
  ],
6531
6710
  });
6532
6711
 
@@ -6782,8 +6961,35 @@ export function useNamespaceScope() {
6782
6961
  });
6783
6962
  }
6784
6963
 
6785
- const NAMESPACE_SWITCH_TIMEOUT = 5000;
6964
+ const NAMESPACE_SWITCH_TIMEOUT = 15000;
6786
6965
  const NAMESPACE_RESCOPE_TIMEOUT = 120000;
6966
+ const NAMESPACE_RECONCILE_TIMEOUT = 5000;
6967
+
6968
+ // After a switch times out on the client the server may still have applied
6969
+ // it. Returns the server's scope when it holds exactly the requested pick, so
6970
+ // a slow success is not reported as a failure.
6971
+ export async function reconcileNamespaceSwitch(
6972
+ namespaces: string[],
6973
+ ): Promise<NamespaceScope | null> {
6974
+ const controller = new AbortController();
6975
+ const timeoutId = setTimeout(
6976
+ () => controller.abort(),
6977
+ NAMESPACE_RECONCILE_TIMEOUT,
6978
+ );
6979
+ try {
6980
+ const scope = await fetchJSON<NamespaceScope>(
6981
+ "/cluster/namespace-scope",
6982
+ controller.signal,
6983
+ );
6984
+ const want = [...new Set(namespaces)].sort().join(",");
6985
+ const got = [...new Set(scope.actives)].sort().join(",");
6986
+ return want === got ? scope : null;
6987
+ } catch {
6988
+ return null;
6989
+ } finally {
6990
+ clearTimeout(timeoutId);
6991
+ }
6992
+ }
6787
6993
 
6788
6994
  export function debugNamespaceLog(
6789
6995
  label: string,
@@ -6799,6 +7005,20 @@ export function debugNamespaceLog(
6799
7005
  });
6800
7006
  }
6801
7007
 
7008
+ /**
7009
+ * A cache-scoped switch rebuilds the watched inventory. View-filter switches
7010
+ * use namespace-specific query keys and do not invalidate the whole client.
7011
+ */
7012
+ export async function refreshAfterNamespaceSwitch(
7013
+ queryClient: QueryClient,
7014
+ scope: Pick<NamespaceScope, "cacheScoped" | "actives">,
7015
+ ): Promise<void> {
7016
+ if (!scope.cacheScoped) return;
7017
+ const notScope = (query: { queryKey: readonly unknown[] }) =>
7018
+ query.queryKey[0] !== "namespace-scope";
7019
+ await queryClient.invalidateQueries({ predicate: notScope }, { cancelRefetch: false });
7020
+ }
7021
+
6802
7022
  export function useSetActiveNamespace() {
6803
7023
  const queryClient = useQueryClient();
6804
7024
  return useMutation<NamespaceScope, Error, { namespaces: string[] }>({
@@ -6820,7 +7040,7 @@ export function useSetActiveNamespace() {
6820
7040
  // flag). If the scope query is missing/stale we can't yet tell a cheap
6821
7041
  // view-filter change from a cache-rebuilding rescope, so bias to the long
6822
7042
  // timeout — only a confirmed non-scoped session gets the fast switch timeout.
6823
- // Aborting a real rebuild at 5s surfaces a spurious failure while the server
7043
+ // Aborting a real rebuild at the switch timeout surfaces a spurious failure while the server
6824
7044
  // keeps going.
6825
7045
  const isRescope = currentScope?.cacheScoped !== false;
6826
7046
  const timeoutMs = isRescope
@@ -6854,6 +7074,13 @@ export function useSetActiveNamespace() {
6854
7074
  error: error instanceof Error ? error.message : String(error),
6855
7075
  });
6856
7076
  if (error instanceof Error && error.name === "AbortError") {
7077
+ // A rescope publishes its target before the rebuild finishes and can
7078
+ // still roll back, so only a view-filter pick is proven by the scope.
7079
+ const applied = isRescope ? null : await reconcileNamespaceSwitch(namespaces);
7080
+ if (applied) {
7081
+ debugNamespaceLog("mutation:timeout-but-applied", { namespaces });
7082
+ return applied;
7083
+ }
6857
7084
  throw new Error(
6858
7085
  isRescope
6859
7086
  ? "Namespace rescope timed out. The cluster may still be loading."
@@ -6876,9 +7103,7 @@ export function useSetActiveNamespace() {
6876
7103
  });
6877
7104
  }
6878
7105
  queryClient.setQueryData<NamespaceScope>(["namespace-scope"], scope);
6879
- if (scope.cacheScoped) {
6880
- queryClient.invalidateQueries();
6881
- }
7106
+ void refreshAfterNamespaceSwitch(queryClient, scope);
6882
7107
  debugNamespaceLog("mutation:success-after-scope-cache-write");
6883
7108
  },
6884
7109
  onError: () => {
@@ -7322,6 +7547,8 @@ export interface DiagnosticsSnapshot {
7322
7547
  };
7323
7548
  timeline?: {
7324
7549
  storageType: string;
7550
+ degraded?: boolean;
7551
+ degradedReason?: string;
7325
7552
  totalEvents: number;
7326
7553
  oldestEvent?: string;
7327
7554
  newestEvent?: string;
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CNPGFleetRow } from '@skyhook-io/k8s-ui'
3
+ import type { CNPGRuntimeResponse } from './cnpg'
4
+ import { cnpgInstanceLive, cnpgLiveStandbyGaps, cnpgReplicationLive, withLiveReplication } from '../components/cnpg/runtimeAssessment'
5
+
6
+ const rt = (status: any): CNPGRuntimeResponse => ({
7
+ cluster: { namespace: 'db', name: 'pg', uid: 'u' },
8
+ sampledAt: '2026-09-30T10:00:00Z',
9
+ permission: { proxy: 'allowed' },
10
+ instances: [
11
+ { pod: 'pg-1', role: 'primary', status, metrics: { state: 'ok' } },
12
+ { pod: 'pg-2', role: 'replica', status: { state: 'ok', replication: [] }, metrics: { state: 'ok' } },
13
+ ],
14
+ })
15
+
16
+ describe('live replication from an incomplete primary report', () => {
17
+ it('is unknown, never "0 streaming"', () => {
18
+ expect(cnpgReplicationLive(rt({ state: 'partial', incomplete: true, replication: null }))).toBeUndefined()
19
+ expect(cnpgReplicationLive(rt({ state: 'ok', replication: [] }))?.streaming).toBe(0)
20
+ })
21
+ it('carries the incomplete flag to the instance list', () => {
22
+ expect(cnpgInstanceLive(rt({ state: 'partial', incomplete: true }))?.[0].incomplete).toBe(true)
23
+ })
24
+ })
25
+
26
+ describe('standbys and slots from the live read', () => {
27
+ const row = (over: Partial<CNPGFleetRow> = {}): CNPGFleetRow =>
28
+ ({
29
+ key: 'db/pg',
30
+ namespace: 'db',
31
+ name: 'pg',
32
+ cluster: { metadata: { annotations: {} }, status: { currentPrimary: 'pg-6' } },
33
+ instances: { ready: 3, desired: 3 },
34
+ pods: [],
35
+ replication: { text: '2/2 Pods ready · lag unknown', tone: 'unknown' },
36
+ problems: [],
37
+ attention: false,
38
+ categories: new Set(),
39
+ hibernated: false,
40
+ ...over,
41
+ }) as unknown as CNPGFleetRow
42
+ const live = (standby1: any): CNPGRuntimeResponse => ({
43
+ cluster: { namespace: 'db', name: 'pg', uid: 'u' },
44
+ sampledAt: '2026-10-04T10:00:00Z',
45
+ permission: { proxy: 'allowed' },
46
+ instances: [
47
+ {
48
+ pod: 'pg-6',
49
+ role: 'primary',
50
+ status: {
51
+ state: 'ok',
52
+ timeline: 7,
53
+ currentLsn: '3/BC06F9E8',
54
+ replication: [{ applicationName: 'pg-2', state: 'streaming', replayLsn: '3/BC06F9E8' }],
55
+ slots: [
56
+ { name: '_cnpg_pg_1', type: 'physical', active: false, retainedBytes: 4.7e9 },
57
+ { name: '_cnpg_pg_2', type: 'physical', active: true, retainedBytes: 0 },
58
+ ],
59
+ },
60
+ metrics: { state: 'ok' },
61
+ },
62
+ { pod: 'pg-1', role: 'replica', status: standby1, metrics: { state: 'ok' } },
63
+ { pod: 'pg-2', role: 'replica', status: { state: 'ok', timeline: 7, roleDetail: 'streaming' }, metrics: { state: 'ok' } },
64
+ ],
65
+ })
66
+
67
+ it('a standby with no row on the primary is a gap, with what it reports about itself', () => {
68
+ const gaps = cnpgLiveStandbyGaps(row(), live({ state: 'ok', timeline: 4, replayPaused: true, roleDetail: 'replayPaused' }))
69
+ expect(gaps.map((g) => g.pod)).toEqual(['pg-1'])
70
+ expect(gaps[0].evidence).toEqual([
71
+ 'it has no row in the primary’s pg_stat_replication',
72
+ 'its WAL replay is paused',
73
+ 'it is on timeline 4 while the primary is on 7',
74
+ ])
75
+ })
76
+ it('a fenced standby, or one running pg_rewind, is not a gap', () => {
77
+ const fenced = row({ cluster: { metadata: { annotations: { 'cnpg.io/fencedInstances': '["pg-1"]' } }, status: {} } } as any)
78
+ expect(cnpgLiveStandbyGaps(fenced, live({ state: 'ok', timeline: 7 }))).toEqual([])
79
+ expect(cnpgLiveStandbyGaps(row(), live({ state: 'ok', timeline: 7, roleDetail: 'pgRewind' }))).toEqual([])
80
+ })
81
+ it('raises the gap and the inactive slot as problems, and names the standby in the replication fact', () => {
82
+ const r = withLiveReplication(row(), live({ state: 'ok', timeline: 4, replayPaused: true }))
83
+ expect(r.replication.text).toBe('1 of 2 expected standbys streaming · pg-1 not connected')
84
+ expect(r.attention).toBe(true)
85
+ expect(r.problems.map((p) => p.id).sort()).toEqual(['slot:db/pg:_cnpg_pg_1', 'standby:db/pg:pg-1'])
86
+ expect(r.problems.find((p) => p.id === 'slot:db/pg:_cnpg_pg_1')!.title).toBe('Inactive slot _cnpg_pg_1 holds 4.4 GiB of WAL on pg-6 for pg-1')
87
+ })
88
+ })
89
+
90
+ describe('a live read replaces what it disproves', () => {
91
+ const base = (problems: any[]): CNPGFleetRow =>
92
+ ({
93
+ key: 'db/pg',
94
+ namespace: 'db',
95
+ name: 'pg',
96
+ cluster: { metadata: {}, status: {} },
97
+ instances: { ready: 2, desired: 2 },
98
+ pods: [],
99
+ replication: { text: '2/2 Pods ready · pg-2 not receiving WAL', tone: 'degraded' },
100
+ problems,
101
+ attention: true,
102
+ categories: new Set(['availability']),
103
+ hibernated: false,
104
+ }) as unknown as CNPGFleetRow
105
+ const fleetStandby = { id: 'standby:db/pg:pg-2', reason: 'CNPGStandbyNotReceiving', severity: 'warning', category: 'availability', title: 'pg-2 is not receiving WAL from the primary', subject: { kind: 'Pod', group: '', namespace: 'db', name: 'pg-2' }, source: 'measurement' }
106
+ const fleetSlot = { id: 'slot:db/pg:_cnpg_pg_2', reason: 'CNPGInactiveSlot', slot: '_cnpg_pg_2', severity: 'warning', category: 'availability', title: 'Inactive slot _cnpg_pg_2 holds 2 GiB', subject: { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg' }, source: 'measurement' }
107
+ const rt = (primaryStatus: any): CNPGRuntimeResponse => ({
108
+ cluster: { namespace: 'db', name: 'pg', uid: 'u' },
109
+ sampledAt: '2026-10-04T10:00:00Z',
110
+ permission: { proxy: 'allowed' },
111
+ instances: [
112
+ { pod: 'pg-1', role: 'primary', status: primaryStatus, metrics: { state: 'ok' } },
113
+ { pod: 'pg-2', role: 'replica', status: { state: 'ok', timeline: 1 }, metrics: { state: 'ok' } },
114
+ ],
115
+ })
116
+ it('clears the fleet’s standby and slot problems when the primary streams to it and the slot is active', () => {
117
+ const r = withLiveReplication(
118
+ base([fleetStandby, fleetSlot]),
119
+ rt({ state: 'ok', timeline: 1, replication: [{ applicationName: 'pg-2', state: 'streaming' }], slots: [{ name: '_cnpg_pg_2', type: 'physical', active: true, retainedBytes: 0 }] }),
120
+ )
121
+ expect(r.problems).toEqual([])
122
+ expect(r.attention).toBe(false)
123
+ expect(r.replication.text).toBe('1/1 streaming')
124
+ })
125
+ it('keeps a fleet slot warning the live read cannot disprove', () => {
126
+ // Unmeasured: inactive, retained bytes not reported.
127
+ const unmeasured = withLiveReplication(base([fleetSlot]), rt({ state: 'ok', timeline: 1, replication: [{ applicationName: 'pg-2', state: 'streaming' }], slots: [{ name: '_cnpg_pg_2', type: 'physical', active: false }] }))
128
+ expect(unmeasured.problems.map((p) => p.id)).toEqual(['slot:db/pg:_cnpg_pg_2'])
129
+ // Absent from a capped, partial list.
130
+ const partial = withLiveReplication(base([fleetSlot]), rt({ state: 'partial', replication: null, slots: [] }))
131
+ expect(partial.problems.map((p) => p.id)).toEqual(['slot:db/pg:_cnpg_pg_2'])
132
+ // Absent from a complete list: the slot is gone.
133
+ const gone = withLiveReplication(base([fleetSlot]), rt({ state: 'ok', timeline: 1, replication: [{ applicationName: 'pg-2', state: 'streaming' }], slots: [] }))
134
+ expect(gone.problems).toEqual([])
135
+ })
136
+ it('judges slots from a partial primary report the replication rows cannot use', () => {
137
+ const r = withLiveReplication(base([]), rt({ state: 'partial', incomplete: true, replication: null, slots: [{ name: '_cnpg_pg_2', type: 'physical', active: false, retainedBytes: 3 * 1024 ** 3 }] }))
138
+ expect(r.problems.map((p) => p.id)).toEqual(['slot:db/pg:_cnpg_pg_2'])
139
+ })
140
+ })