@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
@@ -1,19 +1,23 @@
1
1
  import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
2
- import { useTrafficSources, useTrafficFlows, useTrafficConnect, useSetTrafficSource } from '../../api/traffic'
3
- import { useClusterInfo } from '../../api/client'
4
- import type { TrafficWizardState, AggregatedFlow } from '../../types'
2
+ import { useTrafficSources, useTrafficFlows, useTrafficRecords, useTrafficConnect, useSetTrafficSource, type TrafficEndpointPair } from '../../api/traffic'
3
+ import { useClusterInfo, useNamespaceScope } from '../../api/client'
4
+ import { useSearchParams } from 'react-router-dom'
5
+ import type { TrafficWizardState, AggregatedFlow, TrafficFlow } from '../../types'
5
6
  import { TrafficWizard } from './TrafficWizard'
6
7
  import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
7
8
  import { TrafficFilterSidebar } from './TrafficFilterSidebar'
8
9
  import { TrafficFlowListProvider } from './TrafficFlowListContext'
9
- import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle, Clock } from 'lucide-react'
10
+ import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle, Clock, Crosshair, X } from 'lucide-react'
11
+ import { TrafficFocusSearch } from './TrafficFocusSearch'
12
+ import { TrafficGraphTooLarge } from './TrafficGraphTooLarge'
10
13
  import { clsx } from 'clsx'
11
14
  import { useQueryClient } from '@tanstack/react-query'
12
15
  import { useDock } from '../dock'
13
16
  import { AlertBanner, EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
14
17
  import { useConnection } from '../../context/ConnectionContext'
15
18
  import { Tooltip } from '../ui/Tooltip'
16
- import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind, mergeFlowVolume, coverageLabel } from './trafficFilters'
19
+ import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
20
+ import { matchesStatusRanges, bucketsFromCounts, bucketsFromStatus, isRateBasedSource, keepAvailable, effectiveThreshold, volumeUnit, type VolumeUnit, isExternalKind, mergeFlowVolume, coverageLabel, type GraphFlow, endpointPair, graphEndpoint, graphEndpointId, mergeRawPairs, mergedEdgePort, selectedEdgeMatcher, selectionRawPairs, selectionMatch, graphSize, GRAPH_DRAW_BUDGET, GRAPH_DRAW_CEILING, parseFocus, focusParam, focusId, focusNeighborhood, touchesFocus, endpointSummaries, namespaceSummaries, type TrafficFocus } from './trafficFilters'
17
21
 
18
22
  // Consecutive 2s retries of an empty result that came with a transient warning.
19
23
  const MAX_EMPTY_RETRIES = 5
@@ -166,6 +170,10 @@ const SYSTEM_NAMESPACES = new Set([
166
170
  'external-secrets', // External Secrets Operator
167
171
  ])
168
172
 
173
+ // Sent with each query while system traffic is hidden, so the source drops it
174
+ // before it counts against the source's per-node limits.
175
+ const SYSTEM_NAMESPACE_LIST: readonly string[] = [...SYSTEM_NAMESPACES]
176
+
169
177
  // Detect internal load balancer IPs (appear as "external" but are internal)
170
178
  function isInternalLoadBalancer(name: string): boolean {
171
179
  // GKE internal LB IPs (10.x.x.x range)
@@ -339,11 +347,35 @@ function isSystemEndpoint(name: string, namespace: string | undefined, kind: str
339
347
 
340
348
  const isExternal = isExternalKind
341
349
 
350
+
351
+ // Why the map covers less than the chosen window. Two limits can cut it: each
352
+ // node returns at most nodeFlowLimit flows, and on a large cluster Radar keeps
353
+ // at most flowLimit in total. Narrowing what is fetched is the way to see more.
354
+ function coverageExplanation(data: { nodeFlowLimit?: number; flowLimit?: number } | undefined, timeRange: string, hideSystem: boolean): string {
355
+ const narrow = hideSystem
356
+ ? 'Selecting fewer namespaces fetches less traffic, so the map covers more of the window.'
357
+ : 'Hiding system traffic or selecting fewer namespaces fetches less traffic, so the map covers more of the window.'
358
+ if (data?.flowLimit) {
359
+ return `This cluster produced more flows in the last ${timeRange} than Radar keeps per refresh (${data.flowLimit.toLocaleString()}), so the map shows the newest ones. ${narrow}`
360
+ }
361
+ return `Radar reads at most ${(data?.nodeFlowLimit ?? 0).toLocaleString()} of the newest flows from each node. At least one node reached that limit before the start of the ${timeRange} window, so its earlier traffic is not included. Other nodes may still show older flows. ${narrow}`
362
+ }
363
+
364
+ function flowStatsExplanation(stats: { shown: number; total: number; hidden: number; outsideFocus: number; aggregated: number }): string {
365
+ const parts = [`${stats.shown.toLocaleString()} of the ${stats.total.toLocaleString()} connections in this window are shown`]
366
+ if (stats.hidden > 0) parts.push(`${stats.hidden.toLocaleString()} hidden by filters`)
367
+ if (stats.outsideFocus > 0) parts.push(`${stats.outsideFocus.toLocaleString()} outside the focus`)
368
+ if (stats.aggregated > 0) parts.push(`${stats.aggregated.toLocaleString()} merged into others`)
369
+ return parts.join('; ') + '.'
370
+ }
371
+
342
372
  interface TrafficViewProps {
343
373
  namespaces: string[]
374
+ /** Sets the app-wide namespace selection. */
375
+ onSetNamespaces?: (namespaces: string[]) => void
344
376
  }
345
377
 
346
- export function TrafficView({ namespaces }: TrafficViewProps) {
378
+ export function TrafficView({ namespaces, onSetNamespaces }: TrafficViewProps) {
347
379
  const { connection } = useConnection()
348
380
  const [wizardState, setWizardState] = useState<TrafficWizardState>('detecting')
349
381
  const [timeRange, setTimeRange] = useState<string>('5m')
@@ -358,9 +390,43 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
358
390
  const [aggregateExternal, setAggregateExternal] = useState(true)
359
391
  const [detectServices, setDetectServices] = useState(true)
360
392
  const [collapseInternet, setCollapseInternet] = useState(true)
393
+ // Focus and grouping live in the URL so a link carries both: a pod focus
394
+ // matches only while pods are drawn. A focus is a history entry; switching
395
+ // the grouping replaces the current one.
396
+ const [searchParams, setSearchParams] = useSearchParams()
397
+ const groupByWorkload = searchParams.get('groupBy') !== 'pod'
398
+ const setGroupByWorkload = useCallback((on: boolean) => {
399
+ setSearchParams(prev => {
400
+ const params = new URLSearchParams(prev)
401
+ if (on) params.delete('groupBy')
402
+ else params.set('groupBy', 'pod')
403
+ return params
404
+ }, { replace: true })
405
+ }, [setSearchParams])
406
+ // A record's endpoint as the graph draws it in the chosen grouping.
407
+ const drawnEndpoint = useCallback(
408
+ (e: TrafficFlow['source']) => (groupByWorkload ? graphEndpoint(e) : e),
409
+ [groupByWorkload])
361
410
  const [addonMode, setAddonMode] = useState<AddonMode>('show')
362
411
  const [graphSelection, setGraphSelection] = useState<TrafficGraphSelection | null>(null)
412
+ const clearGraphSelection = useCallback(() => setGraphSelection(null), [])
413
+ // The view the graph was last drawn for, and so keeps drawing (up to the
414
+ // ceiling) as refreshes move its size: set by drawing it or by Draw anyway.
415
+ const [drawnViewKey, setDrawnViewKey] = useState<string | null>(null)
363
416
  const dock = useDock()
417
+ const graphPaneRef = useRef<HTMLDivElement>(null)
418
+
419
+ const focusValue = searchParams.get('focus')
420
+ const focus = useMemo(() => parseFocus(focusValue), [focusValue])
421
+ const focusKey = focus ? focusId(focus) : undefined
422
+ const setFocus = useCallback((next: TrafficFocus | null) => {
423
+ setSearchParams(prev => {
424
+ const params = new URLSearchParams(prev)
425
+ if (next) params.set('focus', focusParam(next))
426
+ else params.delete('focus')
427
+ return params
428
+ })
429
+ }, [setSearchParams])
364
430
 
365
431
  // Dock: offset past sidebar, close flows tab on unmount
366
432
  const flowsTabIdRef = useRef<string | null>(null)
@@ -376,7 +442,9 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
376
442
  }
377
443
  }, []) // eslint-disable-line react-hooks/exhaustive-deps
378
444
 
379
- const [hiddenNamespaces, setHiddenNamespaces] = useState<Set<string>>(new Set())
445
+ const [chosenHiddenNamespaces, setHiddenNamespaces] = useState<Set<string>>(new Set())
446
+ // Paused while focused: a neighbor in a hidden namespace is still a neighbor.
447
+ const hiddenNamespaces = useMemo(() => (focus ? new Set<string>() : chosenHiddenNamespaces), [focus, chosenHiddenNamespaces])
380
448
  // L7 filters (Hubble-only)
381
449
  const [l7Protocol, setL7Protocol] = useState<string>('all')
382
450
  const [l7Methods, setL7Methods] = useState<Set<string>>(new Set())
@@ -426,7 +494,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
426
494
  const {
427
495
  data: sourcesData,
428
496
  isLoading: sourcesLoading,
429
- refetch: refetchSources,
497
+ detectAgain: detectSourcesAgain,
430
498
  } = useTrafficSources()
431
499
 
432
500
  const {
@@ -437,6 +505,9 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
437
505
  } = useTrafficFlows({
438
506
  namespaces,
439
507
  since: timeRange,
508
+ excludeNamespaces: hideSystem ? SYSTEM_NAMESPACE_LIST : undefined,
509
+ excludeHost: hideSystem,
510
+ byPod: !groupByWorkload,
440
511
  // Only fetch flows when connected (not connecting and no connection error)
441
512
  enabled: wizardState === 'ready' && !isConnecting && !connectionError,
442
513
  })
@@ -449,6 +520,21 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
449
520
  // An 'incomplete' one is a fetch that worked but could not see everything
450
521
  // (events lost, nodes unreachable). Not a failure, so not retried at once.
451
522
  const warningIsIncomplete = flowsData?.warningKind === 'incomplete'
523
+ const flowsWarning = flowsData?.warning ? (
524
+ <AlertBanner
525
+ variant="warning"
526
+ // The title follows the kind. A partial warning is about values
527
+ // on edges that are shown — a port reported as 0, UDP as TCP, a
528
+ // 5xx rate that failed to load — so it says "unreliable", not
529
+ // "incomplete", which would send the reader looking for missing
530
+ // workloads. An incomplete or transient one beside flows is
531
+ // exactly that: a stream cut short, events lost, a node the
532
+ // relay could not reach, a TCP query that failed — edges may
533
+ // be missing from what is drawn.
534
+ title={warningIsPermanent ? 'Some values on this map are unreliable' : 'Some traffic may be missing from this map'}
535
+ message={flowsData.warning}
536
+ />
537
+ ) : null
452
538
 
453
539
  // Auto-retry when flows return with warning but no data (e.g., port-forward not
454
540
  // ready yet). Bounded: a warning that keeps coming back — a node the relay
@@ -610,74 +696,57 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
610
696
  })
611
697
  }, [flowsData?.aggregated, hideSystem, hideExternal, activeMinConnections, hiddenNamespaces, addonMode, l7Protocol, activeMethods, activeStatusRanges, activeVerdicts, activeDnsPattern])
612
698
 
613
- // Filter raw flows with the same base filters (for list view)
614
- const filteredRawFlows = useMemo(() => {
615
- if (!flowsData?.flows) return []
616
- return flowsData.flows.filter(flow => {
617
- const sourceIsSystem = isSystemEndpoint(flow.source.name, flow.source.namespace, flow.source.kind)
618
- const destIsSystem = isSystemEndpoint(flow.destination.name, flow.destination.namespace, flow.destination.kind)
619
- if (hideSystem && (sourceIsSystem || destIsSystem)) return false
620
-
621
- const isAlwaysFiltered = (name: string) =>
622
- name === 'metadata.google.internal' || name === 'metadata.google.internal.' ||
623
- name.startsWith('169.254.') || name === 'instance-data.ec2.internal' ||
624
- name === 'localhost' || name === '127.0.0.1' || name.startsWith('127.') || name === '0.0.0.0'
625
- if (isAlwaysFiltered(flow.source.name) || isAlwaysFiltered(flow.destination.name)) return false
626
-
627
- if (hideExternal && (isExternal(flow.source.kind) || isExternal(flow.destination.kind))) return false
699
+ // The graph's filters, applied to individual records for the list view
700
+ const rawFlowPasses = useCallback((flow: TrafficFlow) => {
701
+ // Name rules (system components, addons) judge an endpoint by the name the
702
+ // graph draws it under, so a hidden workload's pods leave the list too.
703
+ const sourceName = drawnEndpoint(flow.source).name
704
+ const destName = drawnEndpoint(flow.destination).name
705
+ const sourceIsSystem = isSystemEndpoint(sourceName, flow.source.namespace, flow.source.kind)
706
+ const destIsSystem = isSystemEndpoint(destName, flow.destination.namespace, flow.destination.kind)
707
+ if (hideSystem && (sourceIsSystem || destIsSystem)) return false
708
+
709
+ const isAlwaysFiltered = (name: string) =>
710
+ name === 'metadata.google.internal' || name === 'metadata.google.internal.' ||
711
+ name.startsWith('169.254.') || name === 'instance-data.ec2.internal' ||
712
+ name === 'localhost' || name === '127.0.0.1' || name.startsWith('127.') || name === '0.0.0.0'
713
+ if (isAlwaysFiltered(flow.source.name) || isAlwaysFiltered(flow.destination.name)) return false
714
+
715
+ if (hideExternal && (isExternal(flow.source.kind) || isExternal(flow.destination.kind))) return false
716
+
717
+ if (addonMode === 'hide') {
718
+ if (isClusterAddon(sourceName, flow.source.namespace) || isClusterAddon(destName, flow.destination.namespace)) return false
719
+ }
628
720
 
629
- if (addonMode === 'hide') {
630
- if (isClusterAddon(flow.source.name, flow.source.namespace) || isClusterAddon(flow.destination.name, flow.destination.namespace)) return false
631
- }
721
+ if (hiddenNamespaces.size > 0) {
722
+ if (flow.source.namespace && hiddenNamespaces.has(flow.source.namespace)) return false
723
+ if (flow.destination.namespace && hiddenNamespaces.has(flow.destination.namespace)) return false
724
+ }
632
725
 
633
- if (hiddenNamespaces.size > 0) {
634
- if (flow.source.namespace && hiddenNamespaces.has(flow.source.namespace)) return false
635
- if (flow.destination.namespace && hiddenNamespaces.has(flow.destination.namespace)) return false
636
- }
726
+ // Protocol filter
727
+ if (l7Protocol === 'HTTP' && flow.l7Protocol !== 'HTTP') return false
728
+ if (l7Protocol === 'DNS' && flow.l7Protocol !== 'DNS') return false
729
+ if (l7Protocol === 'TCP' && flow.l7Protocol) return false
637
730
 
638
- // Protocol filter
639
- if (l7Protocol === 'HTTP' && flow.l7Protocol !== 'HTTP') return false
640
- if (l7Protocol === 'DNS' && flow.l7Protocol !== 'DNS') return false
641
- if (l7Protocol === 'TCP' && flow.l7Protocol) return false
731
+ // L7 sub-filters on individual flow fields
732
+ if (activeMethods.size > 0) {
733
+ if (!flow.httpMethod || !activeMethods.has(flow.httpMethod)) return false
734
+ }
735
+ if (!matchesStatusRanges(activeStatusRanges, bucketsFromStatus(flow.httpStatus), (flow.errorRate ?? 0) > 0)) return false
736
+ if (activeVerdicts.size > 0) {
737
+ if (!flow.verdict || !activeVerdicts.has(flow.verdict)) return false
738
+ }
739
+ if (activeDnsPattern) {
740
+ if (!flow.dnsQuery || !flow.dnsQuery.toLowerCase().includes(activeDnsPattern.toLowerCase())) return false
741
+ }
642
742
 
643
- // L7 sub-filters on individual flow fields
644
- if (activeMethods.size > 0) {
645
- if (!flow.httpMethod || !activeMethods.has(flow.httpMethod)) return false
646
- }
647
- if (!matchesStatusRanges(activeStatusRanges, bucketsFromStatus(flow.httpStatus), (flow.errorRate ?? 0) > 0)) return false
648
- if (activeVerdicts.size > 0) {
649
- if (!flow.verdict || !activeVerdicts.has(flow.verdict)) return false
650
- }
651
- if (activeDnsPattern) {
652
- if (!flow.dnsQuery || !flow.dnsQuery.toLowerCase().includes(activeDnsPattern.toLowerCase())) return false
653
- }
743
+ return true
744
+ }, [hideSystem, hideExternal, hiddenNamespaces, addonMode, l7Protocol, activeMethods, activeStatusRanges, activeVerdicts, activeDnsPattern, drawnEndpoint])
654
745
 
655
- return true
656
- })
657
- }, [flowsData?.flows, hideSystem, hideExternal, hiddenNamespaces, addonMode, l7Protocol, activeMethods, activeStatusRanges, activeVerdicts, activeDnsPattern])
746
+ const filteredRawFlows = useMemo(
747
+ () => (flowsData?.flows ?? []).filter(rawFlowPasses),
748
+ [flowsData?.flows, rawFlowPasses])
658
749
 
659
- // Apply graph selection to filter raw flows for the list panel
660
- const listFlows = useMemo(() => {
661
- if (!graphSelection) return filteredRawFlows
662
- if (graphSelection.type === 'node' && graphSelection.nodeId) {
663
- const id = graphSelection.nodeId
664
- return filteredRawFlows.filter(f => {
665
- const srcId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
666
- const dstId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
667
- return srcId === id || dstId === id
668
- })
669
- }
670
- if (graphSelection.type === 'edge' && graphSelection.sourceId && graphSelection.destId) {
671
- return filteredRawFlows.filter(f => {
672
- const srcId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
673
- const dstId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
674
- // Match either direction (request goes A→B, response goes B→A)
675
- return (srcId === graphSelection.sourceId && dstId === graphSelection.destId) ||
676
- (srcId === graphSelection.destId && dstId === graphSelection.sourceId)
677
- })
678
- }
679
- return filteredRawFlows
680
- }, [filteredRawFlows, graphSelection])
681
750
 
682
751
  // Open flow list in the bottom dock
683
752
  const openFlowListDock = useCallback(() => {
@@ -727,8 +796,12 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
727
796
 
728
797
  // Process flows for external service aggregation (Phase 4.2)
729
798
  // Also tracks service categories for coloring external nodes
799
+ const graphInputFlows = useMemo<GraphFlow[]>(
800
+ () => filteredFlows.map(flow => ({ ...flow, rawPairs: [endpointPair(flow)] })),
801
+ [filteredFlows])
802
+
730
803
  const { processedFlows, serviceCategories } = useMemo<{
731
- processedFlows: AggregatedFlow[]
804
+ processedFlows: GraphFlow[]
732
805
  serviceCategories: Map<string, string>
733
806
  }>(() => {
734
807
  const categories = new Map<string, string>()
@@ -740,7 +813,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
740
813
 
741
814
  if (!aggregateExternal) {
742
815
  // Even without aggregation, detect service categories for coloring (destinations only)
743
- filteredFlows.forEach(flow => {
816
+ graphInputFlows.forEach(flow => {
744
817
  if (isExternal(flow.destination.kind)) {
745
818
  const info = getServiceInfo(flow.destination.name, flow.port)
746
819
  if (info.category) {
@@ -749,13 +822,13 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
749
822
  }
750
823
  // Don't apply port-based detection to sources - port tells us the destination service
751
824
  })
752
- return { processedFlows: filteredFlows, serviceCategories: categories }
825
+ return { processedFlows: graphInputFlows, serviceCategories: categories }
753
826
  }
754
827
 
755
828
  // Aggregate flows to the same external service
756
- const aggregatedMap = new Map<string, AggregatedFlow>()
829
+ const aggregatedMap = new Map<string, GraphFlow>()
757
830
 
758
- filteredFlows.forEach(flow => {
831
+ graphInputFlows.forEach(flow => {
759
832
  // Only aggregate destinations based on port/hostname - sources keep their original name
760
833
  // Port-based detection (MongoDB:27017) only makes sense for destinations
761
834
  const sourceAgg = isExternal(flow.source.kind)
@@ -784,12 +857,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
784
857
  const existing = aggregatedMap.get(key)
785
858
  if (existing) {
786
859
  mergeFlowVolume(existing, flow)
860
+ mergeRawPairs(existing, flow)
861
+ existing.port = mergedEdgePort(existing.port, flow.port)
787
862
  // Everything merged here shares the key's direction-known state, so the
788
863
  // flag is already correct on the entry that was created first.
789
864
  } else {
790
865
  // Create new aggregated flow with modified names
791
866
  aggregatedMap.set(key, {
792
867
  ...flow,
868
+ rawPairs: [...(flow.rawPairs ?? [])],
793
869
  source: sourceAgg.aggregated
794
870
  ? { ...flow.source, name: sourceAgg.name }
795
871
  : flow.source,
@@ -801,15 +877,15 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
801
877
  })
802
878
 
803
879
  return { processedFlows: Array.from(aggregatedMap.values()), serviceCategories: categories }
804
- }, [filteredFlows, aggregateExternal, detectServices])
880
+ }, [graphInputFlows, aggregateExternal, detectServices])
805
881
 
806
882
  // Collapse inbound internet traffic (external sources → internal destinations)
807
- const internetCollapsedFlows = useMemo<AggregatedFlow[]>(() => {
883
+ const internetCollapsedFlows = useMemo<GraphFlow[]>(() => {
808
884
  if (!collapseInternet) return processedFlows
809
885
 
810
886
  // Group flows where external sources connect to internal destinations
811
- const internetFlowsMap = new Map<string, AggregatedFlow>() // destKey -> aggregated flow
812
- const nonInternetFlows: AggregatedFlow[] = []
887
+ const internetFlowsMap = new Map<string, GraphFlow>() // destKey -> aggregated flow
888
+ const nonInternetFlows: GraphFlow[] = []
813
889
 
814
890
  processedFlows.forEach(flow => {
815
891
  const sourceIsExternal = isExternal(flow.source.kind)
@@ -828,10 +904,12 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
828
904
  const existing = internetFlowsMap.get(destKey)
829
905
  if (existing) {
830
906
  mergeFlowVolume(existing, flow)
907
+ mergeRawPairs(existing, flow)
831
908
  } else {
832
909
  // Create new "Internet" → destination flow
833
910
  internetFlowsMap.set(destKey, {
834
911
  ...flow,
912
+ rawPairs: [...(flow.rawPairs ?? [])],
835
913
  source: {
836
914
  name: 'Internet',
837
915
  namespace: '',
@@ -850,22 +928,41 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
850
928
  // When grouping addons:
851
929
  // 1. Aggregate internet → addon into single edge to group
852
930
  // 2. Aggregate addon → kubernetes into single edge from group
853
- const finalFlows = useMemo<AggregatedFlow[]>(() => {
854
- if (addonMode !== 'group') return internetCollapsedFlows
931
+ // Everything a focus can be put on: the endpoints drawn before addons are
932
+ // grouped, which are real ones.
933
+ const focusableEndpoints = useMemo(() => endpointSummaries(internetCollapsedFlows), [internetCollapsedFlows])
934
+ const focusedFlows = useMemo(
935
+ () => (focus ? focusNeighborhood(internetCollapsedFlows, focus) : internetCollapsedFlows),
936
+ [internetCollapsedFlows, focus])
937
+ // A new focus starts with nothing selected: the flow list follows the focus
938
+ // itself (listSelection), and opening its details would cover the node the
939
+ // view was just fitted around.
940
+ const focusFound = !!focus && focusedFlows.length > 0
941
+ useEffect(() => {
942
+ setGraphSelection(null)
943
+ }, [focusKey])
944
+
945
+ // A focused view draws its endpoints as they are: grouping the addons would
946
+ // fold the focus or its neighbors into the group.
947
+ const groupAddons = addonMode === 'group' && !focus
948
+ const finalFlows = useMemo<GraphFlow[]>(() => {
949
+ if (!groupAddons) return focusedFlows
855
950
 
856
951
  // Track totals for aggregated edges
857
952
  let addonInternetTotal = 0
858
953
  let addonToK8sTotal = 0
859
954
  let addonInternetRate = 0
860
955
  let addonToK8sRate = 0
861
- const processedFlows: AggregatedFlow[] = []
956
+ const addonInternetPairs: TrafficEndpointPair[] = []
957
+ const addonToK8sPairs: TrafficEndpointPair[] = []
958
+ const processedFlows: GraphFlow[] = []
862
959
 
863
960
  // Check if destination is the kubernetes API server
864
961
  const isKubernetesAPI = (name: string, namespace: string | undefined) => {
865
962
  return name === 'kubernetes' && (!namespace || namespace === 'default')
866
963
  }
867
964
 
868
- internetCollapsedFlows.forEach(flow => {
965
+ focusedFlows.forEach(flow => {
869
966
  const sourceIsAddon = isClusterAddon(flow.source.name, flow.source.namespace)
870
967
  const destIsAddon = isClusterAddon(flow.destination.name, flow.destination.namespace)
871
968
  const sourceIsInternet = flow.source.kind === 'Internet'
@@ -875,6 +972,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
875
972
  if (sourceIsInternet && destIsAddon) {
876
973
  addonInternetTotal += flow.connections
877
974
  addonInternetRate += flow.requestRate ?? 0
975
+ for (const pair of flow.rawPairs ?? []) addonInternetPairs.push(pair)
878
976
  processedFlows.push({
879
977
  ...flow,
880
978
  source: {
@@ -888,6 +986,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
888
986
  else if (sourceIsAddon && destIsK8sAPI) {
889
987
  addonToK8sTotal += flow.connections
890
988
  addonToK8sRate += flow.requestRate ?? 0
989
+ for (const pair of flow.rawPairs ?? []) addonToK8sPairs.push(pair)
891
990
  processedFlows.push({
892
991
  ...flow,
893
992
  destination: {
@@ -918,6 +1017,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
918
1017
  port: 0,
919
1018
  connections: addonInternetTotal,
920
1019
  ...(addonInternetRate > 0 && { requestRate: addonInternetRate }),
1020
+ rawPairs: addonInternetPairs,
921
1021
  bytesSent: 0,
922
1022
  bytesRecv: 0,
923
1023
  flowCount: 1,
@@ -942,6 +1042,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
942
1042
  port: 443,
943
1043
  connections: addonToK8sTotal,
944
1044
  ...(addonToK8sRate > 0 && { requestRate: addonToK8sRate }),
1045
+ rawPairs: addonToK8sPairs,
945
1046
  bytesSent: 0,
946
1047
  bytesRecv: 0,
947
1048
  flowCount: 1,
@@ -950,7 +1051,149 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
950
1051
  }
951
1052
 
952
1053
  return processedFlows
953
- }, [internetCollapsedFlows, addonMode])
1054
+ }, [focusedFlows, groupAddons])
1055
+
1056
+ // The map is laid out on the main thread, so a view too large to draw is
1057
+ // listed as a table instead of freezing the tab.
1058
+ const drawnGraph = useMemo(() => graphSize(finalFlows), [finalFlows])
1059
+ // Everything that makes it a different view. A refresh doesn't, so a view
1060
+ // that was drawn keeps being drawn as its size drifts past the budget.
1061
+ const viewKey = JSON.stringify([
1062
+ sourcesData?.active ?? '', namespaces, timeRange, focusKey ?? '', groupByWorkload, hideSystem, hideExternal, activeMinConnections,
1063
+ [...hiddenNamespaces].sort(), addonMode, aggregateExternal, detectServices, collapseInternet,
1064
+ l7Protocol, [...activeMethods].sort(), [...activeStatusRanges].sort(), [...activeVerdicts].sort(), activeDnsPattern,
1065
+ ])
1066
+ const fitsBudget = drawnGraph.score <= GRAPH_DRAW_BUDGET
1067
+ const tooLargeToDraw = drawnGraph.score > GRAPH_DRAW_CEILING || (!fitsBudget && drawnViewKey !== viewKey)
1068
+ useEffect(() => {
1069
+ if (finalFlows.length > 0 && fitsBudget) setDrawnViewKey(viewKey)
1070
+ }, [finalFlows.length, fitsBudget, viewKey])
1071
+
1072
+ // The table lists the endpoints as they are, so a selection made from it is
1073
+ // traced through the same flows.
1074
+ const selectableFlows = tooLargeToDraw ? focusedFlows : finalFlows
1075
+ // The server edges behind the selection, as drawn: what its records are
1076
+ // narrowed to, whichever form the lookup is sent in.
1077
+ // The flow list follows the focus while nothing else is selected: closing
1078
+ // its details must not widen the list to the whole cluster's sample.
1079
+ const listSelection = useMemo<TrafficGraphSelection | null>(
1080
+ () => graphSelection ?? (focusFound && focusKey ? { type: 'node', nodeId: focusKey } : null),
1081
+ [graphSelection, focusFound, focusKey])
1082
+ const selectionPairs = useMemo(
1083
+ () => selectionRawPairs(selectableFlows, listSelection, groupAddons && !tooLargeToDraw ? e => isClusterAddon(e.name, e.namespace) : undefined),
1084
+ [listSelection, selectableFlows, groupAddons, tooLargeToDraw])
1085
+ const selection = useMemo(() => selectionMatch(selectionPairs, listSelection), [selectionPairs, listSelection])
1086
+ // Ungrouped, a workload focus is drawn as its pods, so no node carries its
1087
+ // id; its records are still the workload's.
1088
+ const focusSelected = listSelection?.type === 'node' && listSelection.nodeId === focusKey && !!focus
1089
+ const recordsMatch = useMemo(
1090
+ () => {
1091
+ if (selection) return selection
1092
+ if (!focusSelected || !focus?.namespace) return null
1093
+ // The owner kind lets Hubble select the workload's pods by name.
1094
+ const pod = focusedFlows.flatMap(f => [f.source, f.destination])
1095
+ .find(e => e.namespace === focus.namespace && e.workload === focus.name && e.workloadKind)
1096
+ return { endpoints: [{ namespace: focus.namespace, name: focus.name, kind: 'Workload', ...(pod && { workloadKind: pod.workloadKind }) }] }
1097
+ },
1098
+ [selection, focusSelected, focus, focusedFlows])
1099
+
1100
+ // A selection names something in the representation it was made in: the
1101
+ // table lists endpoints as they are, the graph may group addons. Switching
1102
+ // between them keeps only a focus's selection, which means the same in both.
1103
+ const prevTooLargeRef = useRef(tooLargeToDraw)
1104
+ useEffect(() => {
1105
+ if (prevTooLargeRef.current === tooLargeToDraw) return
1106
+ prevTooLargeRef.current = tooLargeToDraw
1107
+ setGraphSelection(prev => (prev?.type === 'node' && prev.nodeId === focusKey ? prev : null))
1108
+ }, [tooLargeToDraw, focusKey])
1109
+
1110
+ // Namespaces hidden in the sidebar are dropped at the source too, when the
1111
+ // request has room, so their traffic can't fill the records limit. Only
1112
+ // here: the flows query keeps them, since the sidebar lists them from its
1113
+ // data to be shown again.
1114
+ const hiddenNamespaceList = useMemo(() => [...hiddenNamespaces].sort(), [hiddenNamespaces])
1115
+ const records = useTrafficRecords({
1116
+ namespaces,
1117
+ since: timeRange,
1118
+ excludeNamespaces: hideSystem ? SYSTEM_NAMESPACE_LIST : undefined,
1119
+ optionalExcludeNamespaces: hiddenNamespaceList,
1120
+ excludeHost: hideSystem,
1121
+ match: recordsMatch,
1122
+ enabled: wizardState === 'ready' && !isConnecting && !connectionError,
1123
+ })
1124
+ // Records come from their own query when the selection could be sent;
1125
+ // otherwise the selection is applied to the sample the flows response carries.
1126
+ const recordsEligible = recordsMatch !== null && records.supported && !records.tooLarge
1127
+ const useRecords = recordsEligible && !records.isError
1128
+
1129
+ const responsesCallerOriented = flowsData?.l7ResponsesCallerOriented === true
1130
+ const onSelectedEdge = useMemo(
1131
+ () => selectedEdgeMatcher(selectionPairs, responsesCallerOriented, drawnEndpoint),
1132
+ [selectionPairs, responsesCallerOriented, drawnEndpoint])
1133
+ // An endpoint lookup answers with all of its edges, including ones the view
1134
+ // hides (below the connection threshold), so they are dropped here.
1135
+ const filteredRecords = useMemo(
1136
+ () => (records.data?.flows ?? []).filter(f => rawFlowPasses(f) && onSelectedEdge(f)),
1137
+ [records.data?.flows, rawFlowPasses, onSelectedEdge])
1138
+ // Only the records the view's filters drop: an endpoint lookup also returns
1139
+ // edges the graph doesn't draw (outside a focus, or past the flows cap).
1140
+ const recordsHiddenByFilters = useMemo(
1141
+ () => (records.data?.flows ?? []).filter(f => onSelectedEdge(f) && !rawFlowPasses(f)).length,
1142
+ [records.data?.flows, rawFlowPasses, onSelectedEdge])
1143
+
1144
+ const sampleSelection = useMemo(() => {
1145
+ if (!listSelection) return filteredRawFlows
1146
+ if (focusSelected && focus && !selection) {
1147
+ return filteredRawFlows.filter(f => touchesFocus(f.source, focus) || touchesFocus(f.destination, focus))
1148
+ }
1149
+ if (selectionPairs) return filteredRawFlows.filter(onSelectedEdge)
1150
+ if (listSelection.type === 'node' && listSelection.nodeId) {
1151
+ const id = listSelection.nodeId
1152
+ return filteredRawFlows.filter(f => {
1153
+ const srcId = graphEndpointId(drawnEndpoint(f.source))
1154
+ const dstId = graphEndpointId(drawnEndpoint(f.destination))
1155
+ return srcId === id || dstId === id
1156
+ })
1157
+ }
1158
+ if (listSelection.type === 'edge' && listSelection.sourceId && listSelection.destId) {
1159
+ return filteredRawFlows.filter(f => {
1160
+ const srcId = graphEndpointId(drawnEndpoint(f.source))
1161
+ const dstId = graphEndpointId(drawnEndpoint(f.destination))
1162
+ // Match either direction (request goes A→B, response goes B→A)
1163
+ return (srcId === listSelection.sourceId && dstId === listSelection.destId) ||
1164
+ (srcId === listSelection.destId && dstId === listSelection.sourceId)
1165
+ })
1166
+ }
1167
+ return filteredRawFlows
1168
+ }, [filteredRawFlows, listSelection, selectionPairs, onSelectedEdge, drawnEndpoint, focusSelected, focus, selection])
1169
+
1170
+ const listFlows = useRecords ? filteredRecords : sampleSelection
1171
+
1172
+ const sampleTotal = flowsData?.flowsTotal ?? flowsData?.flows?.length ?? 0
1173
+ const sampleSize = flowsData?.flows?.length ?? 0
1174
+ const listNote = useMemo(() => {
1175
+ if (useRecords) {
1176
+ // This query's own account of what it could see: it is a separate fetch
1177
+ // from the graph's, and an empty list beside a warning is not "no traffic".
1178
+ const data = records.data
1179
+ if (!data) return undefined
1180
+ const parts: string[] = []
1181
+ if (data.warning) parts.push(data.warning)
1182
+ if (data.matched > data.flows.length) {
1183
+ parts.push(`Newest ${data.flows.length.toLocaleString()} of ${data.matched.toLocaleString()} records for this selection`)
1184
+ }
1185
+ if (recordsHiddenByFilters > 0) parts.push(`${recordsHiddenByFilters.toLocaleString()} record${recordsHiddenByFilters === 1 ? '' : 's'} hidden by the view's filters`)
1186
+ const covered = coverageLabel(data.coveredSince, data.timestamp)
1187
+ if (covered) parts.push(`Records cover the ${covered} of ${timeRange}`)
1188
+ return parts.length > 0 ? parts.join(' · ') : undefined
1189
+ }
1190
+ if (sampleTotal <= sampleSize) return undefined
1191
+ const sample = `newest ${sampleSize.toLocaleString()} of ${sampleTotal.toLocaleString()} records`
1192
+ if (!listSelection) return `Showing the ${sample} — select a node or edge to load its records`
1193
+ if (records.tooLarge) return `This selection is too large to look up on its own; showing its flows among the ${sample}`
1194
+ if (records.isError) return `Couldn't load this selection's records (${records.error?.message}); showing its flows among the ${sample}`
1195
+ return `Showing this selection's flows among the ${sample}`
1196
+ }, [useRecords, records.data, recordsHiddenByFilters, records.tooLarge, records.isError, records.error, sampleTotal, sampleSize, listSelection, timeRange])
954
1197
 
955
1198
  // Stats for display
956
1199
  const flowStats = useMemo(() => {
@@ -958,9 +1201,10 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
958
1201
  const filtered = filteredFlows.length
959
1202
  const shown = finalFlows.length
960
1203
  const hidden = total - filtered
961
- const aggregated = filtered - shown
962
- return { total, filtered, shown, hidden, aggregated }
963
- }, [flowsData?.aggregated?.length, filteredFlows.length, finalFlows.length])
1204
+ const outsideFocus = internetCollapsedFlows.length - focusedFlows.length
1205
+ const aggregated = filtered - shown - outsideFocus
1206
+ return { total, filtered, shown, hidden, outsideFocus, aggregated }
1207
+ }, [flowsData?.aggregated?.length, filteredFlows.length, finalFlows.length, internetCollapsedFlows.length, focusedFlows.length])
964
1208
 
965
1209
  // Compute hot path threshold (top 10% of connections)
966
1210
  const hotPathThreshold = useMemo(() => {
@@ -1020,6 +1264,33 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1020
1264
  }))
1021
1265
  }, [flowsData?.aggregated, hideSystem, hideExternal, activeMinConnections])
1022
1266
 
1267
+ const namespaceScopeQuery = useNamespaceScope()
1268
+ const namespaceLocked = !!namespaceScopeQuery.data?.cacheScoped && !namespaceScopeQuery.data?.namespaceRescope
1269
+ // Narrowing to one namespace keeps the edges with either end in it, as the
1270
+ // server does, so these are what each choice would show. With one namespace
1271
+ // in view already, the others listed are only its peers: picking one would
1272
+ // switch, not narrow, so the panel points at focus and the sidebar instead.
1273
+ const namespaceChoices = useMemo(() => {
1274
+ if (!onSetNamespaces || namespaceLocked || focus || namespaces.length === 1) return null
1275
+ const choices = namespaceSummaries(finalFlows)
1276
+ return choices.length > 0 ? choices : null
1277
+ }, [onSetNamespaces, namespaceLocked, focus, finalFlows, namespaces])
1278
+
1279
+ const focusPartial = !!focus?.namespace && namespaces.length > 0 && !namespaces.includes(focus.namespace)
1280
+
1281
+ const resetFilters = useCallback(() => {
1282
+ setHideSystem(false)
1283
+ setHideExternal(false)
1284
+ chooseMinConnections(0)
1285
+ setHiddenNamespaces(new Set())
1286
+ setAddonMode('show')
1287
+ setL7Protocol('all')
1288
+ setL7Methods(new Set())
1289
+ setL7StatusRanges(new Set())
1290
+ setL7Verdicts(new Set())
1291
+ setDnsPattern('')
1292
+ }, [chooseMinConnections])
1293
+
1023
1294
  // Determine wizard state based on sources detection
1024
1295
  useEffect(() => {
1025
1296
  if (sourcesLoading) {
@@ -1046,24 +1317,51 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1046
1317
  // the auto-connect effect, and re-arming it would re-fire auto-connect on
1047
1318
  // every failed attempt (unbounded loop). Retries come from the explicit
1048
1319
  // Retry button, which calls handleConnect directly.
1049
- const handleConnect = useCallback(() => {
1320
+ //
1321
+ // The page opens on a detection that may be a couple of minutes old, so the
1322
+ // source it names may since have gone. A retry detects again first, which
1323
+ // also picks another available source, and the first failed connect gets
1324
+ // one such retry on its own.
1325
+ const handleConnect = useCallback((redetect: boolean) => {
1050
1326
  setIsConnecting(true)
1051
1327
  setConnectionError(null)
1052
1328
  queryClient.removeQueries({ queryKey: ['traffic-flows'] })
1053
1329
 
1054
- connectMutation.mutate(undefined, {
1055
- onSuccess: (data) => {
1056
- setIsConnecting(false)
1057
- if (!data.connected && data.error) {
1058
- setConnectionError(data.error)
1330
+ const attempt = (fresh: boolean) => {
1331
+ const fail = (message: string) => {
1332
+ if (!fresh) {
1333
+ attempt(true)
1334
+ return
1059
1335
  }
1060
- },
1061
- onError: (error) => {
1062
1336
  setIsConnecting(false)
1063
- setConnectionError(error.message)
1064
- },
1065
- })
1066
- }, [connectMutation, queryClient])
1337
+ setConnectionError(message)
1338
+ }
1339
+ const detected = fresh ? detectSourcesAgain() : Promise.resolve(undefined)
1340
+ detected.then(
1341
+ () => connectMutation.mutate(undefined, {
1342
+ onSuccess: (data) => {
1343
+ if (!data.connected && data.error) {
1344
+ fail(data.error)
1345
+ return
1346
+ }
1347
+ setIsConnecting(false)
1348
+ },
1349
+ onError: (error) => fail(error.message),
1350
+ }),
1351
+ (error: Error) => {
1352
+ setIsConnecting(false)
1353
+ setConnectionError(error.message)
1354
+ },
1355
+ )
1356
+ }
1357
+ attempt(redetect)
1358
+ }, [connectMutation, queryClient, detectSourcesAgain])
1359
+
1360
+ // The wizard polls without waiting on the answer; a failed detection shows
1361
+ // through the sources query's own state.
1362
+ const detectForWizard = useCallback(() => {
1363
+ detectSourcesAgain().catch(() => {})
1364
+ }, [detectSourcesAgain])
1067
1365
 
1068
1366
  // Auto-connect once when a source is first detected. Strictly one-shot per
1069
1367
  // mount / cluster (the ref resets on cluster change); failures surface a
@@ -1071,7 +1369,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1071
1369
  useEffect(() => {
1072
1370
  if (wizardState === 'ready' && !hasAutoConnectedRef.current && !isConnecting) {
1073
1371
  hasAutoConnectedRef.current = true
1074
- handleConnect()
1372
+ handleConnect(false)
1075
1373
  }
1076
1374
  }, [wizardState, isConnecting, handleConnect])
1077
1375
 
@@ -1083,13 +1381,20 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1083
1381
  setState={setWizardState}
1084
1382
  sourcesData={sourcesData}
1085
1383
  sourcesLoading={sourcesLoading}
1086
- onRefetch={refetchSources}
1384
+ onRefetch={detectForWizard}
1087
1385
  />
1088
1386
  )
1089
1387
  }
1090
1388
 
1091
1389
  return (
1092
- <TrafficFlowListProvider flows={listFlows} responsesCallerOriented={flowsData?.l7ResponsesCallerOriented === true} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
1390
+ <TrafficFlowListProvider
1391
+ flows={listFlows}
1392
+ responsesCallerOriented={(useRecords ? records.data?.l7ResponsesCallerOriented : flowsData?.l7ResponsesCallerOriented) === true}
1393
+ graphSelection={graphSelection}
1394
+ clearSelection={clearGraphSelection}
1395
+ loading={useRecords && records.isLoading}
1396
+ note={listNote}
1397
+ >
1093
1398
  <div className="flex h-full w-full">
1094
1399
  {/* Sidebar */}
1095
1400
  <TrafficFilterSidebar
@@ -1103,6 +1408,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1103
1408
  setShowNamespaceGroups={setShowNamespaceGroups}
1104
1409
  collapseInternet={collapseInternet}
1105
1410
  setCollapseInternet={setCollapseInternet}
1411
+ groupByWorkload={groupByWorkload}
1412
+ setGroupByWorkload={setGroupByWorkload}
1106
1413
  addonMode={addonMode}
1107
1414
  setAddonMode={setAddonMode}
1108
1415
  aggregateExternal={aggregateExternal}
@@ -1128,12 +1435,13 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1128
1435
  dnsPattern={dnsPattern}
1129
1436
  setDnsPattern={setDnsPattern}
1130
1437
  namespaces={namespacesWithCounts}
1131
- hiddenNamespaces={hiddenNamespaces}
1438
+ hiddenNamespaces={chosenHiddenNamespaces}
1132
1439
  onToggleNamespace={toggleNamespace}
1440
+ namespacesPausedFor={focus?.name}
1133
1441
  />
1134
1442
 
1135
1443
  {/* Main content area */}
1136
- <div className="flex-1 relative min-w-0">
1444
+ <div ref={graphPaneRef} className="flex-1 relative min-w-0">
1137
1445
  {/* Floating controls — overlaid on graph like topology view */}
1138
1446
  {(() => {
1139
1447
  const availableSources = sourcesData?.detected.filter(s => s.status === 'available') || []
@@ -1164,8 +1472,42 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1164
1472
 
1165
1473
  return (
1166
1474
  <>
1167
- {/* Top-left: source status pill */}
1475
+ {/* Top-left: source status pill, focus */}
1168
1476
  <div className="absolute top-3 left-3 z-10 flex items-center gap-2">
1477
+ {focus ? (
1478
+ <div className="flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-skyhook-500/40 text-[11px]">
1479
+ <Crosshair className="h-3 w-3 text-skyhook-500" />
1480
+ <span className="text-theme-text-secondary">Focused on</span>
1481
+ <span className="font-medium text-theme-text-primary">{focus.name}</span>
1482
+ {focus.namespace && <span className="text-theme-text-tertiary">({focus.namespace})</span>}
1483
+ {focusPartial && (
1484
+ // Narrowing fetches the edges with an end in the chosen
1485
+ // namespaces, so a focus outside them shows only those.
1486
+ <>
1487
+ <span className={SEVERITY_TEXT.warning}>· only its traffic with {namespaces.join(', ')}</span>
1488
+ {onSetNamespaces && !namespaceLocked && (
1489
+ <button type="button" onClick={() => onSetNamespaces([focus.namespace!])} className="text-blue-400 hover:text-blue-300 font-medium">
1490
+ Show all its traffic
1491
+ </button>
1492
+ )}
1493
+ </>
1494
+ )}
1495
+ <Tooltip content="Leave focus">
1496
+ <button type="button" onClick={() => setFocus(null)} aria-label="Leave focus" className="p-0.5 rounded hover:bg-theme-hover text-theme-text-secondary">
1497
+ <X className="h-3 w-3" />
1498
+ </button>
1499
+ </Tooltip>
1500
+ </div>
1501
+ ) : null}
1502
+ {/* The too-large panel carries its own search button. */}
1503
+ {finalFlows.length > 0 && !tooLargeToDraw && !isConnecting && (
1504
+ <TrafficFocusSearch
1505
+ endpoints={focusableEndpoints}
1506
+ onFocus={setFocus}
1507
+ isRateBased={isRateBased}
1508
+ overlayContainer={graphPaneRef.current}
1509
+ />
1510
+ )}
1169
1511
  {activeSource && (
1170
1512
  <div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[11px]">
1171
1513
  {isConnecting ? (
@@ -1177,7 +1519,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1177
1519
  <>
1178
1520
  <span className="w-2 h-2 rounded-full bg-yellow-500" />
1179
1521
  <span className="text-theme-text-secondary">{activeSource.name}</span>
1180
- <button onClick={handleConnect} className="text-yellow-500 hover:text-yellow-400 font-medium">retry</button>
1522
+ <button onClick={() => handleConnect(true)} className="text-yellow-500 hover:text-yellow-400 font-medium">retry</button>
1181
1523
  </>
1182
1524
  ) : (
1183
1525
  <>
@@ -1220,15 +1562,17 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1220
1562
  {coverage && (
1221
1563
  // Not a warning: a busy cluster reaches the limit on every
1222
1564
  // fetch. It says which part of the window the map shows.
1223
- <Tooltip content={`Radar reads at most ${(flowsData?.nodeFlowLimit ?? 0).toLocaleString()} of the newest flows from each node. At least one node reached that limit before the start of the ${timeRange} window, so its earlier traffic is not included. Other nodes may still show older flows.`}>
1565
+ <Tooltip content={coverageExplanation(flowsData, timeRange, hideSystem)}>
1224
1566
  <div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-secondary tabular-nums">
1225
1567
  <Clock className="w-3 h-3" /> {coverage} of {timeRange}
1226
1568
  </div>
1227
1569
  </Tooltip>
1228
1570
  )}
1229
- <div className="flex items-center px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary tabular-nums">
1230
- {flowStats.shown}/{flowStats.total}
1231
- </div>
1571
+ <Tooltip content={flowStatsExplanation(flowStats)}>
1572
+ <div className="flex items-center px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary tabular-nums">
1573
+ {flowStats.shown}/{flowStats.total}
1574
+ </div>
1575
+ </Tooltip>
1232
1576
  {/* Flows are a REST snapshot (no poll, no stream), so this is
1233
1577
  an honest "Updated N ago" + manual refresh — not "live". */}
1234
1578
  <div className="flex items-center rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border px-1.5 py-0.5">
@@ -1236,7 +1580,10 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1236
1580
  mode="snapshot"
1237
1581
  dataUpdatedAt={flowsUpdatedAt}
1238
1582
  isFetching={flowsFetching}
1239
- onRefresh={() => refetchFlowsRaw()}
1583
+ onRefresh={() => {
1584
+ refetchFlowsRaw()
1585
+ if (recordsEligible) records.refetch()
1586
+ }}
1240
1587
  connectionState={connection.state}
1241
1588
  />
1242
1589
  </div>
@@ -1252,7 +1599,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1252
1599
  />
1253
1600
  ) : finalFlows.length > 0 ? (
1254
1601
  <>
1255
- {flowsData?.warning && (
1602
+ {flowsWarning && !tooLargeToDraw && (
1256
1603
  // Sits below the two chip rows (both top-3) rather than beside
1257
1604
  // them: centred at that height it would cover the flow count and
1258
1605
  // the refresh control at common widths. role/aria-live because it
@@ -1262,33 +1609,60 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1262
1609
  aria-live="polite"
1263
1610
  className="absolute top-14 left-1/2 z-10 w-[min(40rem,calc(100%-1.5rem))] -translate-x-1/2"
1264
1611
  >
1265
- <AlertBanner
1266
- variant="warning"
1267
- // The title follows the kind. A partial warning is about values
1268
- // on edges that are shown — a port reported as 0, UDP as TCP, a
1269
- // 5xx rate that failed to load — so it says "unreliable", not
1270
- // "incomplete", which would send the reader looking for missing
1271
- // workloads. An incomplete or transient one beside flows is
1272
- // exactly that: a stream cut short, events lost, a node the
1273
- // relay could not reach, a TCP query that failed — edges may
1274
- // be missing from what is drawn.
1275
- title={warningIsPermanent ? 'Some values on this map are unreliable' : 'Some traffic may be missing from this map'}
1276
- message={flowsData.warning}
1277
- />
1612
+ {flowsWarning}
1278
1613
  </div>
1279
1614
  )}
1280
- <TrafficGraph
1281
- flows={finalFlows}
1282
- hotPathThreshold={hotPathThreshold}
1283
- showNamespaceGroups={showNamespaceGroups}
1284
- serviceCategories={serviceCategories}
1285
- addonMode={addonMode}
1286
- trafficSource={sourcesData?.active || ''}
1287
- onSelectionChange={setGraphSelection}
1288
- />
1615
+ {tooLargeToDraw ? (
1616
+ <TrafficGraphTooLarge
1617
+ graph={drawnGraph}
1618
+ focusName={focus?.name}
1619
+ canFilterNamespaces={!focus && namespacesWithCounts.length > 1}
1620
+ onDrawAnyway={drawnGraph.score <= GRAPH_DRAW_CEILING ? () => setDrawnViewKey(viewKey) : undefined}
1621
+ namespaces={namespaceChoices}
1622
+ onPickNamespace={ns => onSetNamespaces?.([ns])}
1623
+ endpoints={focusableEndpoints}
1624
+ onFocus={setFocus}
1625
+ isRateBased={isRateBased}
1626
+ overlayContainer={graphPaneRef.current}
1627
+ flows={focusedFlows}
1628
+ selection={graphSelection}
1629
+ onSelect={setGraphSelection}
1630
+ focusedId={focusKey}
1631
+ notice={flowsWarning && <div role="status" aria-live="polite">{flowsWarning}</div>}
1632
+ />
1633
+ ) : (
1634
+ <TrafficGraph
1635
+ flows={finalFlows}
1636
+ hotPathThreshold={hotPathThreshold}
1637
+ showNamespaceGroups={showNamespaceGroups}
1638
+ serviceCategories={serviceCategories}
1639
+ addonMode={groupAddons ? 'group' : addonMode === 'group' ? 'show' : addonMode}
1640
+ trafficSource={sourcesData?.active || ''}
1641
+ selection={graphSelection}
1642
+ onSelectionChange={setGraphSelection}
1643
+ onFocus={setFocus}
1644
+ focusedId={focusKey}
1645
+ />
1646
+ )}
1289
1647
  </>
1648
+ ) : focus && flowsData && !connectionError ? (
1649
+ <div className="absolute inset-0 grid grid-rows-[1fr_auto_2fr] justify-items-center [&>*]:row-start-2 px-4">
1650
+ <FocusNotFound
1651
+ focus={focus}
1652
+ hiddenByFilters={(flowsData?.aggregated ?? []).some(f => touchesFocus(f.source, focus) || touchesFocus(f.destination, focus))}
1653
+ systemHidden={hideSystem && !!focus.namespace && SYSTEM_NAMESPACES.has(focus.namespace)}
1654
+ hideSystem={hideSystem}
1655
+ outsideNamespaces={namespaces.length > 0 && !!focus.namespace && !namespaces.includes(focus.namespace)}
1656
+ window={coverage ? `${coverage} of ${timeRange}` : `last ${timeRange}`}
1657
+ onShowAll={resetFilters}
1658
+ onShowSystem={() => setHideSystem(false)}
1659
+ onSwitchNamespace={onSetNamespaces && !namespaceLocked && focus.namespace ? () => onSetNamespaces([focus.namespace!]) : undefined}
1660
+ warning={flowsData.warning}
1661
+ onLeave={() => setFocus(null)}
1662
+ />
1663
+ </div>
1290
1664
  ) : connectionError ? (
1291
- <div className="absolute inset-0 flex items-center justify-center">
1665
+ <div className="absolute inset-0 grid grid-rows-[1fr_auto_2fr] justify-items-center [&>*]:row-start-2">
1292
1666
  <div className="text-center space-y-3">
1293
1667
  <Plug className="h-12 w-12 text-yellow-500 mx-auto" />
1294
1668
  <p className="text-theme-text-secondary">Connection failed</p>
@@ -1296,7 +1670,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1296
1670
  {connectionError}
1297
1671
  </p>
1298
1672
  <button
1299
- onClick={handleConnect}
1673
+ onClick={() => handleConnect(true)}
1300
1674
  className="px-3 py-1.5 text-sm btn-brand rounded"
1301
1675
  >
1302
1676
  Retry Connection
@@ -1304,7 +1678,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1304
1678
  </div>
1305
1679
  </div>
1306
1680
  ) : (
1307
- <div className="absolute inset-0 flex items-center justify-center px-4">
1681
+ <div className="absolute inset-0 grid grid-rows-[1fr_auto_2fr] justify-items-center [&>*]:row-start-2 px-4">
1308
1682
  {flowStats.total > 0 && flowStats.shown === 0 ? (
1309
1683
  <EmptyState
1310
1684
  tone="filtered"
@@ -1319,18 +1693,7 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1319
1693
  // the user pressing a button that promised everything back and
1320
1694
  // changed nothing whenever the cause was a namespace, an addon
1321
1695
  // mode or an L7 choice.
1322
- onClick={() => {
1323
- setHideSystem(false)
1324
- setHideExternal(false)
1325
- chooseMinConnections(0)
1326
- setHiddenNamespaces(new Set())
1327
- setAddonMode('show')
1328
- setL7Protocol('all')
1329
- setL7Methods(new Set())
1330
- setL7StatusRanges(new Set())
1331
- setL7Verdicts(new Set())
1332
- setDnsPattern('')
1333
- }}
1696
+ onClick={resetFilters}
1334
1697
  className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary hover:bg-theme-hover transition-colors"
1335
1698
  >
1336
1699
  Show all
@@ -1374,3 +1737,73 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1374
1737
  </TrafficFlowListProvider>
1375
1738
  )
1376
1739
  }
1740
+
1741
+ /**
1742
+ * A focus with nothing to show. Says which of the reasons it is, since only
1743
+ * one of them means the workload is quiet: Radar may have hidden its traffic,
1744
+ * or never fetched it.
1745
+ */
1746
+ function FocusNotFound({ focus, hiddenByFilters, systemHidden, hideSystem, outsideNamespaces, window, onShowAll, onShowSystem, onSwitchNamespace, warning, onLeave }: {
1747
+ focus: TrafficFocus
1748
+ /** Hide System is on: traffic with system namespaces was never fetched. */
1749
+ hideSystem: boolean
1750
+ /** The fetch's own warning: with it, absence is not proof of quiet. */
1751
+ warning?: string
1752
+ hiddenByFilters: boolean
1753
+ systemHidden: boolean
1754
+ outsideNamespaces: boolean
1755
+ window: string
1756
+ onShowAll: () => void
1757
+ onShowSystem: () => void
1758
+ onSwitchNamespace?: () => void
1759
+ onLeave: () => void
1760
+ }) {
1761
+ const name = focus.namespace ? `${focus.name} (${focus.namespace})` : focus.name
1762
+ let body: string
1763
+ let action: { label: string; onClick: () => void } | undefined
1764
+ if (hiddenByFilters) {
1765
+ body = 'Its traffic is hidden by the current filters.'
1766
+ action = { label: 'Show all traffic', onClick: onShowAll }
1767
+ } else if (systemHidden) {
1768
+ body = `${focus.namespace} is a system namespace, and Hide System keeps its traffic from being fetched.`
1769
+ action = { label: 'Show system traffic', onClick: onShowSystem }
1770
+ } else if (outsideNamespaces) {
1771
+ body = `${focus.namespace} is outside the namespaces in view, so only its traffic with them was fetched, and there was none.`
1772
+ if (onSwitchNamespace) action = { label: `Switch to ${focus.namespace}`, onClick: onSwitchNamespace }
1773
+ } else if (warning) {
1774
+ body = `Radar found none in the ${window}, but some traffic may be missing.`
1775
+ } else if (hideSystem) {
1776
+ body = `Radar saw no traffic to or from it in the ${window}, other than any with system namespaces, which Hide System keeps from being fetched.`
1777
+ action = { label: 'Show system traffic', onClick: onShowSystem }
1778
+ } else {
1779
+ body = `Radar saw no traffic to or from it in the ${window}.`
1780
+ }
1781
+ // Whatever else applies, a fetch that reported trouble is not proof of quiet.
1782
+ if (warning) body += ` ${warning}`
1783
+ return (
1784
+ <EmptyState
1785
+ tone="neutral"
1786
+ variant="card"
1787
+ icon={Crosshair}
1788
+ headline={`No traffic for ${name}`}
1789
+ body={body}
1790
+ action={
1791
+ <div className="flex flex-wrap items-center justify-center gap-2">
1792
+ {action && (
1793
+ <button type="button" onClick={action.onClick} className="btn-brand-muted text-xs px-2.5 py-1 rounded-md">
1794
+ {action.label}
1795
+ </button>
1796
+ )}
1797
+ <button
1798
+ type="button"
1799
+ onClick={onLeave}
1800
+ className="text-xs px-2.5 py-1 rounded-md border border-theme-border text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover"
1801
+ >
1802
+ Leave focus
1803
+ </button>
1804
+ </div>
1805
+ }
1806
+ className="max-w-md"
1807
+ />
1808
+ )
1809
+ }