@skyhook-io/radar-app 1.16.3 → 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 (321) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +138 -93
  4. package/src/RadarApp.tsx +27 -17
  5. package/src/api/actions.errorCode.test.ts +40 -0
  6. package/src/api/actions.test.ts +48 -0
  7. package/src/api/actions.ts +79 -0
  8. package/src/api/apiResources.navigation.test.tsx +232 -0
  9. package/src/api/apiResources.ts +46 -3
  10. package/src/api/client.goneResource.test.ts +49 -0
  11. package/src/api/client.helmSource.test.ts +39 -0
  12. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  13. package/src/api/client.namespaceSwitch.test.ts +107 -0
  14. package/src/api/client.ts +381 -47
  15. package/src/api/cnpg-ha.live.test.ts +140 -0
  16. package/src/api/cnpg-ha.test.ts +78 -0
  17. package/src/api/cnpg-ha.ts +19 -0
  18. package/src/api/cnpg-history.ts +123 -0
  19. package/src/api/cnpg-inspect.ts +102 -0
  20. package/src/api/cnpg-protection.ts +112 -0
  21. package/src/api/cnpg-recovery.test.tsx +60 -0
  22. package/src/api/cnpg-recovery.ts +262 -0
  23. package/src/api/cnpg-sessions.ts +175 -0
  24. package/src/api/cnpg-storage.ts +138 -0
  25. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  26. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  27. package/src/api/cnpg.ts +428 -10
  28. package/src/api/diagnose.test.ts +40 -0
  29. package/src/api/diagnose.ts +12 -1
  30. package/src/api/radarFeatures.render.test.tsx +65 -3
  31. package/src/api/radarFeatures.ts +6 -0
  32. package/src/api/reviewedActionMutation.test.tsx +164 -0
  33. package/src/api/reviewedActionMutation.ts +66 -0
  34. package/src/api/timelineSource.test.ts +9 -0
  35. package/src/api/timelineSource.ts +8 -0
  36. package/src/api/traffic.sources.test.ts +55 -0
  37. package/src/api/traffic.ts +173 -14
  38. package/src/components/applications/ApplicationsView.tsx +3 -0
  39. package/src/components/audit/AuditView.tsx +9 -0
  40. package/src/components/capacity/CapacityActivity.tsx +3 -31
  41. package/src/components/capacity/CapacityDemand.tsx +4 -36
  42. package/src/components/capacity/CapacityOverview.tsx +217 -56
  43. package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
  44. package/src/components/capacity/CapacityView.test.tsx +354 -1
  45. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  46. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  47. package/src/components/capacity/routes.ts +42 -0
  48. package/src/components/capacity/shared.tsx +23 -150
  49. package/src/components/capacity/workspaceRoute.ts +18 -0
  50. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  51. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  52. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  53. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  54. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  55. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  56. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  57. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  58. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  59. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  60. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  61. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  62. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  63. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  64. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  65. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  66. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  67. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  68. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  69. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  70. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  71. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  72. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  73. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  74. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  75. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  76. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  77. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  78. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  79. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  80. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  81. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  82. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  83. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  84. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  85. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  86. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  87. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  88. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  89. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  90. package/src/components/cnpg/CNPGProtection.tsx +230 -124
  91. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  92. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  93. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  94. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  95. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  96. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  97. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  98. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  99. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  100. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  101. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  102. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  103. package/src/components/cnpg/CNPGView.tsx +34 -21
  104. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  105. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  106. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  107. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  108. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  109. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  110. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  111. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  112. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  113. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  114. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  115. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  116. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  117. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  118. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  119. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  120. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  121. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  122. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  123. package/src/components/cnpg/actions/actionModel.ts +127 -0
  124. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  125. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  126. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  127. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  128. package/src/components/cnpg/archivingRepair.ts +145 -0
  129. package/src/components/cnpg/baseBackup.test.ts +36 -0
  130. package/src/components/cnpg/baseBackup.ts +36 -0
  131. package/src/components/cnpg/blocking.test.ts +59 -0
  132. package/src/components/cnpg/blocking.ts +131 -0
  133. package/src/components/cnpg/coverageText.test.ts +27 -0
  134. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  135. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  136. package/src/components/cnpg/creation/targetModel.ts +149 -0
  137. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  138. package/src/components/cnpg/fleetStatus.ts +43 -0
  139. package/src/components/cnpg/formFields.ts +2 -0
  140. package/src/components/cnpg/grantText.test.tsx +11 -0
  141. package/src/components/cnpg/host.test.tsx +24 -0
  142. package/src/components/cnpg/host.tsx +101 -0
  143. package/src/components/cnpg/inspectModel.test.ts +120 -0
  144. package/src/components/cnpg/inspectModel.ts +140 -0
  145. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  146. package/src/components/cnpg/logicalSlots.ts +29 -0
  147. package/src/components/cnpg/lsn.test.ts +40 -0
  148. package/src/components/cnpg/lsn.ts +43 -0
  149. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  150. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  151. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  152. package/src/components/cnpg/operations/model.test.ts +510 -0
  153. package/src/components/cnpg/operations/model.ts +499 -0
  154. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  155. package/src/components/cnpg/operations/presentation.ts +84 -0
  156. package/src/components/cnpg/operations/store.ts +98 -0
  157. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  158. package/src/components/cnpg/operatorStatus.ts +194 -0
  159. package/src/components/cnpg/paths.test.ts +54 -0
  160. package/src/components/cnpg/paths.ts +47 -4
  161. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  162. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  163. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  164. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  165. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  166. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  167. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  168. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  169. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  172. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  173. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  174. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  175. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  176. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  177. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  178. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  179. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  180. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  181. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  182. package/src/components/cnpg/refresh.test.ts +38 -0
  183. package/src/components/cnpg/refresh.ts +17 -0
  184. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  185. package/src/components/cnpg/routes.test.ts +34 -3
  186. package/src/components/cnpg/routes.ts +60 -48
  187. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  188. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  189. package/src/components/cnpg/runtimeModel.ts +196 -0
  190. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  191. package/src/components/cnpg/runtimeParts.tsx +79 -0
  192. package/src/components/cnpg/shared.test.tsx +59 -0
  193. package/src/components/cnpg/shared.tsx +100 -182
  194. package/src/components/cnpg/storageModel.test.ts +107 -0
  195. package/src/components/cnpg/storageModel.ts +101 -0
  196. package/src/components/cnpg/trendSamples.test.ts +148 -0
  197. package/src/components/cnpg/trendSamples.ts +222 -0
  198. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  199. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  200. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  201. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  202. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  203. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  204. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  205. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  206. package/src/components/cnpg/workspaceRoute.ts +23 -0
  207. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  208. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  209. package/src/components/cost/CostView.test.ts +2 -1
  210. package/src/components/cost/CostView.tsx +0 -2
  211. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  212. package/src/components/cost/errors.ts +3 -1
  213. package/src/components/cost/source.test.ts +10 -0
  214. package/src/components/cost/source.ts +5 -1
  215. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  216. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  217. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  218. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  219. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  220. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  221. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  222. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  223. package/src/components/diagnose/InvestigationView.tsx +39 -2
  224. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  225. package/src/components/diagnose/agentCatalog.ts +2 -2
  226. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  227. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  228. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  229. package/src/components/dock/BottomDock.tsx +2 -0
  230. package/src/components/dock/TerminalTab.test.tsx +17 -0
  231. package/src/components/dock/TerminalTab.tsx +7 -3
  232. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  233. package/src/components/execution/BatchExecutionView.tsx +11 -11
  234. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  235. package/src/components/gitops/GitOpsView.tsx +16 -23
  236. package/src/components/helm/ChartBrowser.tsx +3 -3
  237. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  238. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  239. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  240. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  241. package/src/components/helm/RevisionHistory.tsx +3 -2
  242. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  243. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  244. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  245. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  246. package/src/components/helm/ValuesViewer.tsx +169 -78
  247. package/src/components/home/ActivitySummary.tsx +2 -1
  248. package/src/components/home/ClusterHealthCard.tsx +7 -0
  249. package/src/components/home/HomeView.tsx +20 -9
  250. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  251. package/src/components/home/TopologyPreview.tsx +2 -2
  252. package/src/components/home/mcpToolCatalog.ts +1 -1
  253. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  254. package/src/components/issues/IssuesPane.test.ts +65 -1
  255. package/src/components/issues/IssuesPane.tsx +188 -25
  256. package/src/components/issues/uncheckedSources.ts +21 -0
  257. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  258. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  259. package/src/components/resource/PVCUsageBar.tsx +15 -6
  260. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  261. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  262. package/src/components/resources/CompositeRenderer.tsx +4 -4
  263. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  264. package/src/components/resources/ResourcesView.tsx +26 -12
  265. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  266. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  267. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  268. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  269. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  270. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  271. package/src/components/settings/SettingsDialog.tsx +150 -34
  272. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  273. package/src/components/timeline/TimelineList.tsx +25 -24
  274. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  275. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  276. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  277. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  278. package/src/components/timeline/TimelineView.tsx +25 -45
  279. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  280. package/src/components/timeline/useStickyError.test.tsx +47 -0
  281. package/src/components/timeline/useStickyError.ts +15 -0
  282. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  283. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  284. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  285. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  286. package/src/components/traffic/TrafficGraph.tsx +109 -26
  287. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  288. package/src/components/traffic/TrafficView.tsx +582 -149
  289. package/src/components/traffic/trafficFilters.test.ts +398 -1
  290. package/src/components/traffic/trafficFilters.ts +408 -0
  291. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  292. package/src/components/ui/Markdown.test.tsx +65 -0
  293. package/src/components/ui/Markdown.tsx +144 -127
  294. package/src/components/whats-new/releaseNotes.ts +63 -1
  295. package/src/components/workload/WorkloadView.test.ts +11 -1
  296. package/src/components/workload/WorkloadView.tsx +162 -529
  297. package/src/components/workspace/layout.test.tsx +29 -0
  298. package/src/components/workspace/layout.tsx +259 -0
  299. package/src/components/workspace/table.ts +7 -0
  300. package/src/context/TimelineCustomization.tsx +15 -0
  301. package/src/contexts/CapabilitiesContext.tsx +1 -1
  302. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  303. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  304. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  305. package/src/integrations/resourceHost.test.tsx +55 -0
  306. package/src/integrations/resourceHost.ts +99 -0
  307. package/src/integrations/resourceHosts.test.tsx +141 -0
  308. package/src/integrations/resourceHosts.tsx +119 -0
  309. package/src/integrations/workspaceRoute.ts +20 -0
  310. package/src/integrations/workspaceRoutes.test.ts +40 -0
  311. package/src/integrations/workspaceRoutes.ts +22 -0
  312. package/src/integrations/workspaceScreens.tsx +14 -0
  313. package/src/utils/drawer-trail.ts +35 -0
  314. package/src/utils/kindFilterStore.test.ts +147 -0
  315. package/src/utils/kindFilterStore.ts +91 -0
  316. package/src/utils/navigation.test.ts +13 -1
  317. package/src/utils/navigation.ts +39 -2
  318. package/src/utils/page-links.ts +21 -0
  319. package/src/utils/tabStorage.ts +40 -0
  320. package/src/utils/viewMemory.test.ts +78 -0
  321. package/src/utils/viewMemory.ts +54 -0
@@ -28,6 +28,7 @@ export interface RadarFeatureSpec {
28
28
 
29
29
  export const RADAR_FEATURES = {
30
30
  resourceIssues: { label: 'Operational issues', minimumVersion: 'v1.8.0', flag: 'resourceIssues' },
31
+ resourceIssueCoverage: { label: 'Resource-specific issue views', flag: 'resourceIssueCoverage', flagShippedWithEndpoint: true },
31
32
  podEnvironment: { label: 'Values from ConfigMaps and Secrets', minimumVersion: 'v1.9.0', flag: 'podEnvironment' },
32
33
  policyResource: { label: 'Policy results', minimumVersion: 'v1.10.0', flag: 'policyResource' },
33
34
  capacity: { label: 'Capacity views', minimumVersion: 'v1.9.0' },
@@ -35,6 +36,11 @@ export const RADAR_FEATURES = {
35
36
  drainPlan: { label: 'Drain plans', minimumVersion: 'v1.14.0' },
36
37
  applications: { label: 'Applications', minimumVersion: 'v1.7.7' },
37
38
  workloadHistory: { label: 'Workload history', flag: 'workloadHistory', flagShippedWithEndpoint: true },
39
+ cnpgWorkspace: { label: 'CloudNativePG views', flag: 'cnpgWorkspace', flagShippedWithEndpoint: true },
40
+ cnpgProtectionSetup: { label: 'CloudNativePG backup setup', flag: 'cnpgProtectionSetup', flagShippedWithEndpoint: true },
41
+ gitopsWriteEvidence: { label: 'GitOps revert warnings', flag: 'gitopsWriteEvidence', flagShippedWithEndpoint: true },
42
+ serviceEndpointSlices: { label: 'Service EndpointSlice inventories', flag: 'serviceEndpointSlices', flagShippedWithEndpoint: true },
43
+ trafficRecords: { label: 'Flow records for a graph selection', flag: 'trafficRecords', flagShippedWithEndpoint: true },
38
44
  } as const satisfies Record<string, RadarFeatureSpec>
39
45
 
40
46
  export type RadarFeature = keyof typeof RADAR_FEATURES
@@ -0,0 +1,164 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+ import { useReviewedActionMutation } from './reviewedActionMutation'
7
+ import { ApiError } from './client'
8
+ import { actionErrorCode, actionCompleted } from './actions'
9
+ import { RadarFeatureUnsupportedError } from './radarFeatures'
10
+
11
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
12
+
13
+ describe('reviewed action lifetime', () => {
14
+ it.each([
15
+ new TypeError('Connection lost'),
16
+ new ApiError('One write completed', 409, { code: 'partial', completed: ['Pod removed'] }),
17
+ new ApiError('upstream connect error or disconnect/reset before headers', 503),
18
+ ])('retains an ambiguous result through remount and prevents another request: %s', async (failure) => {
19
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
20
+ const host = document.body.appendChild(document.createElement('div'))
21
+ const root = createRoot(host)
22
+ const request = vi.fn(async () => { throw failure })
23
+ let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
24
+ function Dialog({ target }: { target: string }) {
25
+ current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>(target, 'ctx', { mutationFn: request })
26
+ return <div>{current.outcomeError?.message}</div>
27
+ }
28
+ const mount = (target?: string) => act(() => root.render(<QueryClientProvider client={client}>{target && <Dialog target={target} />}</QueryClientProvider>))
29
+ const attempt = () => act(async () => {
30
+ await current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {})
31
+ await new Promise(resolve => setTimeout(resolve, 0))
32
+ })
33
+ try {
34
+ mount('/api/cnpg/clusters/jobs/orders')
35
+ await attempt()
36
+ mount()
37
+ client.removeQueries()
38
+ client.clear()
39
+ mount('/api/cnpg/clusters/jobs/orders')
40
+ expect(current!.outcomeError?.message).toContain('reload this page')
41
+ expect(actionErrorCode(current!.outcomeError)).toBe(actionErrorCode(failure))
42
+ expect(actionCompleted(current!.outcomeError)).toEqual(actionCompleted(failure))
43
+ await attempt()
44
+ expect(request).toHaveBeenCalledTimes(1)
45
+ mount('/other-cluster/api/cnpg/clusters/jobs/orders')
46
+ expect(current!.outcomeError).toBeNull()
47
+ mount('/api/cnpg/scheduledbackups/jobs/orders')
48
+ expect(current!.outcomeError).toBeNull()
49
+ } finally {
50
+ act(() => root.unmount())
51
+ client.clear()
52
+ host.remove()
53
+ }
54
+ })
55
+ it.each([
56
+ { nextTarget: '/api/target', nextContext: 'other-context' },
57
+ { nextTarget: '/other-cluster/api/target', nextContext: 'ctx' },
58
+ ])('keeps a late result on the original cluster binding: %s', async ({ nextTarget, nextContext }) => {
59
+ const client = new QueryClient()
60
+ const host = document.body.appendChild(document.createElement('div'))
61
+ const root = createRoot(host)
62
+ let fail!: (error: Error) => void
63
+ let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
64
+ function Dialog({ target, context }: { target: string; context: string }) {
65
+ current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>(target, context, {
66
+ mutationFn: () => new Promise<null>((_, reject) => { fail = reject }),
67
+ })
68
+ return null
69
+ }
70
+ const mount = (target: string, context: string) => act(() => root.render(<QueryClientProvider client={client}><Dialog target={target} context={context} /></QueryClientProvider>))
71
+ try {
72
+ mount('/api/target', 'ctx')
73
+ let attempted!: Promise<unknown>
74
+ await act(async () => {
75
+ attempted = current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {})
76
+ await Promise.resolve()
77
+ })
78
+ mount(nextTarget, nextContext)
79
+ await act(async () => {
80
+ fail(new TypeError('Connection lost'))
81
+ await attempted
82
+ await new Promise(resolve => setTimeout(resolve, 0))
83
+ })
84
+ expect(current!.outcomeError).toBeNull()
85
+ mount('/api/target', 'ctx')
86
+ expect(current!.outcomeError?.message).toContain('Connection lost')
87
+ } finally {
88
+ act(() => root.unmount())
89
+ client.clear()
90
+ host.remove()
91
+ }
92
+ })
93
+ it.each([
94
+ new ApiError('Facts changed', 409, { code: 'changed' }),
95
+ new ApiError('The operator webhook did not answer', 503, { code: 'operator_webhook_unavailable' }),
96
+ new RadarFeatureUnsupportedError('cnpgWorkspace', {}),
97
+ ])('keeps a known refusal retryable after remount: %s', async (failure) => {
98
+ const client = new QueryClient()
99
+ const host = document.body.appendChild(document.createElement('div'))
100
+ const root = createRoot(host)
101
+ let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
102
+ const request = vi.fn(async () => { throw failure })
103
+ function Dialog() {
104
+ current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>('/api/target', 'ctx', { mutationFn: request })
105
+ return null
106
+ }
107
+ const render = (open: boolean) => act(() => root.render(<QueryClientProvider client={client}>{open && <Dialog />}</QueryClientProvider>))
108
+ try {
109
+ render(true)
110
+ await act(async () => { await current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {}) })
111
+ render(false)
112
+ render(true)
113
+ expect(current!.outcomeError).toBeNull()
114
+ await act(async () => { await current.mutateAsync({ request: { reviewedContext: 'ctx' } }).catch(() => {}) })
115
+ expect(request).toHaveBeenCalledTimes(2)
116
+ } finally {
117
+ act(() => root.unmount())
118
+ client.clear()
119
+ host.remove()
120
+ }
121
+ })
122
+
123
+ it('retains an in-flight request through remount and cache clearing without sending twice', async () => {
124
+ const client = new QueryClient()
125
+ const host = document.body.appendChild(document.createElement('div'))
126
+ const root = createRoot(host)
127
+ let complete!: (data: null) => void
128
+ const request = vi.fn(() => new Promise<null>(resolve => { complete = resolve }))
129
+ let current: ReturnType<typeof useReviewedActionMutation<null, { request: { reviewedContext: string } }>>
130
+ function Dialog() {
131
+ current = useReviewedActionMutation<null, { request: { reviewedContext: string } }>('/api/target', 'ctx', { mutationFn: request })
132
+ return null
133
+ }
134
+ const render = (open: boolean) => act(() => root.render(<QueryClientProvider client={client}>{open && <Dialog />}</QueryClientProvider>))
135
+ try {
136
+ render(true)
137
+ let attempted!: Promise<null>
138
+ await act(async () => {
139
+ attempted = current.mutateAsync({ request: { reviewedContext: 'ctx' } })
140
+ await Promise.resolve()
141
+ })
142
+ render(false)
143
+ client.removeQueries()
144
+ client.clear()
145
+ render(true)
146
+ expect(current!.isPending).toBe(true)
147
+ await act(async () => {
148
+ await expect(current.mutateAsync({ request: { reviewedContext: 'ctx' } })).rejects.toThrow('still in progress')
149
+ })
150
+ expect(request).toHaveBeenCalledTimes(1)
151
+ await act(async () => {
152
+ complete(null)
153
+ await attempted
154
+ await new Promise(resolve => setTimeout(resolve, 0))
155
+ })
156
+ expect(current!.isPending).toBe(false)
157
+ expect(current!.outcomeError).toBeNull()
158
+ } finally {
159
+ act(() => root.unmount())
160
+ client.clear()
161
+ host.remove()
162
+ }
163
+ })
164
+ })
@@ -0,0 +1,66 @@
1
+ import { useCallback, useSyncExternalStore } from 'react'
2
+ import { useMutation, useQueryClient, type MutationFunction, type QueryClient, type UseMutationOptions } from '@tanstack/react-query'
3
+ import { actionOutcomeLocked } from './actions'
4
+ import { ApiError } from './client'
5
+
6
+ interface Outcome {
7
+ pending: boolean
8
+ error: Error | null
9
+ }
10
+ const EMPTY: Outcome = { pending: false, error: null }
11
+ const stores = new WeakMap<QueryClient, { entries: Map<string, Outcome>; listeners: Set<() => void> }>()
12
+
13
+ /** Result memory belongs to the host, so dismissing a dialog cannot unlock a second write. */
14
+ export function useReviewedActionMutation<Data, Variables extends { request: { reviewedContext: string } }>(
15
+ target: string,
16
+ reviewedContext: string | undefined,
17
+ options: Omit<UseMutationOptions<Data, Error, Variables>, 'mutationFn' | 'mutationKey'> & { mutationFn: MutationFunction<Data, Variables> },
18
+ ) {
19
+ const client = useQueryClient()
20
+ let store = stores.get(client)
21
+ if (!store) {
22
+ store = { entries: new Map(), listeners: new Set() }
23
+ stores.set(client, store)
24
+ }
25
+ const outcomes = store
26
+ const keyFor = (context: string | undefined) => JSON.stringify([target, context])
27
+ const key = keyFor(reviewedContext)
28
+ const subscribe = useCallback((listener: () => void) => {
29
+ outcomes.listeners.add(listener)
30
+ return () => { outcomes.listeners.delete(listener) }
31
+ }, [outcomes])
32
+ const snapshot = useCallback(() => outcomes.entries.get(key) ?? EMPTY, [outcomes, key])
33
+ // Connection recovery purges query/mutation caches. It must not forget a
34
+ // request still in flight or a result that may already have changed the target.
35
+ const retained = useSyncExternalStore(subscribe, snapshot, () => EMPTY)
36
+ const publish = (key: string, value: Outcome) => {
37
+ if (value.pending || value.error) outcomes.entries.set(key, value)
38
+ else outcomes.entries.delete(key)
39
+ for (const listener of outcomes.listeners) listener()
40
+ }
41
+ const mutation = useMutation<Data, Error, Variables>({
42
+ ...options,
43
+ mutationKey: ['reviewed-action', target, reviewedContext],
44
+ mutationFn: async (variables, context) => {
45
+ const boundKey = keyFor(variables.request.reviewedContext)
46
+ const previous = outcomes.entries.get(boundKey)
47
+ if (previous?.error) throw previous.error
48
+ if (previous?.pending) throw new ApiError('A write to this target is still in progress. Wait for its result.', 409, { code: 'blocked' })
49
+ publish(boundKey, { pending: true, error: null })
50
+ let retainedError: Error | null = null
51
+ try {
52
+ return await options.mutationFn(variables, context)
53
+ } catch (error) {
54
+ if (actionOutcomeLocked(error)) {
55
+ const failure = error as Error
56
+ const message = `${failure.message} Inspect the current state, then reload this page and review the action again.`
57
+ retainedError = failure instanceof ApiError ? new ApiError(message, failure.status, failure.data) : new Error(message)
58
+ }
59
+ throw error
60
+ } finally {
61
+ publish(boundKey, { pending: false, error: retainedError })
62
+ }
63
+ },
64
+ })
65
+ return { ...mutation, isPending: mutation.isPending || retained.pending, outcomeError: retained.error }
66
+ }
@@ -19,6 +19,7 @@ import {
19
19
  applyClientFilters,
20
20
  localOverviewFromEvents,
21
21
  needsWindowFetch,
22
+ frozenWindowRefetchInterval,
22
23
  type RetainedRing,
23
24
  } from './timelineSource'
24
25
  import type { TimelineEvent } from '../types'
@@ -650,3 +651,11 @@ describe('retained ring fetch (the OSS-identical accumulate model)', () => {
650
651
  // cursor is intact on the cached ring — nothing was committed
651
652
  })
652
653
  })
654
+
655
+ describe('frozenWindowRefetchInterval', () => {
656
+ it('polls a failed window until it loads, and leaves a loaded one alone', () => {
657
+ expect(frozenWindowRefetchInterval('error')).toBe(10_000)
658
+ expect(frozenWindowRefetchInterval('success')).toBe(false)
659
+ expect(frozenWindowRefetchInterval('pending')).toBe(false)
660
+ })
661
+ })
@@ -544,6 +544,13 @@ export function needsWindowFetch(
544
544
  // refresh.
545
545
  const FROZEN_WINDOW_STALE_MS = 10 * 60 * 1000
546
546
 
547
+ // A frozen window that failed to load does poll, at the ring's cadence, until
548
+ // it loads: the error page tells the user it recovers on its own once the
549
+ // timeline's database is back, and without a poll only "Try again" would.
550
+ export function frozenWindowRefetchInterval(status: 'pending' | 'error' | 'success'): number | false {
551
+ return status === 'error' ? RETAINED_POLL_MS : false
552
+ }
553
+
547
554
  // Ring keys whose next fetch must be a full reload (manual refresh). A side
548
555
  // flag, not a side cursor: consuming it only switches the PATH of the next
549
556
  // fetch — all sync state that must stay consistent with the data lives inside
@@ -750,6 +757,7 @@ function createRingEventsHook(opts: {
750
757
  },
751
758
  enabled: windowNeeded,
752
759
  staleTime: FROZEN_WINDOW_STALE_MS,
760
+ refetchInterval: (q) => frozenWindowRefetchInterval(q.state.status),
753
761
  })
754
762
 
755
763
  const kindsKey = query.kinds?.join(',')
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { QueryClient } from '@tanstack/react-query'
3
+ import { detectTrafficSourcesAgain, fetchTrafficSources } from './traffic'
4
+
5
+ const found = { detected: [{ name: 'hubble', status: 'available' }], notDetected: [] }
6
+ const none = { detected: [], notDetected: [{ name: 'hubble', status: 'not_found' }] }
7
+
8
+ function serve(responses: Record<string, unknown>, delays: Record<string, number> = {}) {
9
+ const calls: string[] = []
10
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
11
+ const path = url.slice(url.indexOf('/traffic/'))
12
+ calls.push(path)
13
+ if (delays[path]) {
14
+ await new Promise((resolve, reject) => {
15
+ const timer = setTimeout(resolve, delays[path])
16
+ init?.signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('aborted', 'AbortError')) })
17
+ })
18
+ }
19
+ return new Response(JSON.stringify(responses[path]), { status: 200 })
20
+ }))
21
+ return calls
22
+ }
23
+
24
+ afterEach(() => vi.unstubAllGlobals())
25
+
26
+ describe('fetchTrafficSources', () => {
27
+ it('reuses a recent detection that found a source', async () => {
28
+ const calls = serve({ '/traffic/sources?recent=1': found })
29
+ const sources = await fetchTrafficSources(new QueryClient(), false)
30
+ expect(sources).toEqual(found)
31
+ expect(calls).toEqual(['/traffic/sources?recent=1'])
32
+ })
33
+
34
+ it('detects afresh before showing a recent detection that found none', async () => {
35
+ const calls = serve({ '/traffic/sources?recent=1': none, '/traffic/sources': found })
36
+ const sources = await fetchTrafficSources(new QueryClient(), false)
37
+ expect(sources).toEqual(found)
38
+ expect(calls).toEqual(['/traffic/sources?recent=1', '/traffic/sources'])
39
+ })
40
+ })
41
+
42
+ describe('detectTrafficSourcesAgain', () => {
43
+ it('detects afresh even while a recent detection is being fetched', async () => {
44
+ const calls = serve({ '/traffic/sources?recent=1': found, '/traffic/sources': none }, { '/traffic/sources?recent=1': 50 })
45
+ const client = new QueryClient()
46
+ void client.fetchQuery({ queryKey: ['traffic-sources'], queryFn: ({ signal }) => fetchTrafficSources(client, false, signal) }).catch(() => {})
47
+ const sources = await detectTrafficSourcesAgain(client)
48
+ expect(sources).toEqual(none)
49
+ expect(calls).toContain('/traffic/sources')
50
+ // The abandoned recent detection must not land afterwards.
51
+ await new Promise(resolve => setTimeout(resolve, 80))
52
+ expect(client.getQueryData(['traffic-sources'])).toEqual(none)
53
+ expect(client.getQueryData(['traffic-sources', 'recent'])).toEqual(none)
54
+ })
55
+ })
@@ -1,12 +1,16 @@
1
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
- import type { TrafficSourcesResponse, TrafficFlowsResponse } from '../types'
1
+ import { useCallback, useMemo } from 'react'
2
+ import { useQuery, useMutation, useQueryClient, type QueryClient } from '@tanstack/react-query'
3
+ import type { TrafficSourcesResponse, TrafficFlowsResponse, TrafficRecordsResponse } from '../types'
4
+ import { fetchJSON as fetchApiJSON, useRadarFeature } from './client'
5
+ import { shouldRetryRadarQuery } from './radarFeatures'
3
6
  import { apiUrl, getAuthHeaders, getCredentialsMode } from './config'
4
7
  import { readErrorBody } from './httpErrors'
5
8
 
6
- async function fetchJSON<T>(path: string): Promise<T> {
9
+ async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
7
10
  const response = await fetch(apiUrl(path), {
8
11
  credentials: getCredentialsMode(),
9
12
  headers: getAuthHeaders(),
13
+ signal,
10
14
  })
11
15
  if (!response.ok) {
12
16
  const error = await readErrorBody(response)
@@ -26,34 +30,102 @@ export interface TrafficConnectionInfo {
26
30
  error?: string
27
31
  }
28
32
 
29
- // Get available traffic sources and recommendations
33
+ // Get available traffic sources and recommendations. A detection up to a
34
+ // couple of minutes old is reused when it found a source: a full one probes
35
+ // every source in turn and takes seconds, and the page that linked here has
36
+ // usually just made one. One that found none is redone, since it is about to
37
+ // be shown as setup advice. detectAgain always detects afresh.
38
+ export async function fetchTrafficSources(queryClient: QueryClient, fresh: boolean, signal?: AbortSignal): Promise<TrafficSourcesResponse> {
39
+ let sources = await fetchJSON<TrafficSourcesResponse>(fresh ? '/traffic/sources' : '/traffic/sources?recent=1', signal)
40
+ if (!fresh && !sources.detected.some(s => s.status === 'available')) {
41
+ sources = await fetchJSON<TrafficSourcesResponse>('/traffic/sources', signal)
42
+ }
43
+ // A detection is also the newest answer to "is traffic available".
44
+ queryClient.setQueryData(['traffic-sources', 'recent'], sources)
45
+ return sources
46
+ }
47
+
48
+ export async function detectTrafficSourcesAgain(queryClient: QueryClient): Promise<TrafficSourcesResponse> {
49
+ // A pending fetch of the same key would otherwise be returned in its place.
50
+ await queryClient.cancelQueries({ queryKey: ['traffic-sources'], exact: true })
51
+ return queryClient.fetchQuery({
52
+ queryKey: ['traffic-sources'],
53
+ queryFn: ({ signal }) => fetchTrafficSources(queryClient, true, signal),
54
+ staleTime: 0,
55
+ })
56
+ }
57
+
30
58
  export function useTrafficSources() {
31
- return useQuery<TrafficSourcesResponse>({
59
+ const queryClient = useQueryClient()
60
+ const query = useQuery<TrafficSourcesResponse>({
32
61
  queryKey: ['traffic-sources'],
33
- queryFn: () => fetchJSON('/traffic/sources'),
62
+ queryFn: ({ signal }) => fetchTrafficSources(queryClient, false, signal),
34
63
  staleTime: 30000, // 30 seconds
35
64
  retry: 1,
36
65
  })
66
+ const detectAgain = useCallback(() => detectTrafficSourcesAgain(queryClient), [queryClient])
67
+ return { ...query, detectAgain }
37
68
  }
38
69
 
39
- // Get traffic flows
40
- export interface UseTrafficFlowsOptions {
70
+ // Whether this cluster has a traffic source: true only once one is detected
71
+ // as available, so a page offering traffic never offers it on a guess. Accepts
72
+ // a detection up to a couple of minutes old (?recent=1), so browsing workloads
73
+ // does not probe every source on each page.
74
+ export function useTrafficAvailable(enabled = true): boolean {
75
+ // A workload focus needs the server to name each pod's workload, which
76
+ // shipped with flow records. An older Radar leaves Hubble pods unnamed, so
77
+ // the focus it opened would match nothing.
78
+ const resolvesWorkloads = useRadarFeature('trafficRecords').support === 'supported'
79
+ const { data } = useQuery<TrafficSourcesResponse>({
80
+ queryKey: ['traffic-sources', 'recent'],
81
+ queryFn: () => fetchJSON('/traffic/sources?recent=1'),
82
+ staleTime: 2 * 60_000,
83
+ retry: false,
84
+ enabled: enabled && resolvesWorkloads,
85
+ })
86
+ return resolvesWorkloads && !!data?.detected?.some(s => s.status === 'available')
87
+ }
88
+
89
+ // What the flows and records queries share: the namespaces in view, the
90
+ // window, and the traffic the view hides. The hidden traffic is sent so the
91
+ // source can drop it before it counts against its limits; the view still
92
+ // filters it too.
93
+ export interface TrafficScope {
41
94
  namespaces?: string[]
42
95
  since?: string // Duration like "5m", "1h"
43
- enabled?: boolean
96
+ excludeNamespaces?: readonly string[]
97
+ excludeHost?: boolean
44
98
  }
45
99
 
46
- export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
47
- const { namespaces = [], since, enabled = true } = options
48
-
100
+ function trafficScopeParams({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope): URLSearchParams {
49
101
  const params = new URLSearchParams()
50
- // Traffic backend only supports single namespace, use first if provided
102
+ // With several namespaces in view the server reads them from the saved pick.
51
103
  if (namespaces.length === 1) params.set('namespace', namespaces[0])
52
104
  if (since) params.set('since', since)
105
+ if (excludeNamespaces?.length) params.set('excludeNamespaces', excludeNamespaces.join(','))
106
+ if (excludeHost) params.set('excludeHost', 'true')
107
+ return params
108
+ }
109
+
110
+ function trafficScopeKey({ namespaces = [], since, excludeNamespaces, excludeHost }: TrafficScope) {
111
+ return [namespaces, since, excludeNamespaces ?? [], !!excludeHost]
112
+ }
113
+
114
+ // Get traffic flows
115
+ export interface UseTrafficFlowsOptions extends TrafficScope {
116
+ /** One graph node per pod instead of per workload. */
117
+ byPod?: boolean
118
+ enabled?: boolean
119
+ }
120
+
121
+ export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
122
+ const { enabled = true, byPod = false } = options
123
+ const params = trafficScopeParams(options)
124
+ if (byPod) params.set('groupBy', 'pod')
53
125
  const queryString = params.toString()
54
126
 
55
127
  return useQuery<TrafficFlowsResponse>({
56
- queryKey: ['traffic-flows', namespaces, since],
128
+ queryKey: ['traffic-flows', ...trafficScopeKey(options), byPod],
57
129
  queryFn: () => fetchJSON(`/traffic/flows${queryString ? `?${queryString}` : ''}`),
58
130
  staleTime: 5000, // 5 seconds
59
131
  enabled,
@@ -61,6 +133,93 @@ export function useTrafficFlows(options: UseTrafficFlowsOptions = {}) {
61
133
  })
62
134
  }
63
135
 
136
+ /** An endpoint as the server's aggregation keys it. */
137
+ export interface TrafficEndpointRef {
138
+ namespace?: string
139
+ name: string
140
+ kind?: string
141
+ workloadKind?: string
142
+ }
143
+
144
+ /** One edge of the server's aggregation, keyed as the server keys it. */
145
+ export interface TrafficEndpointPair {
146
+ source: TrafficEndpointRef
147
+ destination: TrafficEndpointRef
148
+ port: number
149
+ directionUnknown?: boolean
150
+ }
151
+
152
+ /** The selection rides in the query string, URL-encoded. Proxies in front of
153
+ * Radar commonly refuse request lines over 8 KiB (ingress-nginx's default),
154
+ * so a larger selection is not sent at all. */
155
+ const MAX_TRAFFIC_MATCH_CHARS = 6_000
156
+
157
+ /** A selection as the server matches it: its edges, or a node as the one
158
+ * endpoint it is, which every one of its edges has at an end. */
159
+ export type TrafficMatch = { pairs: TrafficEndpointPair[] } | { endpoints: TrafficEndpointRef[] }
160
+
161
+ // Every pair encodes to more than this many characters, so a selection with
162
+ // more pairs than the request can hold is rejected without serializing it.
163
+ const MIN_MATCH_PAIR_CHARS = 40
164
+
165
+ /** The whole query string's limit: the selection plus the scope beside it,
166
+ * under the 8 KiB request line proxies commonly allow. */
167
+ const MAX_TRAFFIC_QUERY_CHARS = 7_000
168
+
169
+ function encodedMatch(match: TrafficMatch): string | null {
170
+ if ('pairs' in match && match.pairs.length * MIN_MATCH_PAIR_CHARS > MAX_TRAFFIC_MATCH_CHARS) return null
171
+ const json = JSON.stringify(match)
172
+ return encodeURIComponent(json).length > MAX_TRAFFIC_MATCH_CHARS ? null : json
173
+ }
174
+
175
+ export interface UseTrafficRecordsOptions extends TrafficScope {
176
+ /** The selection, or null for no selection. */
177
+ match: TrafficMatch | null
178
+ /** Namespaces also excluded when the request has room for them; the list
179
+ * filters them out anyway. */
180
+ optionalExcludeNamespaces?: readonly string[]
181
+ enabled?: boolean
182
+ }
183
+
184
+ // The newest records behind a graph selection. The flows response carries only
185
+ // a capped sample of records, so a selected edge is looked up on its own —
186
+ // otherwise a quiet edge on a busy cluster would show nothing.
187
+ export function useTrafficRecords(options: UseTrafficRecordsOptions) {
188
+ const { enabled = true } = options
189
+ const { guard, gatedKey, support } = useRadarFeature('trafficRecords')
190
+ // Serialized when the selection changes (each refresh), not on every
191
+ // render: a merged edge can carry tens of thousands of pairs.
192
+ const match = useMemo(() => (options.match ? encodedMatch(options.match) : null), [options.match])
193
+ const optional = options.optionalExcludeNamespaces ?? []
194
+ let scope: TrafficScope = optional.length > 0
195
+ ? { ...options, excludeNamespaces: [...(options.excludeNamespaces ?? []), ...optional] }
196
+ : options
197
+ let params = trafficScopeParams(scope)
198
+ if (match) params.set('match', match)
199
+ if (optional.length > 0 && params.toString().length > MAX_TRAFFIC_QUERY_CHARS) {
200
+ scope = options
201
+ params = trafficScopeParams(scope)
202
+ if (match) params.set('match', match)
203
+ }
204
+ const tooLarge = options.match !== null && (match === null || params.toString().length > MAX_TRAFFIC_QUERY_CHARS)
205
+ // The view this answers, apart from the optional exclusions.
206
+ const view = JSON.stringify([...trafficScopeKey(options), match])
207
+
208
+ const query = useQuery<TrafficRecordsResponse>({
209
+ queryKey: ['traffic-flows', 'records', ...trafficScopeKey(scope), match, ...gatedKey],
210
+ queryFn: () => guard(() => fetchApiJSON<TrafficRecordsResponse>(`/traffic/flows/records?${params.toString()}`)),
211
+ staleTime: 5000,
212
+ enabled: enabled && match !== null && !tooLarge && support !== 'unsupported',
213
+ meta: { view },
214
+ // Kept while only the optional exclusions change (a namespace hidden in
215
+ // the sidebar, which the list filters out anyway). Not across another
216
+ // selection, window or scope: those records would show under this one.
217
+ placeholderData: (prev, prevQuery) => (prevQuery?.meta?.view === view ? prev : undefined),
218
+ retry: shouldRetryRadarQuery,
219
+ })
220
+ return { ...query, tooLarge, supported: support !== 'unsupported' }
221
+ }
222
+
64
223
  // Get active traffic source
65
224
  export function useActiveTrafficSource() {
66
225
  return useQuery<{ active: string }>({
@@ -55,6 +55,7 @@ import { useTimelineSource } from "../../context/TimelineSource";
55
55
  import { apiVersionToGroup, buildWorkloadPath, kindToPluralWithGroup } from "../../utils/navigation";
56
56
  import { WorkloadView } from "../workload/WorkloadView";
57
57
  import { ApplicationCostTab } from "../cost/ApplicationCostTab";
58
+ import { useTimelineRetentionNote } from "../../context/TimelineCustomization";
58
59
  import { isOpenCostWorkloadKind } from "../cost/kinds";
59
60
 
60
61
  type ApplicationRouteView = ApplicationView | "cost";
@@ -302,6 +303,7 @@ function AppDetailRoute({
302
303
  : historyRangeOptions[0].value;
303
304
  const historyTimelineEnabled =
304
305
  !selectedWorkloadKey && selectedView === "history";
306
+ const retentionNote = useTimelineRetentionNote();
305
307
  const historyTimelineQuery = timelineSource.useEvents({
306
308
  namespaces: appHistoryNamespaces,
307
309
  timeRange: historyRange,
@@ -665,6 +667,7 @@ function AppDetailRoute({
665
667
  historyCoverageRecordCount={historyTimelineQuery.coverage?.length ?? 0}
666
668
  historyRuntimeLimited={historyRuntimeLimited}
667
669
  historyRingTruncated={historyTimelineQuery.truncated ?? false}
670
+ historyRetentionNote={retentionNote}
668
671
  onHistoryRangeChange={setRetainedHistoryRange}
669
672
  onOpenTimeline={openApplicationTimeline}
670
673
  onOpenSource={openSource}
@@ -83,6 +83,9 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
83
83
  )
84
84
  }
85
85
 
86
+ const annotationIgnored = Object.values(data.annotationIgnoredByNamespace ?? {})
87
+ .reduce((sum, byNS) => sum + Object.values(byNS).reduce((a, n) => a + n, 0), 0)
88
+
86
89
  const onResourceClick = (ref: CheckResourceRef) =>
87
90
  onNavigateToResource({ kind: ref.kind, namespace: ref.namespace, name: ref.name, group: ref.group })
88
91
 
@@ -108,6 +111,11 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
108
111
  {ignoredCount > 0 && (
109
112
  <button onClick={() => setShowSettings(true)} className="text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors">{ignoredCount} {ignoredCount === 1 ? 'namespace' : 'namespaces'} hidden</button>
110
113
  )}
114
+ {annotationIgnored > 0 && (
115
+ <Tooltip content={`Resources that opted out of a check with the ${data.ignoreChecksAnnotation} annotation`}>
116
+ <span className="text-xs text-theme-text-tertiary">{annotationIgnored} {annotationIgnored === 1 ? 'finding' : 'findings'} hidden by annotation</span>
117
+ </Tooltip>
118
+ )}
111
119
  <Tooltip content="Checks settings">
112
120
  <button
113
121
  onClick={() => setShowSettings(true)}
@@ -129,6 +137,7 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
129
137
  onResourceClick={onResourceClick}
130
138
  onHideCheck={canEdit ? hideCheck : undefined}
131
139
  onHideCategory={canEdit ? hideCategory : undefined}
140
+ ignoreChecksAnnotation={data.ignoreChecksAnnotation}
132
141
  />
133
142
 
134
143
  {showSettings && <AuditSettingsDialog namespaces={namespaces} onClose={() => setShowSettings(false)} />}