@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,4 +1,6 @@
1
1
  import type { AggregatedFlow, TrafficFlow } from '../../types'
2
+ import type { TrafficEndpointPair, TrafficEndpointRef, TrafficMatch } from '../../api/traffic'
3
+ import type { TrafficGraphSelection } from './TrafficGraph'
2
4
 
3
5
  /**
4
6
  * Does a flow fall into one of the selected HTTP status ranges?
@@ -136,6 +138,19 @@ export function mergeFlowVolume(into: AggregatedFlow, flow: AggregatedFlow): voi
136
138
  if (flow.errorCount) into.errorCount = (into.errorCount || 0) + flow.errorCount
137
139
  if (flow.requestRate) into.requestRate = (into.requestRate || 0) + flow.requestRate
138
140
  if (flow.errorRate) into.errorRate = (into.errorRate || 0) + flow.errorRate
141
+ // New objects rather than in-place sums: a merged edge starts as a shallow
142
+ // copy of the server's, so its maps are still the server's.
143
+ into.httpStatusCounts = sumCounts(into.httpStatusCounts, flow.httpStatusCounts)
144
+ into.verdictCounts = sumCounts(into.verdictCounts, flow.verdictCounts)
145
+ into.dropReasons = sumCounts(into.dropReasons, flow.dropReasons)
146
+ }
147
+
148
+ function sumCounts(a: Record<string, number> | undefined, b: Record<string, number> | undefined): Record<string, number> | undefined {
149
+ if (!b) return a
150
+ if (!a) return { ...b }
151
+ const sum = { ...a }
152
+ for (const [k, v] of Object.entries(b)) sum[k] = (sum[k] ?? 0) + v
153
+ return sum
139
154
  }
140
155
 
141
156
  /**
@@ -287,3 +302,396 @@ export function dedupeHTTPPairs(flows: TrafficFlow[], callerOriented: boolean):
287
302
  }
288
303
  return flows.filter(f => !(isHTTP(f, 'REQUEST') && answered.has(call(f.source.name, f.destination.name, f))))
289
304
  }
305
+
306
+ /**
307
+ * A graph edge, carrying the server edges merged into it. The graph renames and
308
+ * merges endpoints (external services, the Internet node, the addon group), so
309
+ * these are what a selection is traced back through to ask the server for its
310
+ * records.
311
+ */
312
+ export type GraphFlow = AggregatedFlow & { rawPairs?: TrafficEndpointPair[] }
313
+
314
+ /**
315
+ * The port an edge merged from several shows: its lowest real one, whatever
316
+ * order the parts arrived in (0 means none was reported, so it shows only
317
+ * when no part has a port). The Internet node groups by it and selections
318
+ * find the edge by it, so it must not change from one refresh to the next.
319
+ */
320
+ export function mergedEdgePort(a: number, b: number): number {
321
+ if (a === 0) return b
322
+ if (b === 0) return a
323
+ return Math.min(a, b)
324
+ }
325
+
326
+ /**
327
+ * Whether an edge carries a port: its own, or one of the edges merged into it.
328
+ * A merged edge shows one of its ports, so a selection made on another must
329
+ * still find it.
330
+ */
331
+ export function edgeCarriesPort(flow: GraphFlow, port: number | undefined): boolean {
332
+ return port === undefined || flow.port === port || !!flow.rawPairs?.some(p => p.port === port)
333
+ }
334
+
335
+ /**
336
+ * Of the edges between a selection's endpoints, the ones it means: those on
337
+ * its exact port when any are, else those with the port merged in. Two edges
338
+ * can join the same endpoints (the Internet node keeps ports apart), and the
339
+ * one shown on a port must win over one that merely carries it.
340
+ */
341
+ export function edgesOnPort<T>(candidates: T[], flowOf: (c: T) => GraphFlow | undefined, port: number | undefined): T[] {
342
+ if (port === undefined) return candidates
343
+ const exact = candidates.filter(c => flowOf(c)?.port === port)
344
+ if (exact.length > 0) return exact
345
+ return candidates.filter(c => {
346
+ const flow = flowOf(c)
347
+ return !!flow && edgeCarriesPort(flow, port)
348
+ })
349
+ }
350
+
351
+ export function endpointPair(flow: AggregatedFlow): TrafficEndpointPair {
352
+ const ref = (e: AggregatedFlow['source']) => ({
353
+ namespace: e.namespace || undefined,
354
+ name: e.name,
355
+ kind: e.kind,
356
+ ...(e.workloadKind && { workloadKind: e.workloadKind }),
357
+ })
358
+ return {
359
+ source: ref(flow.source),
360
+ destination: ref(flow.destination),
361
+ port: flow.port,
362
+ ...(flow.directionUnknown && { directionUnknown: true }),
363
+ }
364
+ }
365
+
366
+ /** Adds a merged edge's pairs to the edge it was merged into. The target owns
367
+ * its array, so merging appends in place: copying it on every merge is
368
+ * quadratic, and an ingress with tens of thousands of distinct clients
369
+ * collapses into a single Internet edge. */
370
+ export function mergeRawPairs(into: GraphFlow, from: GraphFlow): void {
371
+ const target = into.rawPairs ?? (into.rawPairs = [])
372
+ for (const pair of from.rawPairs ?? []) target.push(pair)
373
+ }
374
+
375
+ // The renderer draws the addon group's virtual endpoints as one group node.
376
+ function selectableId(e: { namespace?: string; name: string; kind?: string }): string {
377
+ if (e.kind === 'AddonGroupTarget' || e.kind === 'AddonGroupSource') return 'addon-group'
378
+ return graphEndpointId(e)
379
+ }
380
+
381
+ /**
382
+ * A record's endpoint as the graph names it: a pod with a known workload is
383
+ * drawn as that workload. Mirrors the server's graphEndpoint (pkg/traffic),
384
+ * which builds the aggregation the graph is drawn from — the two have to agree
385
+ * for a record to be found under its node.
386
+ */
387
+ export function graphEndpoint(e: TrafficFlow['source']): { namespace?: string; name: string } {
388
+ if (e.kind === 'Pod' && e.workload && e.namespace && e.workload !== e.name) return { namespace: e.namespace, name: e.workload }
389
+ return e
390
+ }
391
+
392
+ export function graphEndpointId(e: { namespace?: string; name: string }): string {
393
+ return e.namespace ? `${e.namespace}/${e.name}` : e.name
394
+ }
395
+
396
+ export function pairKey(source: { namespace?: string; name: string }, destination: { namespace?: string; name: string }, port: number, directionUnknown?: boolean): string {
397
+ return `${graphEndpointId(source)}->${graphEndpointId(destination)}:${port}${directionUnknown ? ':u' : ''}`
398
+ }
399
+
400
+ /**
401
+ * Whether a record belongs to the selected edges, in the grouping the graph
402
+ * draws (`drawn`). Without `responsesCallerOriented` the server sends an HTTP
403
+ * response as it was observed, server to client on the client's port, so a
404
+ * response is then also matched to an edge running the other way. Its port says nothing about which of the
405
+ * caller's edges it answers, so it is matched to each of them; other traffic
406
+ * between the same endpoints is not, being on edges of its own.
407
+ */
408
+ export function selectedEdgeMatcher(
409
+ pairs: TrafficEndpointPair[] | null,
410
+ responsesCallerOriented: boolean,
411
+ drawn: (e: TrafficFlow['source']) => { namespace?: string; name: string },
412
+ ): (f: TrafficFlow) => boolean {
413
+ if (!pairs) return () => true
414
+ const keys = new Set(pairs.map(p => pairKey(p.source, p.destination, p.port, p.directionUnknown)))
415
+ const callers = responsesCallerOriented ? null : new Set(pairs.map(p => `${graphEndpointId(p.source)}->${graphEndpointId(p.destination)}`))
416
+ return f => {
417
+ const src = drawn(f.source)
418
+ const dst = drawn(f.destination)
419
+ if (keys.has(pairKey(src, dst, f.port, f.directionUnknown))) return true
420
+ // The response travels callee to caller: reversed, it is the edge's.
421
+ return !!callers && f.l7Protocol === 'HTTP' && f.l7Type === 'RESPONSE' &&
422
+ callers.has(`${graphEndpointId(dst)}->${graphEndpointId(src)}`)
423
+ }
424
+ }
425
+
426
+ /**
427
+ * The server edges behind the selected node or edge, deduplicated and in a
428
+ * stable order — the server returns its aggregation in no particular order,
429
+ * and the result keys the records query. Null when the selection traces back
430
+ * to none. `inAddonGroup` says which endpoints the addon group node holds
431
+ * while addons are grouped, so selecting the group selects their traffic too.
432
+ */
433
+ export function selectionRawPairs(
434
+ flows: GraphFlow[],
435
+ selection: TrafficGraphSelection | null,
436
+ inAddonGroup?: (e: { namespace?: string; name: string }) => boolean,
437
+ ): TrafficEndpointPair[] | null {
438
+ if (!selection) return null
439
+ const pairs = new Map<string, TrafficEndpointPair>()
440
+ const groupId = (e: GraphFlow['source']) =>
441
+ inAddonGroup && selection.nodeId === 'addon-group' && inAddonGroup(e) ? 'addon-group' : selectableId(e)
442
+ const touching = flows.filter(flow => {
443
+ const sourceId = groupId(flow.source)
444
+ const destId = groupId(flow.destination)
445
+ return selection.type === 'node'
446
+ ? sourceId === selection.nodeId || destId === selection.nodeId
447
+ : sourceId === selection.sourceId && destId === selection.destId &&
448
+ !!flow.directionUnknown === !!selection.directionUnknown
449
+ })
450
+ // An edge stands for its port; an edge merged from several ports shows one
451
+ // of them and carries the rest (edgesOnPort).
452
+ const selected = selection.type === 'node' ? touching : edgesOnPort(touching, f => f, selection.port)
453
+ for (const flow of selected) {
454
+ for (const pair of flow.rawPairs ?? []) {
455
+ pairs.set(pairKey(pair.source, pair.destination, pair.port, pair.directionUnknown), pair)
456
+ }
457
+ }
458
+ if (pairs.size === 0) return null
459
+ return Array.from(pairs.entries()).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, pair]) => pair)
460
+ }
461
+
462
+ /**
463
+ * What to ask the server for, for the records behind a selection traced to
464
+ * `pairs` (selectionRawPairs). A node that is one server endpoint is sent as
465
+ * that endpoint: a busy node has too many edges to name each within a URL.
466
+ * The answer then holds its hidden edges too, so the caller keeps the pairs
467
+ * to narrow it. Anything else — an edge, a node merged from several
468
+ * endpoints — is sent as its edges.
469
+ */
470
+ export function selectionMatch(
471
+ pairs: TrafficEndpointPair[] | null,
472
+ selection: TrafficGraphSelection | null,
473
+ ): TrafficMatch | null {
474
+ if (!pairs) return null
475
+ if (selection?.type !== 'node') return { pairs }
476
+ const refKey = (r: TrafficEndpointRef) => `${graphEndpointId(r)}|${r.kind ?? ''}`
477
+ // Only pods can be named to the source; any other endpoint sent alone would
478
+ // fetch everything, while its edges still name the pods at their far ends.
479
+ const nameable = (r: TrafficEndpointRef) => r.kind === 'Pod' || r.kind === 'Workload'
480
+ let only: TrafficEndpointRef | undefined
481
+ for (const p of pairs) {
482
+ const side = graphEndpointId(p.source) === selection.nodeId ? p.source
483
+ : graphEndpointId(p.destination) === selection.nodeId ? p.destination
484
+ : undefined
485
+ if (!side || !nameable(side) || (only && refKey(side) !== refKey(only))) return { pairs }
486
+ only = side
487
+ }
488
+ return only ? { endpoints: [only] } : { pairs }
489
+ }
490
+
491
+ /**
492
+ * How costly the graph is to draw. The layout runs on the main thread and its
493
+ * cost grows faster than linearly, edges more than nodes, so edges count
494
+ * double, as Weave Scope weighs them. Laying out whole namespaces of a dense
495
+ * service graph took 0.55s at a score of 493, 2s at 728, 4.5s at 1,433, 13s
496
+ * at 1,883 and 32s at 2,329; larger graphs overflowed ELK's stack.
497
+ */
498
+ export function graphSize(flows: AggregatedFlow[]): { nodes: number; edges: number; score: number } {
499
+ const nodes = new Set<string>()
500
+ for (const flow of flows) {
501
+ nodes.add(graphEndpointId(flow.source))
502
+ nodes.add(graphEndpointId(flow.destination))
503
+ }
504
+ return { nodes: nodes.size, edges: flows.length, score: nodes.size + 2 * flows.length }
505
+ }
506
+
507
+ /** Above this the graph is not drawn until the view is narrowed (about 1.5s of layout). */
508
+ export const GRAPH_DRAW_BUDGET = 600
509
+ /** Above this it is not drawn at all (about 5s of layout, freezing the tab). */
510
+ export const GRAPH_DRAW_CEILING = 1500
511
+
512
+ /** 5xx responses on an edge: counted where the source observes responses,
513
+ * a per-second rate where it measures one. */
514
+ export function flowErrors(flow: AggregatedFlow): number {
515
+ return flow.httpStatusCounts?.['5xx'] || errorRateOf(flow)
516
+ }
517
+
518
+ export function flowDrops(flow: AggregatedFlow): number {
519
+ return flow.verdictCounts?.dropped ?? 0
520
+ }
521
+
522
+ /** Whether this edge carries HTTP responses, so its 5xx count is a measured
523
+ * zero rather than unknown; plain TCP has none. Zero rates and counts are left
524
+ * out of the response, so a healthy HTTP edge shows only by its requests or
525
+ * protocol. */
526
+ export function hasStatusData(flow: AggregatedFlow): boolean {
527
+ return (!!flow.httpStatusCounts && Object.keys(flow.httpStatusCounts).length > 0) ||
528
+ !!flow.requestRate || !!flow.requestCount || !!flow.errorRate || !!flow.errorCount ||
529
+ flow.l7Protocol === 'HTTP' || flow.l7Protocol === 'gRPC'
530
+ }
531
+
532
+ /**
533
+ * A workload (or any endpoint) the view is narrowed to: it and everything it
534
+ * talks to. Identified by namespace and name, the way the graph names nodes,
535
+ * so it means the same thing whether pods are grouped or not — ungrouped, it
536
+ * is the pods the workload owns.
537
+ */
538
+ export interface TrafficFocus {
539
+ namespace?: string
540
+ name: string
541
+ }
542
+
543
+ /** The focus as the URL carries it: "ns/name", or "/name" for an endpoint
544
+ * with no namespace, whose name can itself hold a slash (a CIDR). */
545
+ export function focusParam(focus: TrafficFocus): string {
546
+ return `${focus.namespace ?? ''}/${focus.name}`
547
+ }
548
+
549
+ export function parseFocus(value: string | null): TrafficFocus | null {
550
+ if (!value) return null
551
+ const slash = value.indexOf('/')
552
+ if (slash < 0) return null
553
+ const namespace = value.slice(0, slash)
554
+ const name = value.slice(slash + 1)
555
+ if (!name) return null
556
+ return namespace ? { namespace, name } : { name }
557
+ }
558
+
559
+ /** The graph's id for the focused node. */
560
+ export function focusId(focus: TrafficFocus): string {
561
+ return graphEndpointId(focus)
562
+ }
563
+
564
+ export function touchesFocus(e: AggregatedFlow['source'], focus: TrafficFocus): boolean {
565
+ if ((e.namespace || undefined) !== focus.namespace) return false
566
+ return e.name === focus.name || (e.kind === 'Pod' && e.workload === focus.name)
567
+ }
568
+
569
+ /** The edges with the focus at either end. */
570
+ export function focusNeighborhood<T extends AggregatedFlow>(flows: T[], focus: TrafficFocus): T[] {
571
+ return flows.filter(f => touchesFocus(f.source, focus) || touchesFocus(f.destination, focus))
572
+ }
573
+
574
+ export interface EndpointSummary {
575
+ id: string
576
+ name: string
577
+ namespace?: string
578
+ kind: string
579
+ workloadKind?: string
580
+ volume: number
581
+ errors: number
582
+ drops: number
583
+ }
584
+
585
+ /** Every endpoint drawn from these flows, with the traffic on its edges. The
586
+ * options a focus can be chosen from, so each one has something to show.
587
+ * Pods drawn ungrouped also offer their workload, which a focus covers whole. */
588
+ export function endpointSummaries(flows: AggregatedFlow[]): EndpointSummary[] {
589
+ const byId = new Map<string, EndpointSummary>()
590
+ const add = (e: { namespace?: string; name: string; kind: string; workloadKind?: string }, flow: AggregatedFlow, seen: Set<string>) => {
591
+ const id = graphEndpointId(e)
592
+ if (seen.has(id)) return
593
+ seen.add(id)
594
+ let s = byId.get(id)
595
+ if (!s) {
596
+ s = { id, name: e.name, namespace: e.namespace || undefined, kind: e.kind, workloadKind: e.workloadKind, volume: 0, errors: 0, drops: 0 }
597
+ byId.set(id, s)
598
+ }
599
+ s.volume += flow.connections
600
+ s.errors += flowErrors(flow)
601
+ s.drops += flowDrops(flow)
602
+ }
603
+ for (const flow of flows) {
604
+ const seen = new Set<string>()
605
+ for (const e of [flow.source, flow.destination]) {
606
+ add(e, flow, seen)
607
+ if (e.kind === 'Pod' && e.namespace && e.workload && e.workload !== e.name) {
608
+ add({ namespace: e.namespace, name: e.workload, kind: 'Workload', workloadKind: e.workloadKind }, flow, seen)
609
+ }
610
+ }
611
+ }
612
+ return Array.from(byId.values())
613
+ }
614
+
615
+ /** Where the problems are first, then the busiest. */
616
+ export function byProblemsThenVolume(a: { errors: number; drops: number; volume: number }, b: { errors: number; drops: number; volume: number }): number {
617
+ const pa = a.errors > 0 || a.drops > 0 ? 1 : 0
618
+ const pb = b.errors > 0 || b.drops > 0 ? 1 : 0
619
+ if (pa !== pb) return pb - pa
620
+ if (a.drops + a.errors !== b.drops + b.errors) return b.drops + b.errors - (a.drops + a.errors)
621
+ return b.volume - a.volume
622
+ }
623
+
624
+ /**
625
+ * Search results for a query: a name that starts with the query before one
626
+ * that only contains it, and within each, problems first, then volume.
627
+ */
628
+ export function searchEndpoints(summaries: EndpointSummary[], query: string, limit = 50): EndpointSummary[] {
629
+ const q = query.trim().toLowerCase()
630
+ const rank = (s: EndpointSummary) => {
631
+ if (!q) return 1
632
+ const name = s.name.toLowerCase()
633
+ if (name === q) return 0
634
+ if (name.startsWith(q)) return 1
635
+ if (name.includes(q)) return 2
636
+ if (s.id.toLowerCase().includes(q)) return 3
637
+ return -1
638
+ }
639
+ return summaries
640
+ .map(s => ({ s, r: rank(s) }))
641
+ .filter(x => x.r >= 0)
642
+ .sort((a, b) => a.r - b.r || byProblemsThenVolume(a.s, b.s))
643
+ .slice(0, limit)
644
+ .map(x => x.s)
645
+ }
646
+
647
+ export interface NamespaceSummary {
648
+ name: string
649
+ endpoints: number
650
+ connections: number
651
+ volume: number
652
+ errors: number
653
+ drops: number
654
+ }
655
+
656
+ /**
657
+ * Per namespace, what narrowing to it would show. Narrowing keeps an edge
658
+ * when either end is in the namespace, as the server does, so a cross-namespace
659
+ * edge counts toward both of its namespaces, and its far end toward the
660
+ * namespace's endpoints.
661
+ */
662
+ /** Whether the namespace's own view — its edges, with either end in it — would
663
+ * be over the draw budget, judged from the traffic already fetched. Picking it
664
+ * fetches that namespace on its own, which can bring in more. */
665
+ export function namespaceOverBudget(ns: NamespaceSummary): boolean {
666
+ return ns.endpoints + 2 * ns.connections > GRAPH_DRAW_BUDGET
667
+ }
668
+
669
+ export function namespaceSummaries(flows: AggregatedFlow[]): NamespaceSummary[] {
670
+ const byNs = new Map<string, { endpoints: Set<string>; connections: number; volume: number; errors: number; drops: number }>()
671
+ for (const flow of flows) {
672
+ const namespaces = new Set([flow.source.namespace, flow.destination.namespace].filter(Boolean))
673
+ for (const ns of namespaces) {
674
+ let s = byNs.get(ns)
675
+ if (!s) {
676
+ s = { endpoints: new Set(), connections: 0, volume: 0, errors: 0, drops: 0 }
677
+ byNs.set(ns, s)
678
+ }
679
+ s.endpoints.add(graphEndpointId(flow.source))
680
+ s.endpoints.add(graphEndpointId(flow.destination))
681
+ s.connections += 1
682
+ s.volume += flow.connections
683
+ s.errors += flowErrors(flow)
684
+ s.drops += flowDrops(flow)
685
+ }
686
+ }
687
+ return Array.from(byNs.entries())
688
+ .map(([name, s]) => ({ name, endpoints: s.endpoints.size, connections: s.connections, volume: s.volume, errors: s.errors, drops: s.drops }))
689
+ .sort((a, b) => byProblemsThenVolume(a, b) || a.name.localeCompare(b.name))
690
+ }
691
+
692
+ /** The edges as table rows, problems first, then volume. */
693
+ export function connectionRows<T extends AggregatedFlow>(flows: T[]): T[] {
694
+ const keyed = flows.map(flow => ({ flow, errors: flowErrors(flow), drops: flowDrops(flow), volume: flow.connections }))
695
+ keyed.sort(byProblemsThenVolume)
696
+ return keyed.map(k => k.flow)
697
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, useCallback } from 'react'
1
+ import { useEffect, useState, useCallback, useRef } from 'react'
2
2
  import { X, Copy, Check, ExternalLink } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayTransitionStyle } from '../../utils/animation'
@@ -7,15 +7,29 @@ import { useDiagnostics } from '../../api/client'
7
7
  import type { DiagnosticsSnapshot, DiagEnvVar, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
8
8
  import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
9
9
 
10
+ export type DiagnosticsFocus = 'timeline'
11
+
10
12
  interface DiagnosticsOverlayProps {
11
13
  onClose: () => void
12
14
  isOpen?: boolean
15
+ /** Section to scroll to on open, for callers linking to one specific problem. */
16
+ focus?: DiagnosticsFocus
13
17
  }
14
18
 
15
- export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverlayProps) {
19
+ export function DiagnosticsOverlay({ onClose, isOpen = true, focus }: DiagnosticsOverlayProps) {
16
20
  const { data, isLoading, error } = useDiagnostics(true)
17
21
  const [copied, setCopied] = useState<'json' | 'formatted' | null>(null)
18
22
  const [reportOpened, setReportOpened] = useState(false)
23
+ const timelineRef = useRef<HTMLDivElement>(null)
24
+ const hasData = data != null
25
+
26
+ // Once, when the snapshot first renders: the overlay polls, and re-scrolling
27
+ // on every refresh would yank the reader back while they read other sections.
28
+ useEffect(() => {
29
+ if (isOpen && hasData && focus === 'timeline') {
30
+ timelineRef.current?.scrollIntoView({ block: 'start' })
31
+ }
32
+ }, [isOpen, hasData, focus])
19
33
 
20
34
  // Close on Escape (capture phase) — only while logically open: the overlay
21
35
  // stays mounted through its exit.
@@ -119,6 +133,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
119
133
  <CacheSection data={data} />
120
134
  <MetricsSection data={data} />
121
135
  <EventPipelineSection data={data} />
136
+ <div ref={timelineRef} className="scroll-mt-4"><TimelineSection data={data} /></div>
122
137
  <InformersSection data={data} />
123
138
  <PrometheusSection data={data} />
124
139
  <TrafficSection data={data} />
@@ -330,6 +345,20 @@ function EventPipelineSection({ data }: { data: DiagnosticsSnapshot }) {
330
345
  )
331
346
  }
332
347
 
348
+ function TimelineSection({ data }: { data: DiagnosticsSnapshot }) {
349
+ if (!data.timeline) return null
350
+ const t = data.timeline
351
+ return (
352
+ <Section title="Timeline" warn={t.degraded}>
353
+ <Row label="Storage" value={t.storageType} warn={t.degraded} />
354
+ {t.degraded && t.degradedReason && <Row label="Unavailable" value={t.degradedReason} warn />}
355
+ {/* An unreachable database has no count to report; 0 would read as empty. */}
356
+ {(!t.degraded || t.storageType === 'memory') && <Row label="Events" value={t.totalEvents.toLocaleString()} />}
357
+ <Row label="Dropped" value={t.totalDrops.toLocaleString()} warn={t.totalDrops > 0} />
358
+ </Section>
359
+ )
360
+ }
361
+
333
362
  function InformersSection({ data }: { data: DiagnosticsSnapshot }) {
334
363
  if (!data.informers) return null
335
364
  const inf = data.informers
@@ -702,6 +731,9 @@ export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIP
702
731
  const t = data.timeline
703
732
  lines.push(`### Timeline`)
704
733
  lines.push(`- Storage: \`${t.storageType}\` | Events: ${t.totalEvents.toLocaleString()} | Errors: ${t.storeErrors} | Drops: ${t.totalDrops}`)
734
+ if (t.degraded && t.degradedReason) {
735
+ lines.push(`- Unavailable: ${t.degradedReason}`)
736
+ }
705
737
  lines.push(``)
706
738
  }
707
739
 
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { Markdown } from './Markdown'
6
+
7
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
8
+
9
+ const TEXT = 'First paragraph.\n\nRun `kubectl get pods` then open [the docs](https://example.com).\n\n```\nkubectl describe pod x\n```'
10
+
11
+ let container: HTMLDivElement
12
+ let root: Root
13
+
14
+ beforeEach(() => {
15
+ container = document.createElement('div')
16
+ document.body.appendChild(container)
17
+ root = createRoot(container)
18
+ })
19
+
20
+ afterEach(() => {
21
+ act(() => root.unmount())
22
+ container.remove()
23
+ })
24
+
25
+ // Callers pass fresh inline callbacks on every render, as the investigation
26
+ // views do, so the rendered elements must survive a re-render with new ones.
27
+ function render(label: string) {
28
+ act(() => {
29
+ root.render(
30
+ <Markdown
31
+ codeActions={(code) => <button type="button">{`${label}:${code}`}</button>}
32
+ linkRenderer={(href, children) => (href?.includes('example') ? <a data-custom={label}>{children}</a> : null)}
33
+ >
34
+ {TEXT}
35
+ </Markdown>,
36
+ )
37
+ })
38
+ }
39
+
40
+ describe('Markdown', () => {
41
+ it('keeps its rendered elements across parent re-renders', () => {
42
+ render('a')
43
+ const paragraph = container.querySelector('p')
44
+ const pre = container.querySelector('pre')
45
+ expect(paragraph).not.toBeNull()
46
+ expect(pre).not.toBeNull()
47
+
48
+ render('b')
49
+
50
+ expect(container.querySelector('p')).toBe(paragraph)
51
+ expect(container.querySelector('pre')).toBe(pre)
52
+ expect(paragraph?.isConnected).toBe(true)
53
+ })
54
+
55
+ it('applies the latest link and code hooks', () => {
56
+ render('a')
57
+ render('b')
58
+
59
+ expect(container.querySelector('a')?.getAttribute('data-custom')).toBe('b')
60
+ const labels = Array.from(container.querySelectorAll('button')).map((b) => b.textContent?.trim())
61
+ expect(labels).toContain('b:kubectl get pods')
62
+ expect(labels).toContain('b:kubectl describe pod x')
63
+ expect(labels.some((l) => l?.startsWith('a:'))).toBe(false)
64
+ })
65
+ })