@oneuptime/common 12.0.5 → 12.0.6

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 (528) hide show
  1. package/Models/DatabaseModels/AIRun.ts +33 -0
  2. package/Models/DatabaseModels/DataSource.ts +744 -0
  3. package/Models/DatabaseModels/Index.ts +3 -0
  4. package/Models/DatabaseModels/ProjectCallSMSConfig.ts +10 -2
  5. package/Models/DatabaseModels/User.ts +2 -0
  6. package/Models/DatabaseModels/UserOnCallLog.ts +2 -0
  7. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +2 -0
  8. package/Models/DatabaseModels/UserSession.ts +2 -0
  9. package/Models/DatabaseModels/UserTotpAuth.ts +6 -4
  10. package/Models/DatabaseModels/UserWebAuthn.ts +5 -3
  11. package/Server/API/AIInsightAPI.ts +47 -4
  12. package/Server/API/AIInvestigationAPI.ts +25 -0
  13. package/Server/API/DashboardAPI.ts +176 -492
  14. package/Server/API/DataSourceAPI.ts +268 -0
  15. package/Server/API/StatusPageAPI.ts +11 -1
  16. package/Server/API/UserAPI.ts +119 -0
  17. package/Server/Infrastructure/ClickhouseConfig.ts +59 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1786303472848-AddDataSourceTable.ts +39 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1786600000000-AddInvestigationAnalysisTldr.ts +24 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1786700000000-DropProjectCallSMSConfigCredentialUniques.ts +76 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  22. package/Server/Infrastructure/Queue.ts +26 -0
  23. package/Server/Infrastructure/QueueWorker.ts +17 -0
  24. package/Server/Middleware/TelemetryIngest.ts +8 -3
  25. package/Server/Services/AIInsightService.ts +132 -12
  26. package/Server/Services/AIRunService.ts +34 -0
  27. package/Server/Services/AIService.ts +17 -2
  28. package/Server/Services/AlertService.ts +38 -32
  29. package/Server/Services/AnalyticsDatabaseService.ts +8 -4
  30. package/Server/Services/DataSourceService.ts +200 -0
  31. package/Server/Services/HostService.ts +92 -0
  32. package/Server/Services/IncidentService.ts +45 -0
  33. package/Server/Services/Index.ts +2 -0
  34. package/Server/Services/MonitorService.ts +49 -0
  35. package/Server/Services/MonitorStatusService.ts +166 -0
  36. package/Server/Services/OpenTelemetryIngestService.ts +255 -184
  37. package/Server/Services/RumSessionPinService.ts +7 -21
  38. package/Server/Services/StatusPageSubscriberService.ts +13 -0
  39. package/Server/Services/UserService.ts +280 -9
  40. package/Server/Static/Vendor/README.md +62 -0
  41. package/Server/Static/Vendor/fonts/InterVariable.woff2 +0 -0
  42. package/Server/Static/Vendor/highlight/highlight.min.js +1244 -0
  43. package/Server/Static/Vendor/highlight/languages/apache.min.js +15 -0
  44. package/Server/Static/Vendor/highlight/languages/bash.min.js +21 -0
  45. package/Server/Static/Vendor/highlight/languages/c.min.js +41 -0
  46. package/Server/Static/Vendor/highlight/languages/cpp.min.js +47 -0
  47. package/Server/Static/Vendor/highlight/languages/csharp.min.js +49 -0
  48. package/Server/Static/Vendor/highlight/languages/css.min.js +31 -0
  49. package/Server/Static/Vendor/highlight/languages/diff.min.js +9 -0
  50. package/Server/Static/Vendor/highlight/languages/dns.min.js +11 -0
  51. package/Server/Static/Vendor/highlight/languages/dockerfile.min.js +8 -0
  52. package/Server/Static/Vendor/highlight/languages/go.min.js +20 -0
  53. package/Server/Static/Vendor/highlight/languages/graphql.min.js +12 -0
  54. package/Server/Static/Vendor/highlight/languages/http.min.js +14 -0
  55. package/Server/Static/Vendor/highlight/languages/ini.min.js +15 -0
  56. package/Server/Static/Vendor/highlight/languages/java.min.js +38 -0
  57. package/Server/Static/Vendor/highlight/languages/javascript.min.js +81 -0
  58. package/Server/Static/Vendor/highlight/languages/json.min.js +8 -0
  59. package/Server/Static/Vendor/highlight/languages/kotlin.min.js +46 -0
  60. package/Server/Static/Vendor/highlight/languages/lua.min.js +15 -0
  61. package/Server/Static/Vendor/highlight/languages/makefile.min.js +14 -0
  62. package/Server/Static/Vendor/highlight/languages/nginx.min.js +21 -0
  63. package/Server/Static/Vendor/highlight/languages/perl.min.js +41 -0
  64. package/Server/Static/Vendor/highlight/languages/php.min.js +59 -0
  65. package/Server/Static/Vendor/highlight/languages/powershell.min.js +39 -0
  66. package/Server/Static/Vendor/highlight/languages/python.min.js +42 -0
  67. package/Server/Static/Vendor/highlight/languages/r.min.js +26 -0
  68. package/Server/Static/Vendor/highlight/languages/ruby.min.js +54 -0
  69. package/Server/Static/Vendor/highlight/languages/rust.min.js +27 -0
  70. package/Server/Static/Vendor/highlight/languages/scala.min.js +28 -0
  71. package/Server/Static/Vendor/highlight/languages/sql.min.js +21 -0
  72. package/Server/Static/Vendor/highlight/languages/swift.min.js +68 -0
  73. package/Server/Static/Vendor/highlight/languages/typescript.min.js +99 -0
  74. package/Server/Static/Vendor/highlight/languages/xml.min.js +29 -0
  75. package/Server/Static/Vendor/highlight/languages/yaml.min.js +28 -0
  76. package/Server/Static/Vendor/highlight/styles/github-dark.min.css +10 -0
  77. package/Server/Static/Vendor/highlight/styles/vs2015.min.css +1 -0
  78. package/Server/Static/Vendor/tailwind/tailwind-3.4.5.js +21028 -0
  79. package/Server/Types/Database/Permissions/BasePermission.ts +7 -0
  80. package/Server/Types/Database/Permissions/DeletePermission.ts +2 -0
  81. package/Server/Types/Database/Permissions/ReadPermission.ts +3 -0
  82. package/Server/Types/Database/Permissions/TenantPermission.ts +101 -16
  83. package/Server/Utils/AI/CodeFix/FixRunBudget.ts +2 -2
  84. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +90 -11
  85. package/Server/Utils/AI/SRE/AlertInvestigationRunner.ts +1 -0
  86. package/Server/Utils/AI/SRE/IncidentInvestigationRunner.ts +1 -0
  87. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.ts +125 -0
  88. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +25 -15
  89. package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +256 -57
  90. package/Server/Utils/AI/SRE/Insights/InsightStore.ts +76 -18
  91. package/Server/Utils/AI/SRE/InvestigationTldr.ts +352 -0
  92. package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +7 -2
  93. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +1793 -0
  94. package/Server/Utils/Dashboard/PublicDashboardViewConfig.ts +65 -0
  95. package/Server/Utils/DataSource/ConnectorRegistry.ts +53 -0
  96. package/Server/Utils/DataSource/Connectors/ClickHouseConnector.ts +338 -0
  97. package/Server/Utils/DataSource/Connectors/ElasticsearchConnector.ts +717 -0
  98. package/Server/Utils/DataSource/Connectors/LokiConnector.ts +606 -0
  99. package/Server/Utils/DataSource/Connectors/MySqlConnector.ts +307 -0
  100. package/Server/Utils/DataSource/Connectors/PostgresConnector.ts +260 -0
  101. package/Server/Utils/DataSource/Connectors/PrometheusConnector.ts +555 -0
  102. package/Server/Utils/DataSource/Connectors/RestApiConnector.ts +597 -0
  103. package/Server/Utils/DataSource/Connectors/SqlServerConnector.ts +318 -0
  104. package/Server/Utils/DataSource/EgressGuard.ts +446 -0
  105. package/Server/Utils/DataSource/HttpFetch.ts +221 -0
  106. package/Server/Utils/DataSource/SqlQueryGuard.ts +383 -0
  107. package/Server/Utils/DataSource/SqlResultShaper.ts +372 -0
  108. package/Server/Utils/DataSource/Types.ts +93 -0
  109. package/Server/Utils/Database/PostgresErrorTranslator.ts +172 -17
  110. package/Server/Utils/InProcessMemo.ts +141 -0
  111. package/Server/Utils/InstanceHealth/TelemetryIngestion.ts +628 -0
  112. package/Server/Utils/Monitor/MonitorMetricUtil.ts +58 -0
  113. package/Server/Utils/Monitor/MonitorResource.ts +14 -0
  114. package/Server/Utils/Response.ts +12 -1
  115. package/Server/Utils/SSRFProtection.ts +237 -0
  116. package/Server/Utils/StartServer.ts +8 -0
  117. package/Server/Utils/StatusPageSubscriberWebhook.ts +30 -0
  118. package/Server/Utils/Telemetry/CaptureSpan.ts +30 -18
  119. package/Server/Utils/Telemetry/IngestionTimestamp.ts +75 -0
  120. package/Server/Utils/Telemetry/ResourceHeartbeat.ts +99 -7
  121. package/Server/Utils/Telemetry/Telemetry.ts +112 -1
  122. package/Server/Utils/Telemetry.ts +104 -3
  123. package/Server/Utils/VendorAssets.ts +165 -0
  124. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +28 -5
  125. package/Server/Utils/Workspace/Slack/Slack.ts +5 -0
  126. package/Server/Views/Partials/Head.ejs +21 -7
  127. package/Tests/App/AdminDashboard/AdminProjectsIdFilter.test.tsx +207 -0
  128. package/Tests/App/AdminDashboard/AdminUsersIdFilter.test.tsx +216 -0
  129. package/Tests/App/Dashboard/AIInsightViewActions.test.tsx +422 -0
  130. package/Tests/App/Dashboard/AddWidgetModal.test.tsx +10 -3
  131. package/Tests/App/Dashboard/AlertsSideMenu.test.tsx +387 -0
  132. package/Tests/App/Dashboard/ChatActivityFeed.test.tsx +199 -0
  133. package/Tests/App/Dashboard/DataSourceChartSyntheticConfig.test.tsx +736 -0
  134. package/Tests/App/Dashboard/DataSourceQueryEditor.test.tsx +868 -0
  135. package/Tests/App/Dashboard/DataSourceWidgetCatalog.test.ts +440 -0
  136. package/Tests/App/Dashboard/DataSourceWidgetFetching.test.tsx +2109 -0
  137. package/Tests/App/Dashboard/DataSourceWidgetViews.test.tsx +726 -0
  138. package/Tests/App/Dashboard/IncidentsSideMenu.test.tsx +447 -0
  139. package/Tests/App/Dashboard/InvestigationPanel.test.tsx +514 -3
  140. package/Tests/App/Dashboard/ProductMenuBreadcrumbs.test.tsx +391 -0
  141. package/Tests/App/Dashboard/ScheduledMaintenanceSideMenu.test.tsx +326 -0
  142. package/Tests/App/Dashboard/SideMenuHarness.tsx +187 -0
  143. package/Tests/App/Dashboard/WidgetCatalog.test.ts +6 -1
  144. package/Tests/App/StatusPage/StatusPageResourceExplorer.test.tsx +1858 -0
  145. package/Tests/Models/DatabaseModels/DataSourceModelSecrets.test.ts +70 -0
  146. package/Tests/Server/API/AIInvestigationAPI.test.ts +187 -0
  147. package/Tests/Server/API/DashboardPublicDataSourceStrip.test.ts +686 -0
  148. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +585 -823
  149. package/Tests/Server/API/DataSourceAPIRoutes.test.ts +486 -0
  150. package/Tests/Server/API/StatusPageSubscriberOwnership.test.ts +291 -0
  151. package/Tests/Server/API/UserAuthenticationAPI.test.ts +641 -0
  152. package/Tests/Server/Infrastructure/ClickhouseKeepAliveOptions.test.ts +441 -0
  153. package/Tests/Server/Middleware/TelemetryIngestTokenLog.test.ts +140 -0
  154. package/Tests/Server/Services/AIInsightService.test.ts +266 -11
  155. package/Tests/Server/Services/AIRunAnalysisTldrPersistence.test.ts +160 -0
  156. package/Tests/Server/Services/AIServiceDailyBudget.test.ts +4 -0
  157. package/Tests/Server/Services/AlertMetricResourceAttributes.test.ts +352 -0
  158. package/Tests/Server/Services/DataSourceServiceSettings.test.ts +244 -0
  159. package/Tests/Server/Services/HostServiceFindOrCreateMemo.test.ts +397 -0
  160. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +41 -2
  161. package/Tests/Server/Services/IncidentMetricResourceAttributes.test.ts +408 -0
  162. package/Tests/Server/Services/MonitorStatusServiceDeleteReferenceCleanup.test.ts +351 -0
  163. package/Tests/Server/Services/MonitorStatusServiceValidation.test.ts +171 -0
  164. package/Tests/Server/Services/OpenTelemetryResourceRetentionMemo.test.ts +398 -0
  165. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +19 -0
  166. package/Tests/Server/Services/ProjectCallSMSConfigCredentialUniqueness.test.ts +305 -0
  167. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +36 -1
  168. package/Tests/Server/Services/RumSessionPinUniqueViolationRecovery.test.ts +163 -0
  169. package/Tests/Server/Services/UserAuthenticationService.test.ts +973 -0
  170. package/Tests/Server/Types/Database/ObjectIdColumnQuery.test.ts +262 -0
  171. package/Tests/Server/Types/Database/Permissions/TenantPermission.test.ts +358 -0
  172. package/Tests/Server/Utils/AI/FixRunBudget.test.ts +2 -2
  173. package/Tests/Server/Utils/AI/Insights/ExceptionSpikeDetector.test.ts +6 -1
  174. package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +121 -4
  175. package/Tests/Server/Utils/AI/Insights/NewExceptionDetector.test.ts +361 -10
  176. package/Tests/Server/Utils/AI/InvestigationSettlement.test.ts +292 -0
  177. package/Tests/Server/Utils/AI/InvestigationTldr.test.ts +623 -0
  178. package/Tests/Server/Utils/AI/InvestigationTldrWiring.test.ts +98 -0
  179. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +857 -0
  180. package/Tests/Server/Utils/Dashboard/PublicDashboardViewConfig.test.ts +480 -0
  181. package/Tests/Server/Utils/DataSource/ClickHouseConnector.test.ts +759 -0
  182. package/Tests/Server/Utils/DataSource/ConnectorRegistry.test.ts +37 -0
  183. package/Tests/Server/Utils/DataSource/EgressGuard.test.ts +645 -0
  184. package/Tests/Server/Utils/DataSource/ElasticsearchConnector.test.ts +1206 -0
  185. package/Tests/Server/Utils/DataSource/HttpFetch.test.ts +616 -0
  186. package/Tests/Server/Utils/DataSource/LokiConnector.test.ts +970 -0
  187. package/Tests/Server/Utils/DataSource/MySqlConnector.test.ts +935 -0
  188. package/Tests/Server/Utils/DataSource/PostgresConnector.test.ts +854 -0
  189. package/Tests/Server/Utils/DataSource/PrometheusConnector.test.ts +881 -0
  190. package/Tests/Server/Utils/DataSource/RestApiConnector.test.ts +1020 -0
  191. package/Tests/Server/Utils/DataSource/SqlQueryGuard.test.ts +425 -0
  192. package/Tests/Server/Utils/DataSource/SqlResultShaper.test.ts +555 -0
  193. package/Tests/Server/Utils/DataSource/SqlServerConnector.test.ts +934 -0
  194. package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +228 -7
  195. package/Tests/Server/Utils/InProcessMemo.test.ts +292 -0
  196. package/Tests/Server/Utils/InstanceHealth/TelemetryIngestion.test.ts +960 -0
  197. package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +103 -0
  198. package/Tests/Server/Utils/Monitor/MonitorMetricResourceAttributes.test.ts +336 -0
  199. package/Tests/Server/Utils/OfflineAssetHygiene.test.ts +521 -0
  200. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +143 -0
  201. package/Tests/Server/Utils/SSRFProtection.test.ts +193 -0
  202. package/Tests/Server/Utils/StatusPageSubscriberWebhook.test.ts +101 -0
  203. package/Tests/Server/Utils/TailwindBuildCopy.test.ts +225 -0
  204. package/Tests/Server/Utils/Telemetry/ResourceHeartbeatL1Memo.test.ts +355 -0
  205. package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +128 -0
  206. package/Tests/Server/Utils/Telemetry/TelemetryUtilAttributeKeys.test.ts +381 -0
  207. package/Tests/Server/Utils/Telemetry.test.ts +87 -0
  208. package/Tests/Server/Utils/VendorAssetIntegrity.test.ts +343 -0
  209. package/Tests/Server/Utils/VendorAssets.test.ts +671 -0
  210. package/Tests/Server/Views/OfflineRender.test.ts +297 -0
  211. package/Tests/Types/Dashboard/DataSourceComponentTypes.test.ts +618 -0
  212. package/Tests/Types/DateClickhouseFormat.test.ts +192 -0
  213. package/Tests/Types/Password.test.ts +363 -0
  214. package/Tests/UI/Components/ConfirmModal.test.tsx +7 -0
  215. package/Tests/UI/Components/ErrorBoundary.test.tsx +53 -0
  216. package/Tests/UI/Components/Modal.test.tsx +281 -1
  217. package/Tests/UI/Components/ObjectIdFilterPipeline.test.tsx +396 -0
  218. package/Tests/UI/Components/StatusPage/ResourceGroupNavigator.test.tsx +1147 -0
  219. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +116 -1
  220. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  221. package/Tests/Utils/Analytics.test.ts +23 -0
  222. package/Tests/Utils/Dashboard/Components/DashboardDataSourceComponents.test.ts +640 -0
  223. package/Tests/Utils/DataSource/DataSourceQueryText.test.ts +138 -0
  224. package/Tests/Utils/DataSource/DataSourceValueReducer.test.ts +526 -0
  225. package/Tests/Utils/Metrics/MetricResourceAttributeUtil.test.ts +691 -0
  226. package/Tests/Utils/StatusPage/GroupHierarchyView.test.ts +1452 -0
  227. package/Tests/Utils/StatusPage/ResourceExplorer.test.ts +931 -0
  228. package/Tests/Utils/Telemetry/CaptureSpanExportGating.test.ts +288 -0
  229. package/Tests/Utils/Telemetry/IngestionTimestamp.test.ts +170 -0
  230. package/Types/AI/AIInsightEvidence.ts +6 -0
  231. package/Types/Analytics/RevenueEvent.ts +31 -0
  232. package/Types/Dashboard/DashboardComponentType.ts +31 -0
  233. package/Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent.ts +27 -0
  234. package/Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent.ts +32 -0
  235. package/Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent.ts +30 -0
  236. package/Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent.ts +55 -0
  237. package/Types/DataSource/DataSourceLimits.ts +62 -0
  238. package/Types/DataSource/DataSourceQueryConfig.ts +41 -0
  239. package/Types/DataSource/DataSourceTableResult.ts +38 -0
  240. package/Types/DataSource/DataSourceType.ts +213 -0
  241. package/Types/Date.ts +43 -3
  242. package/Types/Password.ts +64 -0
  243. package/Types/Permission.ts +46 -0
  244. package/Types/UserAuthenticationStatus.ts +34 -0
  245. package/UI/Components/Button/Button.tsx +1 -1
  246. package/UI/Components/ErrorBoundary.tsx +459 -108
  247. package/UI/Components/Icon/Icon.tsx +10 -9
  248. package/UI/Components/Modal/ConfirmModal.tsx +6 -1
  249. package/UI/Components/Modal/Modal.tsx +267 -35
  250. package/UI/Components/Modal/ModalFooter.tsx +6 -1
  251. package/UI/Components/MoreMenu/MoreMenu.tsx +19 -4
  252. package/UI/Components/MoreMenu/MoreMenuItem.tsx +6 -1
  253. package/UI/Components/StatusPage/ResourceGrid.tsx +314 -0
  254. package/UI/Components/StatusPage/ResourceGroupNavigator.tsx +808 -0
  255. package/UI/Components/StatusPage/ResourceList.tsx +570 -0
  256. package/UI/Styles/Theme.css +18 -0
  257. package/UI/Utils/API/RequestOptions.ts +7 -0
  258. package/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.ts +1 -0
  259. package/UI/Utils/ModelAPI/ModelAPI.ts +1 -0
  260. package/UI/Utils/TestDataSource.ts +61 -0
  261. package/UI/esbuild-config.js +45 -0
  262. package/Utils/Analytics.ts +15 -0
  263. package/Utils/Dashboard/Components/DashboardDataSourceChartComponent.ts +88 -0
  264. package/Utils/Dashboard/Components/DashboardDataSourceGaugeComponent.ts +123 -0
  265. package/Utils/Dashboard/Components/DashboardDataSourceShared.ts +16 -0
  266. package/Utils/Dashboard/Components/DashboardDataSourceTableComponent.ts +72 -0
  267. package/Utils/Dashboard/Components/DashboardDataSourceValueComponent.ts +126 -0
  268. package/Utils/Dashboard/Components/Index.ts +28 -0
  269. package/Utils/DataSource/DataSourceQueryText.ts +94 -0
  270. package/Utils/DataSource/DataSourceValueReducer.ts +156 -0
  271. package/Utils/Metrics/MetricResourceAttributeUtil.ts +380 -0
  272. package/Utils/NetworkDiscovery/ScanTargetUtil.ts +8 -6
  273. package/Utils/StatusPage/GroupHierarchyView.ts +931 -0
  274. package/Utils/StatusPage/ResourceExplorer.ts +853 -0
  275. package/build/dist/Models/DatabaseModels/AIRun.js +34 -0
  276. package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
  277. package/build/dist/Models/DatabaseModels/DataSource.js +774 -0
  278. package/build/dist/Models/DatabaseModels/DataSource.js.map +1 -0
  279. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  280. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  281. package/build/dist/Models/DatabaseModels/ProjectCallSMSConfig.js +14 -4
  282. package/build/dist/Models/DatabaseModels/ProjectCallSMSConfig.js.map +1 -1
  283. package/build/dist/Models/DatabaseModels/User.js +2 -0
  284. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  285. package/build/dist/Models/DatabaseModels/UserOnCallLog.js +2 -0
  286. package/build/dist/Models/DatabaseModels/UserOnCallLog.js.map +1 -1
  287. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +2 -0
  288. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  289. package/build/dist/Models/DatabaseModels/UserSession.js +2 -0
  290. package/build/dist/Models/DatabaseModels/UserSession.js.map +1 -1
  291. package/build/dist/Models/DatabaseModels/UserTotpAuth.js +6 -4
  292. package/build/dist/Models/DatabaseModels/UserTotpAuth.js.map +1 -1
  293. package/build/dist/Models/DatabaseModels/UserWebAuthn.js +5 -3
  294. package/build/dist/Models/DatabaseModels/UserWebAuthn.js.map +1 -1
  295. package/build/dist/Server/API/AIInsightAPI.js +34 -4
  296. package/build/dist/Server/API/AIInsightAPI.js.map +1 -1
  297. package/build/dist/Server/API/AIInvestigationAPI.js +23 -0
  298. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  299. package/build/dist/Server/API/DashboardAPI.js +119 -433
  300. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  301. package/build/dist/Server/API/DataSourceAPI.js +173 -0
  302. package/build/dist/Server/API/DataSourceAPI.js.map +1 -0
  303. package/build/dist/Server/API/StatusPageAPI.js +11 -1
  304. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  305. package/build/dist/Server/API/UserAPI.js +94 -2
  306. package/build/dist/Server/API/UserAPI.js.map +1 -1
  307. package/build/dist/Server/Infrastructure/ClickhouseConfig.js +49 -0
  308. package/build/dist/Server/Infrastructure/ClickhouseConfig.js.map +1 -1
  309. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786303472848-AddDataSourceTable.js +20 -0
  310. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786303472848-AddDataSourceTable.js.map +1 -0
  311. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786600000000-AddInvestigationAnalysisTldr.js +19 -0
  312. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786600000000-AddInvestigationAnalysisTldr.js.map +1 -0
  313. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786700000000-DropProjectCallSMSConfigCredentialUniques.js +64 -0
  314. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786700000000-DropProjectCallSMSConfigCredentialUniques.js.map +1 -0
  315. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  316. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  317. package/build/dist/Server/Infrastructure/Queue.js +27 -0
  318. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  319. package/build/dist/Server/Infrastructure/QueueWorker.js +13 -0
  320. package/build/dist/Server/Infrastructure/QueueWorker.js.map +1 -1
  321. package/build/dist/Server/Middleware/TelemetryIngest.js +8 -3
  322. package/build/dist/Server/Middleware/TelemetryIngest.js.map +1 -1
  323. package/build/dist/Server/Services/AIInsightService.js +110 -10
  324. package/build/dist/Server/Services/AIInsightService.js.map +1 -1
  325. package/build/dist/Server/Services/AIRunService.js +34 -0
  326. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  327. package/build/dist/Server/Services/AIService.js +16 -2
  328. package/build/dist/Server/Services/AIService.js.map +1 -1
  329. package/build/dist/Server/Services/AlertService.js +28 -35
  330. package/build/dist/Server/Services/AlertService.js.map +1 -1
  331. package/build/dist/Server/Services/AnalyticsDatabaseService.js +8 -4
  332. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  333. package/build/dist/Server/Services/DataSourceService.js +159 -0
  334. package/build/dist/Server/Services/DataSourceService.js.map +1 -0
  335. package/build/dist/Server/Services/HostService.js +74 -0
  336. package/build/dist/Server/Services/HostService.js.map +1 -1
  337. package/build/dist/Server/Services/IncidentService.js +35 -24
  338. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  339. package/build/dist/Server/Services/Index.js +2 -0
  340. package/build/dist/Server/Services/Index.js.map +1 -1
  341. package/build/dist/Server/Services/MonitorService.js +45 -0
  342. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  343. package/build/dist/Server/Services/MonitorStatusService.js +129 -0
  344. package/build/dist/Server/Services/MonitorStatusService.js.map +1 -1
  345. package/build/dist/Server/Services/OpenTelemetryIngestService.js +222 -159
  346. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  347. package/build/dist/Server/Services/RumSessionPinService.js +7 -15
  348. package/build/dist/Server/Services/RumSessionPinService.js.map +1 -1
  349. package/build/dist/Server/Services/StatusPageSubscriberService.js +10 -0
  350. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  351. package/build/dist/Server/Services/UserService.js +241 -10
  352. package/build/dist/Server/Services/UserService.js.map +1 -1
  353. package/build/dist/Server/Types/Database/Permissions/BasePermission.js +6 -0
  354. package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
  355. package/build/dist/Server/Types/Database/Permissions/DeletePermission.js +1 -0
  356. package/build/dist/Server/Types/Database/Permissions/DeletePermission.js.map +1 -1
  357. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js +2 -0
  358. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js.map +1 -1
  359. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +68 -12
  360. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
  361. package/build/dist/Server/Utils/AI/CodeFix/FixRunBudget.js +2 -2
  362. package/build/dist/Server/Utils/AI/CodeFix/FixRunBudget.js.map +1 -1
  363. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +68 -8
  364. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  365. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js +1 -0
  366. package/build/dist/Server/Utils/AI/SRE/AlertInvestigationRunner.js.map +1 -1
  367. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js +1 -0
  368. package/build/dist/Server/Utils/AI/SRE/IncidentInvestigationRunner.js.map +1 -1
  369. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.js +97 -0
  370. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.js.map +1 -0
  371. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +17 -15
  372. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
  373. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +178 -54
  374. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
  375. package/build/dist/Server/Utils/AI/SRE/Insights/InsightStore.js +65 -16
  376. package/build/dist/Server/Utils/AI/SRE/Insights/InsightStore.js.map +1 -1
  377. package/build/dist/Server/Utils/AI/SRE/InvestigationTldr.js +303 -0
  378. package/build/dist/Server/Utils/AI/SRE/InvestigationTldr.js.map +1 -0
  379. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -1
  380. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +1261 -0
  381. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -0
  382. package/build/dist/Server/Utils/Dashboard/PublicDashboardViewConfig.js +47 -0
  383. package/build/dist/Server/Utils/Dashboard/PublicDashboardViewConfig.js.map +1 -0
  384. package/build/dist/Server/Utils/DataSource/ConnectorRegistry.js +42 -0
  385. package/build/dist/Server/Utils/DataSource/ConnectorRegistry.js.map +1 -0
  386. package/build/dist/Server/Utils/DataSource/Connectors/ClickHouseConnector.js +187 -0
  387. package/build/dist/Server/Utils/DataSource/Connectors/ClickHouseConnector.js.map +1 -0
  388. package/build/dist/Server/Utils/DataSource/Connectors/ElasticsearchConnector.js +493 -0
  389. package/build/dist/Server/Utils/DataSource/Connectors/ElasticsearchConnector.js.map +1 -0
  390. package/build/dist/Server/Utils/DataSource/Connectors/LokiConnector.js +399 -0
  391. package/build/dist/Server/Utils/DataSource/Connectors/LokiConnector.js.map +1 -0
  392. package/build/dist/Server/Utils/DataSource/Connectors/MySqlConnector.js +195 -0
  393. package/build/dist/Server/Utils/DataSource/Connectors/MySqlConnector.js.map +1 -0
  394. package/build/dist/Server/Utils/DataSource/Connectors/PostgresConnector.js +149 -0
  395. package/build/dist/Server/Utils/DataSource/Connectors/PostgresConnector.js.map +1 -0
  396. package/build/dist/Server/Utils/DataSource/Connectors/PrometheusConnector.js +362 -0
  397. package/build/dist/Server/Utils/DataSource/Connectors/PrometheusConnector.js.map +1 -0
  398. package/build/dist/Server/Utils/DataSource/Connectors/RestApiConnector.js +342 -0
  399. package/build/dist/Server/Utils/DataSource/Connectors/RestApiConnector.js.map +1 -0
  400. package/build/dist/Server/Utils/DataSource/Connectors/SqlServerConnector.js +186 -0
  401. package/build/dist/Server/Utils/DataSource/Connectors/SqlServerConnector.js.map +1 -0
  402. package/build/dist/Server/Utils/DataSource/EgressGuard.js +305 -0
  403. package/build/dist/Server/Utils/DataSource/EgressGuard.js.map +1 -0
  404. package/build/dist/Server/Utils/DataSource/HttpFetch.js +134 -0
  405. package/build/dist/Server/Utils/DataSource/HttpFetch.js.map +1 -0
  406. package/build/dist/Server/Utils/DataSource/SqlQueryGuard.js +331 -0
  407. package/build/dist/Server/Utils/DataSource/SqlQueryGuard.js.map +1 -0
  408. package/build/dist/Server/Utils/DataSource/SqlResultShaper.js +280 -0
  409. package/build/dist/Server/Utils/DataSource/SqlResultShaper.js.map +1 -0
  410. package/build/dist/Server/Utils/DataSource/Types.js +2 -0
  411. package/build/dist/Server/Utils/DataSource/Types.js.map +1 -0
  412. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +103 -9
  413. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -1
  414. package/build/dist/Server/Utils/InProcessMemo.js +113 -0
  415. package/build/dist/Server/Utils/InProcessMemo.js.map +1 -0
  416. package/build/dist/Server/Utils/InstanceHealth/TelemetryIngestion.js +406 -0
  417. package/build/dist/Server/Utils/InstanceHealth/TelemetryIngestion.js.map +1 -0
  418. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +34 -0
  419. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
  420. package/build/dist/Server/Utils/Monitor/MonitorResource.js +14 -0
  421. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  422. package/build/dist/Server/Utils/Response.js +9 -1
  423. package/build/dist/Server/Utils/Response.js.map +1 -1
  424. package/build/dist/Server/Utils/SSRFProtection.js +192 -0
  425. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -0
  426. package/build/dist/Server/Utils/StartServer.js +7 -0
  427. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  428. package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js +24 -0
  429. package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js.map +1 -1
  430. package/build/dist/Server/Utils/Telemetry/CaptureSpan.js +25 -11
  431. package/build/dist/Server/Utils/Telemetry/CaptureSpan.js.map +1 -1
  432. package/build/dist/Server/Utils/Telemetry/IngestionTimestamp.js +32 -0
  433. package/build/dist/Server/Utils/Telemetry/IngestionTimestamp.js.map +1 -0
  434. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +92 -5
  435. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -1
  436. package/build/dist/Server/Utils/Telemetry/Telemetry.js +92 -6
  437. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  438. package/build/dist/Server/Utils/Telemetry.js +93 -6
  439. package/build/dist/Server/Utils/Telemetry.js.map +1 -1
  440. package/build/dist/Server/Utils/VendorAssets.js +121 -0
  441. package/build/dist/Server/Utils/VendorAssets.js.map +1 -0
  442. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -4
  443. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  444. package/build/dist/Server/Utils/Workspace/Slack/Slack.js +5 -0
  445. package/build/dist/Server/Utils/Workspace/Slack/Slack.js.map +1 -1
  446. package/build/dist/Types/Analytics/RevenueEvent.js +26 -0
  447. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -0
  448. package/build/dist/Types/Dashboard/DashboardComponentType.js +27 -0
  449. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  450. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent.js +2 -0
  451. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent.js.map +1 -0
  452. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent.js +2 -0
  453. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent.js.map +1 -0
  454. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent.js +2 -0
  455. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent.js.map +1 -0
  456. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent.js +16 -0
  457. package/build/dist/Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent.js.map +1 -0
  458. package/build/dist/Types/DataSource/DataSourceLimits.js +47 -0
  459. package/build/dist/Types/DataSource/DataSourceLimits.js.map +1 -0
  460. package/build/dist/Types/DataSource/DataSourceQueryConfig.js +10 -0
  461. package/build/dist/Types/DataSource/DataSourceQueryConfig.js.map +1 -0
  462. package/build/dist/Types/DataSource/DataSourceTableResult.js +14 -0
  463. package/build/dist/Types/DataSource/DataSourceTableResult.js.map +1 -0
  464. package/build/dist/Types/DataSource/DataSourceType.js +183 -0
  465. package/build/dist/Types/DataSource/DataSourceType.js.map +1 -0
  466. package/build/dist/Types/Date.js +38 -3
  467. package/build/dist/Types/Date.js.map +1 -1
  468. package/build/dist/Types/Password.js +56 -0
  469. package/build/dist/Types/Password.js.map +1 -0
  470. package/build/dist/Types/Permission.js +42 -0
  471. package/build/dist/Types/Permission.js.map +1 -1
  472. package/build/dist/Types/UserAuthenticationStatus.js +2 -0
  473. package/build/dist/Types/UserAuthenticationStatus.js.map +1 -0
  474. package/build/dist/UI/Components/ErrorBoundary.js +388 -72
  475. package/build/dist/UI/Components/ErrorBoundary.js.map +1 -1
  476. package/build/dist/UI/Components/Icon/Icon.js +10 -4
  477. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  478. package/build/dist/UI/Components/Modal/ConfirmModal.js +1 -1
  479. package/build/dist/UI/Components/Modal/ConfirmModal.js.map +1 -1
  480. package/build/dist/UI/Components/Modal/Modal.js +178 -19
  481. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  482. package/build/dist/UI/Components/Modal/ModalFooter.js +3 -1
  483. package/build/dist/UI/Components/Modal/ModalFooter.js.map +1 -1
  484. package/build/dist/UI/Components/MoreMenu/MoreMenu.js +9 -6
  485. package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
  486. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js +7 -1
  487. package/build/dist/UI/Components/MoreMenu/MoreMenuItem.js.map +1 -1
  488. package/build/dist/UI/Components/StatusPage/ResourceGrid.js +119 -0
  489. package/build/dist/UI/Components/StatusPage/ResourceGrid.js.map +1 -0
  490. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js +354 -0
  491. package/build/dist/UI/Components/StatusPage/ResourceGroupNavigator.js.map +1 -0
  492. package/build/dist/UI/Components/StatusPage/ResourceList.js +203 -0
  493. package/build/dist/UI/Components/StatusPage/ResourceList.js.map +1 -0
  494. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js +1 -6
  495. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js.map +1 -1
  496. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +3 -8
  497. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  498. package/build/dist/UI/Utils/TestDataSource.js +39 -0
  499. package/build/dist/UI/Utils/TestDataSource.js.map +1 -0
  500. package/build/dist/Utils/Analytics.js +4 -0
  501. package/build/dist/Utils/Analytics.js.map +1 -1
  502. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceChartComponent.js +79 -0
  503. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceChartComponent.js.map +1 -0
  504. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceGaugeComponent.js +111 -0
  505. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceGaugeComponent.js.map +1 -0
  506. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceShared.js +15 -0
  507. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceShared.js.map +1 -0
  508. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceTableComponent.js +62 -0
  509. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceTableComponent.js.map +1 -0
  510. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceValueComponent.js +112 -0
  511. package/build/dist/Utils/Dashboard/Components/DashboardDataSourceValueComponent.js.map +1 -0
  512. package/build/dist/Utils/Dashboard/Components/Index.js +16 -0
  513. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  514. package/build/dist/Utils/DataSource/DataSourceQueryText.js +64 -0
  515. package/build/dist/Utils/DataSource/DataSourceQueryText.js.map +1 -0
  516. package/build/dist/Utils/DataSource/DataSourceValueReducer.js +124 -0
  517. package/build/dist/Utils/DataSource/DataSourceValueReducer.js.map +1 -0
  518. package/build/dist/Utils/Metrics/MetricResourceAttributeUtil.js +281 -0
  519. package/build/dist/Utils/Metrics/MetricResourceAttributeUtil.js.map +1 -0
  520. package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js +8 -6
  521. package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js.map +1 -1
  522. package/build/dist/Utils/StatusPage/GroupHierarchyView.js +560 -0
  523. package/build/dist/Utils/StatusPage/GroupHierarchyView.js.map +1 -0
  524. package/build/dist/Utils/StatusPage/ResourceExplorer.js +519 -0
  525. package/build/dist/Utils/StatusPage/ResourceExplorer.js.map +1 -0
  526. package/jest.config.json +1 -0
  527. package/package.json +5 -1
  528. package/tsconfig.json +12 -10
@@ -0,0 +1,2109 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ RenderResult,
12
+ act,
13
+ cleanup,
14
+ fireEvent,
15
+ render,
16
+ screen,
17
+ waitFor,
18
+ } from "@testing-library/react";
19
+ import * as React from "react";
20
+ import getJestMockFunction, { MockFunction } from "../../MockType";
21
+
22
+ /*
23
+ * The four External Data Source widgets each own their own fetch loop, and
24
+ * every one of those loops has to answer the same three questions before it
25
+ * is allowed to touch the network: is this widget actually configured, which
26
+ * concrete time window is it asking about, and is this a public dashboard?
27
+ *
28
+ * Getting the first wrong spams a customer's Postgres/Prometheus with empty
29
+ * queries the moment a widget is dropped on a board. Getting the last wrong
30
+ * is a security boundary failure — the anonymous public dashboard API
31
+ * deliberately exposes no data-source surface, so a widget that fetches
32
+ * anyway leaks project-owned credentials' reach to unauthenticated viewers.
33
+ *
34
+ * Variable interpolation lives on the client (the server receives final query
35
+ * text), so "{{env}}" surviving into the request is a real bug class, not a
36
+ * cosmetic one — which is why it is asserted against the request arguments
37
+ * rather than against anything rendered.
38
+ */
39
+
40
+ const fetchTimeSeriesMock: MockFunction = getJestMockFunction();
41
+ const fetchTableMock: MockFunction = getJestMockFunction();
42
+ const metricChartsRenderMock: MockFunction = getJestMockFunction();
43
+
44
+ /*
45
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the
46
+ * compiled requires, so the mock variables above are still unassigned when
47
+ * the factory runs. Dereferencing them lazily, at call time, is what makes
48
+ * this work.
49
+ */
50
+ jest.mock(
51
+ "../../../../App/FeatureSet/Dashboard/src/Utils/DataSourceQuery",
52
+ () => {
53
+ return {
54
+ __esModule: true,
55
+ default: {
56
+ fetchTimeSeries: (...args: Array<any>) => {
57
+ return fetchTimeSeriesMock(...args);
58
+ },
59
+ fetchTable: (...args: Array<any>) => {
60
+ return fetchTableMock(...args);
61
+ },
62
+ getAttributeKeys: (result: { data: Array<Record<string, any>> }) => {
63
+ const keys: Array<string> = [];
64
+ for (const point of result.data || []) {
65
+ const attributes: Record<string, unknown> | undefined = point[
66
+ "attributes"
67
+ ] as Record<string, unknown> | undefined;
68
+ for (const key of Object.keys(attributes || {})) {
69
+ if (!keys.includes(key)) {
70
+ keys.push(key);
71
+ }
72
+ }
73
+ }
74
+ return keys;
75
+ },
76
+ },
77
+ };
78
+ },
79
+ );
80
+
81
+ /*
82
+ * The chart widget's whole job here is what it ASKS for and what it hands
83
+ * down; rendering a real recharts surface in jsdom would test neither.
84
+ */
85
+ jest.mock(
86
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts",
87
+ () => {
88
+ return {
89
+ __esModule: true,
90
+ default: (props: MetricChartsStubProps): React.ReactElement => {
91
+ metricChartsRenderMock(props);
92
+ return React.createElement(
93
+ "div",
94
+ { "data-testid": "metric-charts" },
95
+ `results:${props.metricResults.length} configs:${props.metricViewData.queryConfigs.length}`,
96
+ );
97
+ },
98
+ };
99
+ },
100
+ );
101
+
102
+ import DashboardDataSourceChartComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceChartComponent";
103
+ import DashboardDataSourceGaugeComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceGaugeComponent";
104
+ import DashboardDataSourceTableComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceTableComponent";
105
+ import DashboardDataSourceValueComponentElement from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardDataSourceValueComponent";
106
+ import { DashboardBaseComponentProps } from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Components/DashboardBaseComponent";
107
+ import {
108
+ PublicDashboardContext,
109
+ setPublicDashboardContext,
110
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Dashboard/Utils/PublicDashboardContext";
111
+ import AggregatedModel from "../../../Types/BaseDatabase/AggregatedModel";
112
+ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
113
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
114
+ import DashboardChartType from "../../../Types/Dashboard/Chart/ChartType";
115
+ import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
116
+ import DashboardDataSourceChartComponent from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceChartComponent";
117
+ import DashboardDataSourceGaugeComponent from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceGaugeComponent";
118
+ import DashboardDataSourceTableComponent from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceTableComponent";
119
+ import DashboardDataSourceValueComponent, {
120
+ DataSourceValueReduce,
121
+ } from "../../../Types/Dashboard/DashboardComponents/DashboardDataSourceValueComponent";
122
+ import { DashboardValueTrendDirection } from "../../../Types/Dashboard/DashboardComponents/DashboardValueComponent";
123
+ import DashboardVariable, {
124
+ DashboardVariableType,
125
+ } from "../../../Types/Dashboard/DashboardVariable";
126
+ import DashboardViewConfig from "../../../Types/Dashboard/DashboardViewConfig";
127
+ import DataSourceQueryConfig from "../../../Types/DataSource/DataSourceQueryConfig";
128
+ import DataSourceTableResult, {
129
+ DataSourceTableColumnType,
130
+ } from "../../../Types/DataSource/DataSourceTableResult";
131
+ import { ObjectType } from "../../../Types/JSON";
132
+ import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
133
+ import MetricViewData from "../../../Types/Metrics/MetricViewData";
134
+ import ObjectID from "../../../Types/ObjectID";
135
+ import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
136
+ import TimeRange from "../../../Types/Time/TimeRange";
137
+
138
+ interface MetricChartsStubProps {
139
+ metricResults: Array<AggregatedResult>;
140
+ metricViewData: MetricViewData;
141
+ hideCard?: boolean | undefined;
142
+ topNOverrideScope?: string | undefined;
143
+ }
144
+
145
+ /** One recorded call into the data source query client. */
146
+ interface DataSourceFetchArgs {
147
+ queryConfig: DataSourceQueryConfig;
148
+ startDate: Date;
149
+ endDate: Date;
150
+ }
151
+
152
+ const COMPONENT_ID: ObjectID = new ObjectID(
153
+ "11111111-1111-4111-8111-111111111111",
154
+ );
155
+
156
+ const START_DATE: Date = new Date("2026-08-10T09:00:00.000Z");
157
+ const END_DATE: Date = new Date("2026-08-10T10:00:00.000Z");
158
+
159
+ /*
160
+ * A CUSTOM range pins the window to fixed instants — a relative range would
161
+ * resolve against the wall clock and make the startDate/endDate assertions
162
+ * untestable.
163
+ */
164
+ const DASHBOARD_RANGE: RangeStartAndEndDateTime = {
165
+ range: TimeRange.CUSTOM,
166
+ startAndEndDate: new InBetween<Date>(START_DATE, END_DATE),
167
+ };
168
+
169
+ const DASHBOARD_VIEW_CONFIG: DashboardViewConfig = {
170
+ _type: ObjectType.DashboardViewConfig,
171
+ components: [],
172
+ heightInDashboardUnits: 60,
173
+ };
174
+
175
+ type BuildBasePropsFunction = (
176
+ overrides?: Partial<DashboardBaseComponentProps>,
177
+ ) => DashboardBaseComponentProps;
178
+
179
+ const buildBaseProps: BuildBasePropsFunction = (
180
+ overrides: Partial<DashboardBaseComponentProps> = {},
181
+ ): DashboardBaseComponentProps => {
182
+ return {
183
+ componentId: COMPONENT_ID,
184
+ isEditMode: false,
185
+ isSelected: false,
186
+ key: "data-source-widget",
187
+ onComponentUpdate: (): void => {
188
+ // The widgets under test never write back through this.
189
+ },
190
+ totalCurrentDashboardWidthInPx: 1200,
191
+ dashboardCanvasTopInPx: 0,
192
+ dashboardCanvasLeftInPx: 0,
193
+ dashboardCanvasWidthInPx: 1200,
194
+ dashboardCanvasHeightInPx: 800,
195
+ dashboardComponentHeightInPx: 320,
196
+ dashboardComponentWidthInPx: 480,
197
+ dashboardViewConfig: DASHBOARD_VIEW_CONFIG,
198
+ dashboardStartAndEndDate: DASHBOARD_RANGE,
199
+ metricTypes: [],
200
+ refreshTick: 0,
201
+ variables: undefined,
202
+ ...overrides,
203
+ };
204
+ };
205
+
206
+ type BuildPointFunction = (
207
+ value: number | null,
208
+ timestamp: string,
209
+ attributes?: Record<string, string>,
210
+ ) => AggregatedModel;
211
+
212
+ const buildPoint: BuildPointFunction = (
213
+ value: number | null,
214
+ timestamp: string,
215
+ attributes?: Record<string, string>,
216
+ ): AggregatedModel => {
217
+ return {
218
+ timestamp: new Date(timestamp),
219
+ value: value as number,
220
+ ...(attributes ? { attributes: attributes } : {}),
221
+ } as AggregatedModel;
222
+ };
223
+
224
+ type BuildSeriesFunction = (
225
+ points: Array<AggregatedModel>,
226
+ errorMessage?: string,
227
+ ) => AggregatedResult;
228
+
229
+ const buildSeries: BuildSeriesFunction = (
230
+ points: Array<AggregatedModel>,
231
+ errorMessage?: string,
232
+ ): AggregatedResult => {
233
+ return {
234
+ data: points,
235
+ truncated: false,
236
+ ...(errorMessage ? { errorMessage: errorMessage } : {}),
237
+ };
238
+ };
239
+
240
+ type GetFetchCallsFunction = (mock: MockFunction) => Array<DataSourceFetchArgs>;
241
+
242
+ const getFetchCalls: GetFetchCallsFunction = (
243
+ mock: MockFunction,
244
+ ): Array<DataSourceFetchArgs> => {
245
+ return mock.mock.calls.map((call: Array<unknown>): DataSourceFetchArgs => {
246
+ return call[0] as DataSourceFetchArgs;
247
+ });
248
+ };
249
+
250
+ type RenderValueFunction = (
251
+ args: DashboardDataSourceValueComponent["arguments"],
252
+ overrides?: Partial<DashboardBaseComponentProps>,
253
+ ) => RenderResult;
254
+
255
+ const renderValueWidget: RenderValueFunction = (
256
+ args: DashboardDataSourceValueComponent["arguments"],
257
+ overrides: Partial<DashboardBaseComponentProps> = {},
258
+ ): RenderResult => {
259
+ const component: DashboardDataSourceValueComponent = {
260
+ _type: ObjectType.DashboardComponent,
261
+ componentId: COMPONENT_ID,
262
+ componentType: DashboardComponentType.DataSourceValue,
263
+ topInDashboardUnits: 0,
264
+ leftInDashboardUnits: 0,
265
+ widthInDashboardUnits: 6,
266
+ heightInDashboardUnits: 4,
267
+ minWidthInDashboardUnits: 3,
268
+ minHeightInDashboardUnits: 2,
269
+ arguments: args,
270
+ };
271
+
272
+ return render(
273
+ <DashboardDataSourceValueComponentElement
274
+ {...buildBaseProps(overrides)}
275
+ component={component}
276
+ />,
277
+ );
278
+ };
279
+
280
+ type RenderGaugeFunction = (
281
+ args: DashboardDataSourceGaugeComponent["arguments"],
282
+ overrides?: Partial<DashboardBaseComponentProps>,
283
+ ) => RenderResult;
284
+
285
+ const renderGaugeWidget: RenderGaugeFunction = (
286
+ args: DashboardDataSourceGaugeComponent["arguments"],
287
+ overrides: Partial<DashboardBaseComponentProps> = {},
288
+ ): RenderResult => {
289
+ const component: DashboardDataSourceGaugeComponent = {
290
+ _type: ObjectType.DashboardComponent,
291
+ componentId: COMPONENT_ID,
292
+ componentType: DashboardComponentType.DataSourceGauge,
293
+ topInDashboardUnits: 0,
294
+ leftInDashboardUnits: 0,
295
+ widthInDashboardUnits: 6,
296
+ heightInDashboardUnits: 4,
297
+ minWidthInDashboardUnits: 3,
298
+ minHeightInDashboardUnits: 2,
299
+ arguments: args,
300
+ };
301
+
302
+ return render(
303
+ <DashboardDataSourceGaugeComponentElement
304
+ {...buildBaseProps(overrides)}
305
+ component={component}
306
+ />,
307
+ );
308
+ };
309
+
310
+ type RenderTableFunction = (
311
+ args: DashboardDataSourceTableComponent["arguments"],
312
+ overrides?: Partial<DashboardBaseComponentProps>,
313
+ ) => RenderResult;
314
+
315
+ const renderTableWidget: RenderTableFunction = (
316
+ args: DashboardDataSourceTableComponent["arguments"],
317
+ overrides: Partial<DashboardBaseComponentProps> = {},
318
+ ): RenderResult => {
319
+ const component: DashboardDataSourceTableComponent = {
320
+ _type: ObjectType.DashboardComponent,
321
+ componentId: COMPONENT_ID,
322
+ componentType: DashboardComponentType.DataSourceTable,
323
+ topInDashboardUnits: 0,
324
+ leftInDashboardUnits: 0,
325
+ widthInDashboardUnits: 8,
326
+ heightInDashboardUnits: 6,
327
+ minWidthInDashboardUnits: 4,
328
+ minHeightInDashboardUnits: 3,
329
+ arguments: args,
330
+ };
331
+
332
+ return render(
333
+ <DashboardDataSourceTableComponentElement
334
+ {...buildBaseProps(overrides)}
335
+ component={component}
336
+ />,
337
+ );
338
+ };
339
+
340
+ type RenderChartFunction = (
341
+ args: DashboardDataSourceChartComponent["arguments"],
342
+ overrides?: Partial<DashboardBaseComponentProps>,
343
+ ) => RenderResult;
344
+
345
+ const renderChartWidget: RenderChartFunction = (
346
+ args: DashboardDataSourceChartComponent["arguments"],
347
+ overrides: Partial<DashboardBaseComponentProps> = {},
348
+ ): RenderResult => {
349
+ const component: DashboardDataSourceChartComponent = {
350
+ _type: ObjectType.DashboardComponent,
351
+ componentId: COMPONENT_ID,
352
+ componentType: DashboardComponentType.DataSourceChart,
353
+ topInDashboardUnits: 0,
354
+ leftInDashboardUnits: 0,
355
+ widthInDashboardUnits: 8,
356
+ heightInDashboardUnits: 6,
357
+ minWidthInDashboardUnits: 4,
358
+ minHeightInDashboardUnits: 3,
359
+ arguments: args,
360
+ };
361
+
362
+ return render(
363
+ <DashboardDataSourceChartComponentElement
364
+ {...buildBaseProps(overrides)}
365
+ component={component}
366
+ />,
367
+ );
368
+ };
369
+
370
+ /*
371
+ * The render helpers above mount and forget. The staleness and refresh-tick
372
+ * tests need to hand the SAME element type back to `rerender` with different
373
+ * base props, so they build the element instead of rendering it.
374
+ */
375
+ type BuildValueElementFunction = (
376
+ args: DashboardDataSourceValueComponent["arguments"],
377
+ overrides?: Partial<DashboardBaseComponentProps>,
378
+ ) => React.ReactElement;
379
+
380
+ const buildValueElement: BuildValueElementFunction = (
381
+ args: DashboardDataSourceValueComponent["arguments"],
382
+ overrides: Partial<DashboardBaseComponentProps> = {},
383
+ ): React.ReactElement => {
384
+ const component: DashboardDataSourceValueComponent = {
385
+ _type: ObjectType.DashboardComponent,
386
+ componentId: COMPONENT_ID,
387
+ componentType: DashboardComponentType.DataSourceValue,
388
+ topInDashboardUnits: 0,
389
+ leftInDashboardUnits: 0,
390
+ widthInDashboardUnits: 6,
391
+ heightInDashboardUnits: 4,
392
+ minWidthInDashboardUnits: 3,
393
+ minHeightInDashboardUnits: 2,
394
+ arguments: args,
395
+ };
396
+
397
+ return (
398
+ <DashboardDataSourceValueComponentElement
399
+ {...buildBaseProps(overrides)}
400
+ component={component}
401
+ />
402
+ );
403
+ };
404
+
405
+ type BuildTableElementFunction = (
406
+ args: DashboardDataSourceTableComponent["arguments"],
407
+ overrides?: Partial<DashboardBaseComponentProps>,
408
+ ) => React.ReactElement;
409
+
410
+ const buildTableElement: BuildTableElementFunction = (
411
+ args: DashboardDataSourceTableComponent["arguments"],
412
+ overrides: Partial<DashboardBaseComponentProps> = {},
413
+ ): React.ReactElement => {
414
+ const component: DashboardDataSourceTableComponent = {
415
+ _type: ObjectType.DashboardComponent,
416
+ componentId: COMPONENT_ID,
417
+ componentType: DashboardComponentType.DataSourceTable,
418
+ topInDashboardUnits: 0,
419
+ leftInDashboardUnits: 0,
420
+ widthInDashboardUnits: 8,
421
+ heightInDashboardUnits: 6,
422
+ minWidthInDashboardUnits: 4,
423
+ minHeightInDashboardUnits: 3,
424
+ arguments: args,
425
+ };
426
+
427
+ return (
428
+ <DashboardDataSourceTableComponentElement
429
+ {...buildBaseProps(overrides)}
430
+ component={component}
431
+ />
432
+ );
433
+ };
434
+
435
+ type BuildChartElementFunction = (
436
+ args: DashboardDataSourceChartComponent["arguments"],
437
+ overrides?: Partial<DashboardBaseComponentProps>,
438
+ ) => React.ReactElement;
439
+
440
+ const buildChartElement: BuildChartElementFunction = (
441
+ args: DashboardDataSourceChartComponent["arguments"],
442
+ overrides: Partial<DashboardBaseComponentProps> = {},
443
+ ): React.ReactElement => {
444
+ const component: DashboardDataSourceChartComponent = {
445
+ _type: ObjectType.DashboardComponent,
446
+ componentId: COMPONENT_ID,
447
+ componentType: DashboardComponentType.DataSourceChart,
448
+ topInDashboardUnits: 0,
449
+ leftInDashboardUnits: 0,
450
+ widthInDashboardUnits: 8,
451
+ heightInDashboardUnits: 6,
452
+ minWidthInDashboardUnits: 4,
453
+ minHeightInDashboardUnits: 3,
454
+ arguments: args,
455
+ };
456
+
457
+ return (
458
+ <DashboardDataSourceChartComponentElement
459
+ {...buildBaseProps(overrides)}
460
+ component={component}
461
+ />
462
+ );
463
+ };
464
+
465
+ /**
466
+ * A promise whose settlement this test file controls, so two fetches can be
467
+ * left in flight at once and completed in whatever order the test needs.
468
+ */
469
+ interface DeferredPromise<T> {
470
+ promise: Promise<T>;
471
+ resolve: (value: T) => void;
472
+ reject: (error: Error) => void;
473
+ }
474
+
475
+ type CreateDeferredFunction = <T>() => DeferredPromise<T>;
476
+
477
+ const createDeferred: CreateDeferredFunction = <T,>(): DeferredPromise<T> => {
478
+ let resolveFunction: (value: T) => void = (): void => {
479
+ // Replaced synchronously by the executor below.
480
+ };
481
+ let rejectFunction: (error: Error) => void = (): void => {
482
+ // Replaced synchronously by the executor below.
483
+ };
484
+
485
+ const promise: Promise<T> = new Promise<T>(
486
+ (resolve: (value: T) => void, reject: (error: Error) => void): void => {
487
+ resolveFunction = resolve;
488
+ rejectFunction = reject;
489
+ },
490
+ );
491
+
492
+ return {
493
+ promise: promise,
494
+ resolve: resolveFunction,
495
+ reject: rejectFunction,
496
+ };
497
+ };
498
+
499
+ /** Settle a deferred fetch and let React commit whatever it caused. */
500
+ type SettleFunction = (settle: () => void) => Promise<void>;
501
+
502
+ const settleAndFlush: SettleFunction = async (
503
+ settle: () => void,
504
+ ): Promise<void> => {
505
+ await act(async (): Promise<void> => {
506
+ settle();
507
+ });
508
+ };
509
+
510
+ /** The `value` of the first point of the first result MetricCharts was given. */
511
+ type GetChartRenderedValuesFunction = () => Array<number | undefined>;
512
+
513
+ const getChartRenderedValues: GetChartRenderedValuesFunction = (): Array<
514
+ number | undefined
515
+ > => {
516
+ return metricChartsRenderMock.mock.calls.map(
517
+ (call: Array<unknown>): number | undefined => {
518
+ const chartProps: MetricChartsStubProps =
519
+ call[0] as MetricChartsStubProps;
520
+ return chartProps.metricResults[0]?.data[0]?.value;
521
+ },
522
+ );
523
+ };
524
+
525
+ /** The "N rows (truncated)" counter in the table widget's header. */
526
+ type GetRowCountLabelFunction = (container: HTMLElement) => string;
527
+
528
+ const getRowCountLabel: GetRowCountLabelFunction = (
529
+ container: HTMLElement,
530
+ ): string => {
531
+ const label: Element | null = container.querySelector("span.tabular-nums");
532
+
533
+ if (!label) {
534
+ throw new Error("Table widget did not render a row count label.");
535
+ }
536
+
537
+ return (label.textContent || "").trim();
538
+ };
539
+
540
+ /** Rendered table body, cell text by row, in DOM order. */
541
+ type GetRenderedRowsFunction = (container: HTMLElement) => Array<Array<string>>;
542
+
543
+ const getRenderedRows: GetRenderedRowsFunction = (
544
+ container: HTMLElement,
545
+ ): Array<Array<string>> => {
546
+ return Array.from(container.querySelectorAll("tbody tr")).map(
547
+ (row: Element): Array<string> => {
548
+ return Array.from(row.querySelectorAll("td")).map(
549
+ (cell: Element): string => {
550
+ return (cell.textContent || "").trim();
551
+ },
552
+ );
553
+ },
554
+ );
555
+ };
556
+
557
+ const TABLE_RESULT: DataSourceTableResult = {
558
+ columns: [
559
+ { key: "service", title: "Service", type: DataSourceTableColumnType.Text },
560
+ { key: "errors", title: "Errors", type: DataSourceTableColumnType.Number },
561
+ ],
562
+ rows: [
563
+ { service: "checkout", errors: 12 },
564
+ { service: "search", errors: 3 },
565
+ { service: "billing", errors: 41 },
566
+ { service: "auth", errors: 7 },
567
+ ],
568
+ truncated: false,
569
+ };
570
+
571
+ const PUBLIC_DASHBOARD_CONTEXT: PublicDashboardContext = {
572
+ dashboardId: new ObjectID("22222222-2222-4222-8222-222222222222"),
573
+ apiUrl: {
574
+ toString: (): string => {
575
+ return "http://localhost/public-dashboard-api";
576
+ },
577
+ },
578
+ postJSON: () => {
579
+ throw new Error(
580
+ "A data source widget reached the public dashboard API. It must not.",
581
+ );
582
+ },
583
+ } as unknown as PublicDashboardContext;
584
+
585
+ beforeEach((): void => {
586
+ jest.clearAllMocks();
587
+ fetchTimeSeriesMock.mockResolvedValue(buildSeries([]));
588
+ fetchTableMock.mockResolvedValue({
589
+ columns: [],
590
+ rows: [],
591
+ truncated: false,
592
+ });
593
+ });
594
+
595
+ afterEach((): void => {
596
+ cleanup();
597
+ setPublicDashboardContext(null);
598
+ });
599
+
600
+ describe("Data Source widget fetching", () => {
601
+ describe("unconfigured widgets never touch the query client", () => {
602
+ test("value widget with a blank data source renders its setup prompt", () => {
603
+ renderValueWidget({
604
+ query: { dataSourceId: "", query: "up" },
605
+ title: "Requests",
606
+ });
607
+
608
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
609
+ expect(
610
+ screen.getByText("Click to configure a query"),
611
+ ).toBeInTheDocument();
612
+ });
613
+
614
+ test("value widget with whitespace-only query text is not configured", () => {
615
+ renderValueWidget({
616
+ query: { dataSourceId: "ds-prom", query: " \n\t " },
617
+ title: "Requests",
618
+ });
619
+
620
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
621
+ expect(screen.getByText("Requests")).toBeInTheDocument();
622
+ expect(
623
+ screen.getByText("Click to configure a query"),
624
+ ).toBeInTheDocument();
625
+ });
626
+
627
+ test("value widget with no query object at all renders its setup prompt", () => {
628
+ renderValueWidget({ title: "Requests" });
629
+
630
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
631
+ expect(
632
+ screen.getByText("Click to configure a query"),
633
+ ).toBeInTheDocument();
634
+ });
635
+
636
+ test("gauge widget with whitespace-only query text is not configured", () => {
637
+ renderGaugeWidget({
638
+ query: { dataSourceId: "ds-prom", query: " " },
639
+ gaugeTitle: "CPU",
640
+ });
641
+
642
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
643
+ expect(screen.getByText("CPU")).toBeInTheDocument();
644
+ expect(
645
+ screen.getByText("Click to configure a query"),
646
+ ).toBeInTheDocument();
647
+ });
648
+
649
+ test("gauge widget falls back to its own setup title when untitled", () => {
650
+ renderGaugeWidget({ query: { dataSourceId: "", query: "" } });
651
+
652
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
653
+ expect(screen.getByText("Data Source Gauge")).toBeInTheDocument();
654
+ });
655
+
656
+ test("table widget with whitespace-only query text is not configured", () => {
657
+ renderTableWidget({
658
+ query: { dataSourceId: "ds-pg", query: "\t \n" },
659
+ tableTitle: "Slow queries",
660
+ });
661
+
662
+ expect(fetchTableMock).not.toHaveBeenCalled();
663
+ expect(screen.getByText("Slow queries")).toBeInTheDocument();
664
+ expect(
665
+ screen.getByText("Click to configure a query"),
666
+ ).toBeInTheDocument();
667
+ });
668
+
669
+ test("table widget with a blank data source renders its setup prompt", () => {
670
+ renderTableWidget({ query: { dataSourceId: " ", query: "select 1" } });
671
+
672
+ expect(fetchTableMock).not.toHaveBeenCalled();
673
+ expect(screen.getByText("Data Source Table")).toBeInTheDocument();
674
+ });
675
+
676
+ /*
677
+ * A prompt, not an error tile. Every chart is seeded with one blank
678
+ * query card, so this is the state the widget is in the instant it is
679
+ * added — reporting it as a failure blames the user for the default.
680
+ */
681
+ test("chart widget with only half-filled query cards asks to be configured", () => {
682
+ renderChartWidget({
683
+ queries: [
684
+ { id: "q1", dataSourceId: "ds-prom", query: " " },
685
+ { id: "q2", dataSourceId: "", query: "up" },
686
+ ],
687
+ chartTitle: "Throughput",
688
+ });
689
+
690
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
691
+ expect(
692
+ screen.getByText("Click to configure a query"),
693
+ ).toBeInTheDocument();
694
+ expect(screen.getByText("Throughput")).toBeInTheDocument();
695
+ expect(
696
+ screen.queryByText("Please configure a data source query."),
697
+ ).not.toBeInTheDocument();
698
+ });
699
+
700
+ test("chart widget with no queries array at all asks to be configured", () => {
701
+ renderChartWidget({ chartTitle: "Throughput" });
702
+
703
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
704
+ expect(
705
+ screen.getByText("Click to configure a query"),
706
+ ).toBeInTheDocument();
707
+ });
708
+
709
+ // Untitled, so the prompt has to name the widget itself.
710
+ test("an untitled unconfigured chart names itself in the prompt", () => {
711
+ renderChartWidget({ queries: [] });
712
+
713
+ expect(screen.getByText("Data Source Chart")).toBeInTheDocument();
714
+ expect(
715
+ screen.getByText("Click to configure a query"),
716
+ ).toBeInTheDocument();
717
+ });
718
+ });
719
+
720
+ describe("configured widgets query once, for the dashboard's window", () => {
721
+ test("value widget sends the data source id and the resolved time range", async () => {
722
+ fetchTimeSeriesMock.mockResolvedValue(
723
+ buildSeries([buildPoint(42, "2026-08-10T09:30:00.000Z")]),
724
+ );
725
+
726
+ const rendered: RenderResult = renderValueWidget({
727
+ query: { dataSourceId: "ds-prom", query: "up" },
728
+ title: "Up",
729
+ });
730
+
731
+ await waitFor((): void => {
732
+ expect(rendered.container.textContent).toContain("42");
733
+ });
734
+
735
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
736
+ const call: DataSourceFetchArgs = getFetchCalls(fetchTimeSeriesMock)[0]!;
737
+ expect(call.queryConfig.dataSourceId).toBe("ds-prom");
738
+ expect(call.queryConfig.query).toBe("up");
739
+ expect(call.startDate).toEqual(START_DATE);
740
+ expect(call.endDate).toEqual(END_DATE);
741
+ });
742
+
743
+ test("table widget sends the same window through the table endpoint", async () => {
744
+ fetchTableMock.mockResolvedValue(TABLE_RESULT);
745
+
746
+ const rendered: RenderResult = renderTableWidget({
747
+ query: { dataSourceId: "ds-pg", query: "select 1" },
748
+ });
749
+
750
+ await waitFor((): void => {
751
+ expect(getRenderedRows(rendered.container)).toHaveLength(4);
752
+ });
753
+
754
+ expect(fetchTableMock).toHaveBeenCalledTimes(1);
755
+ const call: DataSourceFetchArgs = getFetchCalls(fetchTableMock)[0]!;
756
+ expect(call.queryConfig.dataSourceId).toBe("ds-pg");
757
+ expect(call.startDate).toEqual(START_DATE);
758
+ expect(call.endDate).toEqual(END_DATE);
759
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
760
+ });
761
+
762
+ test("gauge widget reduces the returned series into one number", async () => {
763
+ fetchTimeSeriesMock.mockResolvedValue(
764
+ buildSeries([
765
+ buildPoint(10, "2026-08-10T09:10:00.000Z"),
766
+ buildPoint(80, "2026-08-10T09:20:00.000Z"),
767
+ ]),
768
+ );
769
+
770
+ const rendered: RenderResult = renderGaugeWidget({
771
+ query: { dataSourceId: "ds-prom", query: "cpu" },
772
+ gaugeTitle: "CPU",
773
+ reduce: DataSourceValueReduce.Max,
774
+ });
775
+
776
+ await waitFor((): void => {
777
+ expect(rendered.container.textContent).toContain("80");
778
+ });
779
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
780
+ });
781
+
782
+ test("value widget reduces out of TIME order, not array order", async () => {
783
+ /*
784
+ * A hand-written SQL query with no ORDER BY can return anything; Last
785
+ * has to mean "latest timestamp", not "last row the driver emitted".
786
+ */
787
+ fetchTimeSeriesMock.mockResolvedValue(
788
+ buildSeries([
789
+ buildPoint(7, "2026-08-10T09:50:00.000Z"),
790
+ buildPoint(3, "2026-08-10T09:05:00.000Z"),
791
+ ]),
792
+ );
793
+
794
+ const rendered: RenderResult = renderValueWidget({
795
+ query: { dataSourceId: "ds-pg", query: "select value from t" },
796
+ title: "Latest",
797
+ reduce: DataSourceValueReduce.Last,
798
+ });
799
+
800
+ await waitFor((): void => {
801
+ expect(rendered.container.textContent).toContain("7");
802
+ });
803
+ });
804
+
805
+ test("a configured query that returns nothing says so instead of showing zero", async () => {
806
+ fetchTimeSeriesMock.mockResolvedValue(buildSeries([]));
807
+
808
+ renderValueWidget({
809
+ query: { dataSourceId: "ds-prom", query: "up" },
810
+ title: "Up",
811
+ });
812
+
813
+ await waitFor((): void => {
814
+ expect(
815
+ screen.getByText("No data for the selected time range"),
816
+ ).toBeInTheDocument();
817
+ });
818
+ });
819
+
820
+ test("a connector's own error message beats the generic no-data text", async () => {
821
+ fetchTimeSeriesMock.mockResolvedValue(
822
+ buildSeries([], "PromQL parse error at char 4"),
823
+ );
824
+
825
+ renderValueWidget({
826
+ query: { dataSourceId: "ds-prom", query: "up{" },
827
+ title: "Up",
828
+ });
829
+
830
+ await waitFor((): void => {
831
+ expect(
832
+ screen.getByText("PromQL parse error at char 4"),
833
+ ).toBeInTheDocument();
834
+ });
835
+ });
836
+
837
+ test("non-finite points are discarded rather than poisoning the reduction", async () => {
838
+ /*
839
+ * A SQL NULL or a missing column arrives as null on a field typed
840
+ * `number` — averaged unchecked it renders NaN.
841
+ */
842
+ fetchTimeSeriesMock.mockResolvedValue(
843
+ buildSeries([
844
+ buildPoint(null, "2026-08-10T09:10:00.000Z"),
845
+ buildPoint(10, "2026-08-10T09:20:00.000Z"),
846
+ buildPoint(30, "2026-08-10T09:30:00.000Z"),
847
+ ]),
848
+ );
849
+
850
+ const rendered: RenderResult = renderValueWidget({
851
+ query: { dataSourceId: "ds-pg", query: "select value from t" },
852
+ title: "Average",
853
+ reduce: DataSourceValueReduce.Avg,
854
+ });
855
+
856
+ await waitFor((): void => {
857
+ expect(rendered.container.textContent).toContain("20");
858
+ });
859
+ expect(rendered.container.textContent).not.toContain("NaN");
860
+ });
861
+ });
862
+
863
+ describe("dashboard variables are interpolated before the request leaves", () => {
864
+ const ENV_VARIABLE: DashboardVariable = {
865
+ id: "var-env",
866
+ name: "env",
867
+ type: DashboardVariableType.TextInput,
868
+ selectedValue: "production",
869
+ };
870
+
871
+ test("value widget replaces {{env}} in the query it sends", async () => {
872
+ renderValueWidget(
873
+ {
874
+ query: {
875
+ dataSourceId: "ds-prom",
876
+ query: 'sum(rate(http_requests_total{env="{{env}}"}[5m]))',
877
+ },
878
+ title: "Requests",
879
+ },
880
+ { variables: [ENV_VARIABLE] },
881
+ );
882
+
883
+ await waitFor((): void => {
884
+ expect(
885
+ screen.getByText("No data for the selected time range"),
886
+ ).toBeInTheDocument();
887
+ });
888
+
889
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
890
+ expect(getFetchCalls(fetchTimeSeriesMock)[0]!.queryConfig.query).toBe(
891
+ 'sum(rate(http_requests_total{env="production"}[5m]))',
892
+ );
893
+ });
894
+
895
+ test("table widget interpolates too, and keeps the data source id", async () => {
896
+ fetchTableMock.mockResolvedValue(TABLE_RESULT);
897
+
898
+ const rendered: RenderResult = renderTableWidget(
899
+ {
900
+ query: {
901
+ dataSourceId: "ds-pg",
902
+ query: "select * from deploys where env = '{{ env }}'",
903
+ },
904
+ },
905
+ { variables: [ENV_VARIABLE] },
906
+ );
907
+
908
+ await waitFor((): void => {
909
+ expect(getRenderedRows(rendered.container)).toHaveLength(4);
910
+ });
911
+
912
+ expect(fetchTableMock).toHaveBeenCalledTimes(1);
913
+ const call: DataSourceFetchArgs = getFetchCalls(fetchTableMock)[0]!;
914
+ expect(call.queryConfig.query).toBe(
915
+ "select * from deploys where env = 'production'",
916
+ );
917
+ expect(call.queryConfig.dataSourceId).toBe("ds-pg");
918
+ });
919
+
920
+ test("a multi-select variable joins its values with regex alternation", async () => {
921
+ const clusterVariable: DashboardVariable = {
922
+ id: "var-cluster",
923
+ name: "cluster",
924
+ type: DashboardVariableType.CustomList,
925
+ isMultiSelect: true,
926
+ selectedValues: ["eu-1", "us-2"],
927
+ };
928
+
929
+ renderGaugeWidget(
930
+ {
931
+ query: {
932
+ dataSourceId: "ds-prom",
933
+ query: 'up{cluster=~"{{cluster}}"}',
934
+ },
935
+ },
936
+ { variables: [clusterVariable] },
937
+ );
938
+
939
+ await waitFor((): void => {
940
+ expect(
941
+ screen.getByText("No data for the selected time range"),
942
+ ).toBeInTheDocument();
943
+ });
944
+
945
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
946
+ expect(getFetchCalls(fetchTimeSeriesMock)[0]!.queryConfig.query).toBe(
947
+ 'up{cluster=~"eu-1|us-2"}',
948
+ );
949
+ });
950
+
951
+ test("an unresolved variable leaves its token intact for the connector to complain about", async () => {
952
+ const emptyVariable: DashboardVariable = {
953
+ id: "var-env",
954
+ name: "env",
955
+ type: DashboardVariableType.TextInput,
956
+ };
957
+
958
+ renderValueWidget(
959
+ {
960
+ query: { dataSourceId: "ds-prom", query: 'up{env="{{env}}"}' },
961
+ title: "Up",
962
+ },
963
+ { variables: [emptyVariable] },
964
+ );
965
+
966
+ await waitFor((): void => {
967
+ expect(
968
+ screen.getByText("No data for the selected time range"),
969
+ ).toBeInTheDocument();
970
+ });
971
+
972
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
973
+ expect(getFetchCalls(fetchTimeSeriesMock)[0]!.queryConfig.query).toBe(
974
+ 'up{env="{{env}}"}',
975
+ );
976
+ });
977
+ });
978
+
979
+ describe("public dashboards are a hard boundary, not a cosmetic one", () => {
980
+ beforeEach((): void => {
981
+ setPublicDashboardContext(PUBLIC_DASHBOARD_CONTEXT);
982
+ });
983
+
984
+ test("value widget renders the placeholder and issues no request", () => {
985
+ renderValueWidget({
986
+ query: { dataSourceId: "ds-prom", query: "up" },
987
+ title: "Up",
988
+ });
989
+
990
+ expect(
991
+ screen.getByText(/not available on public dashboards/i),
992
+ ).toBeInTheDocument();
993
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
994
+ });
995
+
996
+ test("gauge widget renders the placeholder and issues no request", () => {
997
+ renderGaugeWidget({
998
+ query: { dataSourceId: "ds-prom", query: "cpu" },
999
+ gaugeTitle: "CPU",
1000
+ });
1001
+
1002
+ expect(
1003
+ screen.getByText(/not available on public dashboards/i),
1004
+ ).toBeInTheDocument();
1005
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
1006
+ });
1007
+
1008
+ test("table widget renders the placeholder and issues no request", () => {
1009
+ renderTableWidget({
1010
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1011
+ tableTitle: "Slow queries",
1012
+ });
1013
+
1014
+ expect(
1015
+ screen.getByText(/not available on public dashboards/i),
1016
+ ).toBeInTheDocument();
1017
+ expect(fetchTableMock).not.toHaveBeenCalled();
1018
+ expect(screen.queryByText("Slow queries")).not.toBeInTheDocument();
1019
+ });
1020
+
1021
+ test("chart widget renders the placeholder and issues no request", () => {
1022
+ renderChartWidget({
1023
+ queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
1024
+ chartTitle: "Throughput",
1025
+ });
1026
+
1027
+ expect(
1028
+ screen.getByText(/not available on public dashboards/i),
1029
+ ).toBeInTheDocument();
1030
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
1031
+ expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
1032
+ });
1033
+ });
1034
+
1035
+ describe("a rejected fetch surfaces a message instead of throwing", () => {
1036
+ test("value widget renders the friendly error", async () => {
1037
+ fetchTimeSeriesMock.mockRejectedValue(
1038
+ new Error("Prometheus refused the connection"),
1039
+ );
1040
+
1041
+ renderValueWidget({
1042
+ query: { dataSourceId: "ds-prom", query: "up" },
1043
+ title: "Up",
1044
+ });
1045
+
1046
+ await waitFor((): void => {
1047
+ expect(
1048
+ screen.getByText("Prometheus refused the connection"),
1049
+ ).toBeInTheDocument();
1050
+ });
1051
+ });
1052
+
1053
+ test("gauge widget renders the friendly error", async () => {
1054
+ fetchTimeSeriesMock.mockRejectedValue(new Error("Query timed out"));
1055
+
1056
+ renderGaugeWidget({
1057
+ query: { dataSourceId: "ds-prom", query: "cpu" },
1058
+ gaugeTitle: "CPU",
1059
+ });
1060
+
1061
+ await waitFor((): void => {
1062
+ expect(screen.getByText("Query timed out")).toBeInTheDocument();
1063
+ });
1064
+ });
1065
+
1066
+ test("table widget renders the friendly error instead of an empty grid", async () => {
1067
+ fetchTableMock.mockRejectedValue(
1068
+ new Error('relation "deploys" does not exist'),
1069
+ );
1070
+
1071
+ const rendered: RenderResult = renderTableWidget({
1072
+ query: { dataSourceId: "ds-pg", query: "select * from deploys" },
1073
+ });
1074
+
1075
+ await waitFor((): void => {
1076
+ expect(
1077
+ screen.getByText('relation "deploys" does not exist'),
1078
+ ).toBeInTheDocument();
1079
+ });
1080
+ expect(rendered.container.querySelector("table")).toBeNull();
1081
+ });
1082
+
1083
+ test("chart widget renders the friendly error and no chart", async () => {
1084
+ fetchTimeSeriesMock.mockRejectedValue(new Error("Data source not found"));
1085
+
1086
+ renderChartWidget({
1087
+ queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
1088
+ });
1089
+
1090
+ await waitFor((): void => {
1091
+ expect(screen.getByText("Data source not found")).toBeInTheDocument();
1092
+ });
1093
+ expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
1094
+ });
1095
+ });
1096
+
1097
+ describe("table widget row cap", () => {
1098
+ beforeEach((): void => {
1099
+ fetchTableMock.mockResolvedValue(TABLE_RESULT);
1100
+ });
1101
+
1102
+ test("renders every row and no truncation flag when maxRows is unset", async () => {
1103
+ const rendered: RenderResult = renderTableWidget({
1104
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1105
+ });
1106
+
1107
+ await waitFor((): void => {
1108
+ expect(getRenderedRows(rendered.container)).toHaveLength(4);
1109
+ });
1110
+ expect(getRowCountLabel(rendered.container)).toBe("4 rows");
1111
+ });
1112
+
1113
+ test("trims to maxRows and flags the result as truncated", async () => {
1114
+ const rendered: RenderResult = renderTableWidget({
1115
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1116
+ maxRows: 2,
1117
+ });
1118
+
1119
+ await waitFor((): void => {
1120
+ expect(getRenderedRows(rendered.container)).toHaveLength(2);
1121
+ });
1122
+ expect(getRowCountLabel(rendered.container)).toBe("2 rows (truncated)");
1123
+ });
1124
+
1125
+ test("trims AFTER sorting, so the cap means top-N by the sorted column", async () => {
1126
+ const rendered: RenderResult = renderTableWidget({
1127
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1128
+ maxRows: 2,
1129
+ });
1130
+
1131
+ await waitFor((): void => {
1132
+ expect(getRenderedRows(rendered.container)).toHaveLength(2);
1133
+ });
1134
+
1135
+ // Unsorted: the first two rows the query returned.
1136
+ expect(
1137
+ getRenderedRows(rendered.container).map(
1138
+ (row: Array<string>): string => {
1139
+ return row[0]!;
1140
+ },
1141
+ ),
1142
+ ).toEqual(["checkout", "search"]);
1143
+
1144
+ fireEvent.click(screen.getByRole("columnheader", { name: /Errors/ }));
1145
+
1146
+ // Ascending by error count: the two smallest, not the first two rows.
1147
+ expect(
1148
+ getRenderedRows(rendered.container).map(
1149
+ (row: Array<string>): string => {
1150
+ return row[0]!;
1151
+ },
1152
+ ),
1153
+ ).toEqual(["search", "auth"]);
1154
+ expect(getRowCountLabel(rendered.container)).toBe("2 rows (truncated)");
1155
+ });
1156
+
1157
+ test("a maxRows of zero is treated as no cap at all", async () => {
1158
+ const rendered: RenderResult = renderTableWidget({
1159
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1160
+ maxRows: 0,
1161
+ });
1162
+
1163
+ await waitFor((): void => {
1164
+ expect(getRenderedRows(rendered.container)).toHaveLength(4);
1165
+ });
1166
+ expect(getRowCountLabel(rendered.container)).toBe("4 rows");
1167
+ });
1168
+
1169
+ test("a maxRows larger than the result set does not claim truncation", async () => {
1170
+ const rendered: RenderResult = renderTableWidget({
1171
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1172
+ maxRows: 50,
1173
+ });
1174
+
1175
+ await waitFor((): void => {
1176
+ expect(getRenderedRows(rendered.container)).toHaveLength(4);
1177
+ });
1178
+ expect(getRowCountLabel(rendered.container)).toBe("4 rows");
1179
+ });
1180
+
1181
+ test("the connector's own truncation is reported even without a widget cap", async () => {
1182
+ fetchTableMock.mockResolvedValue({
1183
+ ...TABLE_RESULT,
1184
+ truncated: true,
1185
+ });
1186
+
1187
+ const rendered: RenderResult = renderTableWidget({
1188
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1189
+ });
1190
+
1191
+ await waitFor((): void => {
1192
+ expect(getRenderedRows(rendered.container)).toHaveLength(4);
1193
+ });
1194
+ expect(getRowCountLabel(rendered.container)).toBe("4 rows (truncated)");
1195
+ });
1196
+
1197
+ test("a query returning no columns says so rather than drawing an empty table", async () => {
1198
+ fetchTableMock.mockResolvedValue({
1199
+ columns: [],
1200
+ rows: [],
1201
+ truncated: false,
1202
+ });
1203
+
1204
+ const rendered: RenderResult = renderTableWidget({
1205
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1206
+ });
1207
+
1208
+ await waitFor((): void => {
1209
+ expect(screen.getByText("No rows returned.")).toBeInTheDocument();
1210
+ });
1211
+ expect(rendered.container.querySelector("table")).toBeNull();
1212
+ expect(getRowCountLabel(rendered.container)).toBe("0 rows");
1213
+ });
1214
+ });
1215
+
1216
+ describe("chart widget fans out one request per configured query", () => {
1217
+ test("fires a request per query and ignores half-filled cards", async () => {
1218
+ fetchTimeSeriesMock.mockImplementation(
1219
+ (args: DataSourceFetchArgs): Promise<AggregatedResult> => {
1220
+ return Promise.resolve(
1221
+ buildSeries([
1222
+ buildPoint(1, "2026-08-10T09:15:00.000Z", {
1223
+ query: args.queryConfig.query,
1224
+ }),
1225
+ ]),
1226
+ );
1227
+ },
1228
+ );
1229
+
1230
+ renderChartWidget({
1231
+ queries: [
1232
+ { id: "q1", dataSourceId: "ds-prom", query: "up" },
1233
+ // Source picked, query still empty — the user is mid-edit.
1234
+ { id: "q2", dataSourceId: "ds-prom", query: " " },
1235
+ // Query typed, source never picked.
1236
+ { id: "q3", dataSourceId: "", query: "select 1" },
1237
+ { id: "q4", dataSourceId: "ds-pg", query: "select count(*) from t" },
1238
+ ],
1239
+ chartType: DashboardChartType.Line,
1240
+ });
1241
+
1242
+ await waitFor((): void => {
1243
+ expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
1244
+ });
1245
+
1246
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
1247
+ const calls: Array<DataSourceFetchArgs> =
1248
+ getFetchCalls(fetchTimeSeriesMock);
1249
+ expect(
1250
+ calls.map((call: DataSourceFetchArgs): string => {
1251
+ return call.queryConfig.query;
1252
+ }),
1253
+ ).toEqual(["up", "select count(*) from t"]);
1254
+ expect(
1255
+ calls.map((call: DataSourceFetchArgs): string => {
1256
+ return call.queryConfig.dataSourceId;
1257
+ }),
1258
+ ).toEqual(["ds-prom", "ds-pg"]);
1259
+ });
1260
+
1261
+ test("hands the chart layer one result and one synthetic config per ready query", async () => {
1262
+ fetchTimeSeriesMock.mockResolvedValue(
1263
+ buildSeries([
1264
+ buildPoint(5, "2026-08-10T09:15:00.000Z", { instance: "a:9090" }),
1265
+ ]),
1266
+ );
1267
+
1268
+ renderChartWidget({
1269
+ queries: [
1270
+ { id: "q1", dataSourceId: "ds-prom", query: "up" },
1271
+ { id: "q2", dataSourceId: "ds-prom", query: "" },
1272
+ { id: "q3", dataSourceId: "ds-prom", query: "rate(x[5m])" },
1273
+ ],
1274
+ });
1275
+
1276
+ await waitFor((): void => {
1277
+ expect(screen.getByTestId("metric-charts")).toHaveTextContent(
1278
+ "results:2 configs:2",
1279
+ );
1280
+ });
1281
+
1282
+ const lastProps: MetricChartsStubProps = metricChartsRenderMock.mock
1283
+ .calls[
1284
+ metricChartsRenderMock.mock.calls.length - 1
1285
+ ]![0] as MetricChartsStubProps;
1286
+
1287
+ expect(
1288
+ lastProps.metricViewData.queryConfigs.map(
1289
+ (config: MetricQueryConfigData): string | undefined => {
1290
+ return config.id;
1291
+ },
1292
+ ),
1293
+ ).toEqual(["q1", "q3"]);
1294
+ expect(
1295
+ lastProps.metricViewData.queryConfigs[0]!.metricQueryData
1296
+ .groupByAttributeKeys,
1297
+ ).toEqual(["instance"]);
1298
+ expect(lastProps.topNOverrideScope).toBe(COMPONENT_ID.toString());
1299
+ });
1300
+
1301
+ test("one failing query fails the widget rather than drawing a partial chart", async () => {
1302
+ fetchTimeSeriesMock
1303
+ .mockResolvedValueOnce(
1304
+ buildSeries([buildPoint(1, "2026-08-10T09:15:00.000Z")]),
1305
+ )
1306
+ .mockRejectedValueOnce(new Error("Query exceeded the statement limit"));
1307
+
1308
+ renderChartWidget({
1309
+ queries: [
1310
+ { id: "q1", dataSourceId: "ds-prom", query: "up" },
1311
+ { id: "q2", dataSourceId: "ds-pg", query: "select 1" },
1312
+ ],
1313
+ });
1314
+
1315
+ await waitFor((): void => {
1316
+ expect(
1317
+ screen.getByText("Query exceeded the statement limit"),
1318
+ ).toBeInTheDocument();
1319
+ });
1320
+ expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
1321
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
1322
+ });
1323
+ });
1324
+
1325
+ /*
1326
+ * The staleness guard (`fetchSequenceRef`). A refresh tick or a config
1327
+ * edit starts a second request while the first is still in flight, and
1328
+ * nothing orders their completions — a data source that is slow once is
1329
+ * slow unpredictably. Every one of these tests leaves two fetches open at
1330
+ * the same time and completes them in the WRONG order on purpose.
1331
+ */
1332
+ describe("only the newest fetch is allowed to write state", () => {
1333
+ const VALUE_ARGS: DashboardDataSourceValueComponent["arguments"] = {
1334
+ query: { dataSourceId: "ds-prom", query: "up" },
1335
+ title: "Up",
1336
+ };
1337
+
1338
+ const TABLE_ARGS: DashboardDataSourceTableComponent["arguments"] = {
1339
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1340
+ };
1341
+
1342
+ const CHART_ARGS: DashboardDataSourceChartComponent["arguments"] = {
1343
+ queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
1344
+ };
1345
+
1346
+ const STALE_TABLE_RESULT: DataSourceTableResult = {
1347
+ columns: [
1348
+ {
1349
+ key: "service",
1350
+ title: "Service",
1351
+ type: DataSourceTableColumnType.Text,
1352
+ },
1353
+ ],
1354
+ rows: [{ service: "stale-service" }],
1355
+ truncated: false,
1356
+ };
1357
+
1358
+ const FRESH_TABLE_RESULT: DataSourceTableResult = {
1359
+ columns: [
1360
+ {
1361
+ key: "service",
1362
+ title: "Service",
1363
+ type: DataSourceTableColumnType.Text,
1364
+ },
1365
+ ],
1366
+ rows: [{ service: "fresh-service" }],
1367
+ truncated: false,
1368
+ };
1369
+
1370
+ test("value widget keeps the newer number when the older fetch answers last", async () => {
1371
+ const slowFirst: DeferredPromise<AggregatedResult> =
1372
+ createDeferred<AggregatedResult>();
1373
+ const newerSecond: DeferredPromise<AggregatedResult> =
1374
+ createDeferred<AggregatedResult>();
1375
+
1376
+ fetchTimeSeriesMock
1377
+ .mockReturnValueOnce(slowFirst.promise)
1378
+ .mockReturnValueOnce(newerSecond.promise);
1379
+
1380
+ const rendered: RenderResult = render(
1381
+ buildValueElement(VALUE_ARGS, { refreshTick: 0 }),
1382
+ );
1383
+
1384
+ // The auto-refresh tick, while the first request is still open.
1385
+ rendered.rerender(buildValueElement(VALUE_ARGS, { refreshTick: 1 }));
1386
+
1387
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
1388
+
1389
+ await settleAndFlush((): void => {
1390
+ newerSecond.resolve(
1391
+ buildSeries([buildPoint(77, "2026-08-10T09:40:00.000Z")]),
1392
+ );
1393
+ });
1394
+
1395
+ expect(rendered.container.textContent).toContain("77");
1396
+
1397
+ await settleAndFlush((): void => {
1398
+ slowFirst.resolve(
1399
+ buildSeries([buildPoint(11, "2026-08-10T09:10:00.000Z")]),
1400
+ );
1401
+ });
1402
+
1403
+ expect(rendered.container.textContent).toContain("77");
1404
+ expect(rendered.container.textContent).not.toContain("11");
1405
+ });
1406
+
1407
+ /*
1408
+ * The parenthetical in the comment: the stale response does not merely
1409
+ * lose the value race, it must not run `setError("")` either — that
1410
+ * would wipe a failure the user is entitled to see and leave the tile
1411
+ * silently blank.
1412
+ */
1413
+ test("value widget's newer error survives a stale success landing after it", async () => {
1414
+ const slowFirst: DeferredPromise<AggregatedResult> =
1415
+ createDeferred<AggregatedResult>();
1416
+ const failingSecond: DeferredPromise<AggregatedResult> =
1417
+ createDeferred<AggregatedResult>();
1418
+
1419
+ fetchTimeSeriesMock
1420
+ .mockReturnValueOnce(slowFirst.promise)
1421
+ .mockReturnValueOnce(failingSecond.promise);
1422
+
1423
+ const rendered: RenderResult = render(
1424
+ buildValueElement(VALUE_ARGS, { refreshTick: 0 }),
1425
+ );
1426
+ rendered.rerender(buildValueElement(VALUE_ARGS, { refreshTick: 1 }));
1427
+
1428
+ await settleAndFlush((): void => {
1429
+ failingSecond.reject(new Error("Prometheus refused the connection"));
1430
+ });
1431
+
1432
+ expect(
1433
+ screen.getByText("Prometheus refused the connection"),
1434
+ ).toBeInTheDocument();
1435
+
1436
+ await settleAndFlush((): void => {
1437
+ slowFirst.resolve(
1438
+ buildSeries([buildPoint(11, "2026-08-10T09:10:00.000Z")]),
1439
+ );
1440
+ });
1441
+
1442
+ expect(
1443
+ screen.getByText("Prometheus refused the connection"),
1444
+ ).toBeInTheDocument();
1445
+ expect(rendered.container.textContent).not.toContain("11");
1446
+ });
1447
+
1448
+ /*
1449
+ * The mirror image, and the one that proves the stale response really is
1450
+ * delivered rather than dropped: the component is awaiting this promise,
1451
+ * so its rejection reaches the widget's own catch (an unawaited one would
1452
+ * surface as an unhandled rejection instead). The catch has to bail the
1453
+ * same way the success path does.
1454
+ */
1455
+ test("value widget's newer data survives a stale failure landing after it", async () => {
1456
+ const slowFirst: DeferredPromise<AggregatedResult> =
1457
+ createDeferred<AggregatedResult>();
1458
+ const newerSecond: DeferredPromise<AggregatedResult> =
1459
+ createDeferred<AggregatedResult>();
1460
+
1461
+ fetchTimeSeriesMock
1462
+ .mockReturnValueOnce(slowFirst.promise)
1463
+ .mockReturnValueOnce(newerSecond.promise);
1464
+
1465
+ const rendered: RenderResult = render(
1466
+ buildValueElement(VALUE_ARGS, { refreshTick: 0 }),
1467
+ );
1468
+ rendered.rerender(buildValueElement(VALUE_ARGS, { refreshTick: 1 }));
1469
+
1470
+ await settleAndFlush((): void => {
1471
+ newerSecond.resolve(
1472
+ buildSeries([buildPoint(77, "2026-08-10T09:40:00.000Z")]),
1473
+ );
1474
+ });
1475
+
1476
+ expect(rendered.container.textContent).toContain("77");
1477
+
1478
+ await settleAndFlush((): void => {
1479
+ slowFirst.reject(new Error("Connection reset by peer"));
1480
+ });
1481
+
1482
+ expect(rendered.container.textContent).toContain("77");
1483
+ expect(
1484
+ screen.queryByText("Connection reset by peer"),
1485
+ ).not.toBeInTheDocument();
1486
+ });
1487
+
1488
+ test("table widget keeps the newer rows when the older fetch answers last", async () => {
1489
+ const slowFirst: DeferredPromise<DataSourceTableResult> =
1490
+ createDeferred<DataSourceTableResult>();
1491
+ const newerSecond: DeferredPromise<DataSourceTableResult> =
1492
+ createDeferred<DataSourceTableResult>();
1493
+
1494
+ fetchTableMock
1495
+ .mockReturnValueOnce(slowFirst.promise)
1496
+ .mockReturnValueOnce(newerSecond.promise);
1497
+
1498
+ const rendered: RenderResult = render(
1499
+ buildTableElement(TABLE_ARGS, { refreshTick: 0 }),
1500
+ );
1501
+ rendered.rerender(buildTableElement(TABLE_ARGS, { refreshTick: 1 }));
1502
+
1503
+ expect(fetchTableMock).toHaveBeenCalledTimes(2);
1504
+
1505
+ await settleAndFlush((): void => {
1506
+ newerSecond.resolve(FRESH_TABLE_RESULT);
1507
+ });
1508
+
1509
+ expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
1510
+
1511
+ await settleAndFlush((): void => {
1512
+ slowFirst.resolve(STALE_TABLE_RESULT);
1513
+ });
1514
+
1515
+ expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
1516
+ expect(rendered.container.textContent).not.toContain("stale-service");
1517
+ });
1518
+
1519
+ test("table widget's newer error survives a stale success landing after it", async () => {
1520
+ const slowFirst: DeferredPromise<DataSourceTableResult> =
1521
+ createDeferred<DataSourceTableResult>();
1522
+ const failingSecond: DeferredPromise<DataSourceTableResult> =
1523
+ createDeferred<DataSourceTableResult>();
1524
+
1525
+ fetchTableMock
1526
+ .mockReturnValueOnce(slowFirst.promise)
1527
+ .mockReturnValueOnce(failingSecond.promise);
1528
+
1529
+ const rendered: RenderResult = render(
1530
+ buildTableElement(TABLE_ARGS, { refreshTick: 0 }),
1531
+ );
1532
+ rendered.rerender(buildTableElement(TABLE_ARGS, { refreshTick: 1 }));
1533
+
1534
+ await settleAndFlush((): void => {
1535
+ failingSecond.reject(new Error('relation "deploys" does not exist'));
1536
+ });
1537
+
1538
+ expect(
1539
+ screen.getByText('relation "deploys" does not exist'),
1540
+ ).toBeInTheDocument();
1541
+
1542
+ await settleAndFlush((): void => {
1543
+ slowFirst.resolve(STALE_TABLE_RESULT);
1544
+ });
1545
+
1546
+ expect(
1547
+ screen.getByText('relation "deploys" does not exist'),
1548
+ ).toBeInTheDocument();
1549
+ expect(rendered.container.textContent).not.toContain("stale-service");
1550
+ expect(rendered.container.querySelector("table")).toBeNull();
1551
+ });
1552
+
1553
+ test("table widget's newer rows survive a stale failure landing after them", async () => {
1554
+ const slowFirst: DeferredPromise<DataSourceTableResult> =
1555
+ createDeferred<DataSourceTableResult>();
1556
+ const newerSecond: DeferredPromise<DataSourceTableResult> =
1557
+ createDeferred<DataSourceTableResult>();
1558
+
1559
+ fetchTableMock
1560
+ .mockReturnValueOnce(slowFirst.promise)
1561
+ .mockReturnValueOnce(newerSecond.promise);
1562
+
1563
+ const rendered: RenderResult = render(
1564
+ buildTableElement(TABLE_ARGS, { refreshTick: 0 }),
1565
+ );
1566
+ rendered.rerender(buildTableElement(TABLE_ARGS, { refreshTick: 1 }));
1567
+
1568
+ await settleAndFlush((): void => {
1569
+ newerSecond.resolve(FRESH_TABLE_RESULT);
1570
+ });
1571
+
1572
+ expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
1573
+
1574
+ await settleAndFlush((): void => {
1575
+ slowFirst.reject(new Error("Connection reset by peer"));
1576
+ });
1577
+
1578
+ expect(getRenderedRows(rendered.container)).toEqual([["fresh-service"]]);
1579
+ expect(
1580
+ screen.queryByText("Connection reset by peer"),
1581
+ ).not.toBeInTheDocument();
1582
+ });
1583
+
1584
+ /*
1585
+ * Same race on the chart, where the comment adds "and Promise.all
1586
+ * completions are not ordered" — the whole batch of a stale tick is one
1587
+ * settlement, so a single late batch would replace every series at once.
1588
+ */
1589
+ test("chart widget never hands the chart layer a stale batch", async () => {
1590
+ const slowFirst: DeferredPromise<AggregatedResult> =
1591
+ createDeferred<AggregatedResult>();
1592
+ const newerSecond: DeferredPromise<AggregatedResult> =
1593
+ createDeferred<AggregatedResult>();
1594
+
1595
+ fetchTimeSeriesMock
1596
+ .mockReturnValueOnce(slowFirst.promise)
1597
+ .mockReturnValueOnce(newerSecond.promise);
1598
+
1599
+ const chart: RenderResult = render(
1600
+ buildChartElement(CHART_ARGS, { refreshTick: 0 }),
1601
+ );
1602
+ chart.rerender(buildChartElement(CHART_ARGS, { refreshTick: 1 }));
1603
+
1604
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
1605
+
1606
+ await settleAndFlush((): void => {
1607
+ newerSecond.resolve(
1608
+ buildSeries([buildPoint(77, "2026-08-10T09:40:00.000Z")]),
1609
+ );
1610
+ });
1611
+
1612
+ expect(screen.getByTestId("metric-charts")).toBeInTheDocument();
1613
+
1614
+ await settleAndFlush((): void => {
1615
+ slowFirst.resolve(
1616
+ buildSeries([buildPoint(11, "2026-08-10T09:10:00.000Z")]),
1617
+ );
1618
+ });
1619
+
1620
+ expect(getChartRenderedValues()).not.toContain(11);
1621
+ expect(
1622
+ getChartRenderedValues()[getChartRenderedValues().length - 1],
1623
+ ).toBe(77);
1624
+ });
1625
+ });
1626
+
1627
+ /*
1628
+ * `refreshTick` is a dependency of the startAndEndDate memo purely so a
1629
+ * RELATIVE range re-resolves against the wall clock on every auto-refresh.
1630
+ * Every other test in this file pins a CUSTOM range, which resolves to the
1631
+ * same two instants forever and therefore cannot tell a working dep from a
1632
+ * missing one.
1633
+ */
1634
+ describe("an auto-refresh re-resolves a relative range against the clock", () => {
1635
+ const RELATIVE_RANGE: RangeStartAndEndDateTime = {
1636
+ range: TimeRange.PAST_ONE_HOUR,
1637
+ };
1638
+
1639
+ const ONE_HOUR_IN_MS: number = 60 * 60 * 1000;
1640
+ const FIVE_MINUTES_IN_MS: number = 5 * 60 * 1000;
1641
+
1642
+ beforeEach((): void => {
1643
+ jest.useFakeTimers();
1644
+ jest.setSystemTime(new Date("2026-08-10T12:00:00.000Z"));
1645
+ });
1646
+
1647
+ afterEach((): void => {
1648
+ jest.useRealTimers();
1649
+ });
1650
+
1651
+ test("value widget asks for a window that moved with the clock", async () => {
1652
+ const rendered: RenderResult = render(
1653
+ buildValueElement(
1654
+ { query: { dataSourceId: "ds-prom", query: "up" }, title: "Up" },
1655
+ { dashboardStartAndEndDate: RELATIVE_RANGE, refreshTick: 0 },
1656
+ ),
1657
+ );
1658
+
1659
+ await act(async (): Promise<void> => {});
1660
+
1661
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
1662
+
1663
+ act((): void => {
1664
+ jest.advanceTimersByTime(FIVE_MINUTES_IN_MS);
1665
+ });
1666
+
1667
+ rendered.rerender(
1668
+ buildValueElement(
1669
+ { query: { dataSourceId: "ds-prom", query: "up" }, title: "Up" },
1670
+ { dashboardStartAndEndDate: RELATIVE_RANGE, refreshTick: 1 },
1671
+ ),
1672
+ );
1673
+
1674
+ await act(async (): Promise<void> => {});
1675
+
1676
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
1677
+
1678
+ const calls: Array<DataSourceFetchArgs> =
1679
+ getFetchCalls(fetchTimeSeriesMock);
1680
+
1681
+ // "Past 1 hour" really is an hour wide, both times.
1682
+ expect(calls[0]!.endDate.getTime() - calls[0]!.startDate.getTime()).toBe(
1683
+ ONE_HOUR_IN_MS,
1684
+ );
1685
+ expect(calls[1]!.endDate.getTime() - calls[1]!.startDate.getTime()).toBe(
1686
+ ONE_HOUR_IN_MS,
1687
+ );
1688
+
1689
+ // ...and the second one is the window five minutes later, not the first.
1690
+ expect(calls[1]!.endDate.getTime()).toBeGreaterThan(
1691
+ calls[0]!.endDate.getTime(),
1692
+ );
1693
+ expect(calls[1]!.endDate.getTime() - calls[0]!.endDate.getTime()).toBe(
1694
+ FIVE_MINUTES_IN_MS,
1695
+ );
1696
+ expect(
1697
+ calls[1]!.startDate.getTime() - calls[0]!.startDate.getTime(),
1698
+ ).toBe(FIVE_MINUTES_IN_MS);
1699
+ });
1700
+
1701
+ test("chart widget asks for a window that moved with the clock", async () => {
1702
+ const chartArgs: DashboardDataSourceChartComponent["arguments"] = {
1703
+ queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
1704
+ };
1705
+
1706
+ const rendered: RenderResult = render(
1707
+ buildChartElement(chartArgs, {
1708
+ dashboardStartAndEndDate: RELATIVE_RANGE,
1709
+ refreshTick: 0,
1710
+ }),
1711
+ );
1712
+
1713
+ await act(async (): Promise<void> => {});
1714
+
1715
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(1);
1716
+
1717
+ act((): void => {
1718
+ jest.advanceTimersByTime(FIVE_MINUTES_IN_MS);
1719
+ });
1720
+
1721
+ rendered.rerender(
1722
+ buildChartElement(chartArgs, {
1723
+ dashboardStartAndEndDate: RELATIVE_RANGE,
1724
+ refreshTick: 1,
1725
+ }),
1726
+ );
1727
+
1728
+ await act(async (): Promise<void> => {});
1729
+
1730
+ expect(fetchTimeSeriesMock).toHaveBeenCalledTimes(2);
1731
+
1732
+ const calls: Array<DataSourceFetchArgs> =
1733
+ getFetchCalls(fetchTimeSeriesMock);
1734
+ expect(calls[1]!.endDate.getTime() - calls[0]!.endDate.getTime()).toBe(
1735
+ FIVE_MINUTES_IN_MS,
1736
+ );
1737
+ expect(calls[0]!.endDate.getTime() - calls[0]!.startDate.getTime()).toBe(
1738
+ ONE_HOUR_IN_MS,
1739
+ );
1740
+ });
1741
+ });
1742
+
1743
+ /*
1744
+ * A window that resolves to no dates at all. The chart calls that an error
1745
+ * the user has to fix; the other three stop loading and say nothing about
1746
+ * it. The asymmetry is deliberate but easy to "tidy up" by accident, so
1747
+ * both halves are pinned here.
1748
+ */
1749
+ describe("a window with no dates: the chart complains, the rest go quiet", () => {
1750
+ const NO_DATE_RANGE: RangeStartAndEndDateTime = {
1751
+ range: TimeRange.CUSTOM,
1752
+ startAndEndDate: new InBetween<Date>(
1753
+ undefined as unknown as Date,
1754
+ undefined as unknown as Date,
1755
+ ),
1756
+ };
1757
+
1758
+ const MISSING_DATE_ERROR: string =
1759
+ "Please select a valid start and end date.";
1760
+
1761
+ test("chart widget reports the missing window and queries nothing", async () => {
1762
+ renderChartWidget(
1763
+ {
1764
+ queries: [{ id: "q1", dataSourceId: "ds-prom", query: "up" }],
1765
+ chartTitle: "Throughput",
1766
+ },
1767
+ { dashboardStartAndEndDate: NO_DATE_RANGE },
1768
+ );
1769
+
1770
+ await waitFor((): void => {
1771
+ expect(screen.getByText(MISSING_DATE_ERROR)).toBeInTheDocument();
1772
+ });
1773
+
1774
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
1775
+ expect(screen.queryByTestId("metric-charts")).not.toBeInTheDocument();
1776
+ });
1777
+
1778
+ test("value widget stops loading with no error of any kind", async () => {
1779
+ renderValueWidget(
1780
+ { query: { dataSourceId: "ds-prom", query: "up" }, title: "Up" },
1781
+ { dashboardStartAndEndDate: NO_DATE_RANGE },
1782
+ );
1783
+
1784
+ await waitFor((): void => {
1785
+ expect(
1786
+ screen.getByText("No data for the selected time range"),
1787
+ ).toBeInTheDocument();
1788
+ });
1789
+
1790
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
1791
+ expect(screen.queryByText(MISSING_DATE_ERROR)).not.toBeInTheDocument();
1792
+ });
1793
+
1794
+ test("gauge widget stops loading with no error of any kind", async () => {
1795
+ renderGaugeWidget(
1796
+ { query: { dataSourceId: "ds-prom", query: "cpu" }, gaugeTitle: "CPU" },
1797
+ { dashboardStartAndEndDate: NO_DATE_RANGE },
1798
+ );
1799
+
1800
+ await waitFor((): void => {
1801
+ expect(
1802
+ screen.getByText("No data for the selected time range"),
1803
+ ).toBeInTheDocument();
1804
+ });
1805
+
1806
+ expect(fetchTimeSeriesMock).not.toHaveBeenCalled();
1807
+ expect(screen.queryByText(MISSING_DATE_ERROR)).not.toBeInTheDocument();
1808
+ });
1809
+
1810
+ test("table widget stops loading with no error of any kind", async () => {
1811
+ const rendered: RenderResult = renderTableWidget(
1812
+ {
1813
+ query: { dataSourceId: "ds-pg", query: "select 1" },
1814
+ tableTitle: "Slow queries",
1815
+ },
1816
+ { dashboardStartAndEndDate: NO_DATE_RANGE },
1817
+ );
1818
+
1819
+ await waitFor((): void => {
1820
+ expect(screen.getByText("No rows returned.")).toBeInTheDocument();
1821
+ });
1822
+
1823
+ expect(fetchTableMock).not.toHaveBeenCalled();
1824
+ expect(screen.queryByText(MISSING_DATE_ERROR)).not.toBeInTheDocument();
1825
+ // The header still renders, so this is the quiet state, not the error tile.
1826
+ expect(screen.getByText("Slow queries")).toBeInTheDocument();
1827
+ expect(getRowCountLabel(rendered.container)).toBe("0 rows");
1828
+ });
1829
+ });
1830
+
1831
+ /*
1832
+ * A widget title is NOT a metric name.
1833
+ *
1834
+ * The Value and Gauge widgets share their renderers with the OneUptime
1835
+ * metric widgets, and those renderers take a `metricName` so ValueFormatter
1836
+ * can apply two name-based heuristics:
1837
+ *
1838
+ * - isFractionMetric(name): with unit "1", a `.utilization` / `.ratio` /
1839
+ * `.fraction` / `.percent` name means the number is a [0, 1] ratio, so
1840
+ * the view multiplies it by 100 before displaying it AND before
1841
+ * comparing it against the widget's thresholds.
1842
+ * - isHigherWorseMetric(name): decides whether a rising trend is drawn
1843
+ * green or red when the widget's own `trendDirection` is unset.
1844
+ *
1845
+ * Both are safe for OneUptime metrics, whose names are exporter-authored
1846
+ * and follow those conventions. Neither is safe for external data: a Data
1847
+ * Source widget's title is free text a human typed, and naming a Postgres
1848
+ * query "cache.hit.utilization" is a label, not a promise that the number
1849
+ * is a ratio. So both widgets pass a hardcoded `metricName=""`
1850
+ * (DashboardDataSourceValueComponent.tsx ~201,
1851
+ * DashboardDataSourceGaugeComponent.tsx ~172) and the heuristics stay
1852
+ * silent.
1853
+ *
1854
+ * That constant is the kind of thing a later reader "fixes" by wiring the
1855
+ * title through — which silently multiplies a customer's 0.9 by 100 and
1856
+ * turns a Healthy gauge Critical. Every title below is therefore chosen to
1857
+ * be exactly a name that WOULD trip a heuristic, and each test is paired
1858
+ * with a control that asks for the same behaviour EXPLICITLY (via `unit` or
1859
+ * `trendDirection`). The controls are what make a failure legible: the
1860
+ * invariant is "the title is never read as a metric name", not "this widget
1861
+ * can never render a percent or a red arrow".
1862
+ */
1863
+ describe("a widget title is never read as a metric name", () => {
1864
+ /*
1865
+ * The big number and its unit suffix. Both views draw them in the same
1866
+ * `font-bold tabular-nums` box, and everything else on the tile (title,
1867
+ * status label, gauge min/max footer, threshold tick tooltips) lives
1868
+ * outside it — so this reads the digits, not the chrome around them.
1869
+ */
1870
+ type GetBigNumberFunction = (container: HTMLElement) => string;
1871
+
1872
+ const getBigNumberText: GetBigNumberFunction = (
1873
+ container: HTMLElement,
1874
+ ): string => {
1875
+ const element: Element | null = container.querySelector(
1876
+ "div.font-bold.tabular-nums",
1877
+ );
1878
+
1879
+ if (!element) {
1880
+ throw new Error("Widget did not render a value.");
1881
+ }
1882
+
1883
+ return (element.textContent || "").trim();
1884
+ };
1885
+
1886
+ /** The trend chip: which way it points, and which way it was coloured. */
1887
+ interface TrendIndicator {
1888
+ tone: "good" | "bad";
1889
+ text: string;
1890
+ }
1891
+
1892
+ type GetTrendIndicatorFunction = (container: HTMLElement) => TrendIndicator;
1893
+
1894
+ const getTrendIndicator: GetTrendIndicatorFunction = (
1895
+ container: HTMLElement,
1896
+ ): TrendIndicator => {
1897
+ const good: Element | null = container.querySelector(
1898
+ "span.text-emerald-500",
1899
+ );
1900
+ const bad: Element | null = container.querySelector("span.text-red-500");
1901
+
1902
+ if (good && !bad) {
1903
+ return { tone: "good", text: (good.textContent || "").trim() };
1904
+ }
1905
+
1906
+ if (bad && !good) {
1907
+ return { tone: "bad", text: (bad.textContent || "").trim() };
1908
+ }
1909
+
1910
+ throw new Error(
1911
+ "Value widget did not render exactly one trend indicator.",
1912
+ );
1913
+ };
1914
+
1915
+ /*
1916
+ * Ends in ".utilization", so ValueFormatter.isFractionMetric matches it —
1917
+ * a perfectly ordinary thing to call a cache hit-rate query.
1918
+ */
1919
+ const FRACTION_LOOKING_TITLE: string = "cache.hit.utilization";
1920
+
1921
+ /*
1922
+ * Contains "error", so ValueFormatter.isHigherWorseMetric matches it,
1923
+ * while carrying none of the "higher is better" tokens that regex checks
1924
+ * first.
1925
+ */
1926
+ const HIGHER_WORSE_LOOKING_TITLE: string = "api.error.rate";
1927
+
1928
+ /*
1929
+ * Four points is the minimum the trend calculation looks at, and the
1930
+ * second half averages double the first — an unambiguous rise, so the
1931
+ * only thing left in question is its colour.
1932
+ */
1933
+ const RISING_SERIES: Array<AggregatedModel> = [
1934
+ buildPoint(10, "2026-08-10T09:10:00.000Z"),
1935
+ buildPoint(10, "2026-08-10T09:20:00.000Z"),
1936
+ buildPoint(20, "2026-08-10T09:30:00.000Z"),
1937
+ buildPoint(20, "2026-08-10T09:40:00.000Z"),
1938
+ ];
1939
+
1940
+ const FRACTION_GAUGE_ARGS: DashboardDataSourceGaugeComponent["arguments"] =
1941
+ {
1942
+ query: { dataSourceId: "ds-prom", query: "cache_hit_rate" },
1943
+ gaugeTitle: FRACTION_LOOKING_TITLE,
1944
+ unit: "1",
1945
+ minValue: 0,
1946
+ maxValue: 100,
1947
+ warningThreshold: 50,
1948
+ criticalThreshold: 80,
1949
+ };
1950
+
1951
+ test("value widget shows 0.9 as 0.9, not as the percent its title implies", async () => {
1952
+ fetchTimeSeriesMock.mockResolvedValue(
1953
+ buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
1954
+ );
1955
+
1956
+ const rendered: RenderResult = renderValueWidget({
1957
+ query: { dataSourceId: "ds-prom", query: "cache_hit_rate" },
1958
+ title: FRACTION_LOOKING_TITLE,
1959
+ unit: "1",
1960
+ });
1961
+
1962
+ await waitFor((): void => {
1963
+ expect(getBigNumberText(rendered.container)).toBe("0.9");
1964
+ });
1965
+
1966
+ // 90.00% is exactly what isFractionMetric would have produced here.
1967
+ expect(rendered.container.textContent).not.toContain("90.00");
1968
+ expect(getBigNumberText(rendered.container)).not.toContain("%");
1969
+ });
1970
+
1971
+ test("the same value widget does render a percent when the UNIT says so", async () => {
1972
+ fetchTimeSeriesMock.mockResolvedValue(
1973
+ buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
1974
+ );
1975
+
1976
+ const rendered: RenderResult = renderValueWidget({
1977
+ query: { dataSourceId: "ds-prom", query: "cache_hit_rate" },
1978
+ title: FRACTION_LOOKING_TITLE,
1979
+ unit: "%",
1980
+ });
1981
+
1982
+ /*
1983
+ * The percent path is alive and the title is unchanged — so the test
1984
+ * above is pinning where the "%" decision comes from, not the absence
1985
+ * of percent rendering. Still 0.9, not 90: only the fraction heuristic
1986
+ * rescales, and it did not fire here either.
1987
+ */
1988
+ await waitFor((): void => {
1989
+ expect(getBigNumberText(rendered.container)).toBe("0.90%");
1990
+ });
1991
+ });
1992
+
1993
+ test("gauge widget compares 0.9 against its thresholds unscaled", async () => {
1994
+ fetchTimeSeriesMock.mockResolvedValue(
1995
+ buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
1996
+ );
1997
+
1998
+ const rendered: RenderResult = renderGaugeWidget(FRACTION_GAUGE_ARGS);
1999
+
2000
+ /*
2001
+ * Thresholds are authored on the gauge's own 0-100 scale. Rescaled,
2002
+ * 0.9 would become 90 and cross the critical line at 80; unscaled it
2003
+ * sits below both, which is what the user configured.
2004
+ */
2005
+ await waitFor((): void => {
2006
+ expect(screen.getByText("Healthy")).toBeInTheDocument();
2007
+ });
2008
+
2009
+ expect(getBigNumberText(rendered.container)).toBe("0.9");
2010
+ expect(screen.queryByText("Critical")).not.toBeInTheDocument();
2011
+ expect(screen.queryByText("Warning")).not.toBeInTheDocument();
2012
+ });
2013
+
2014
+ test("the same gauge does go Critical once the value really is 90", async () => {
2015
+ fetchTimeSeriesMock.mockResolvedValue(
2016
+ buildSeries([buildPoint(90, "2026-08-10T09:30:00.000Z")]),
2017
+ );
2018
+
2019
+ const rendered: RenderResult = renderGaugeWidget(FRACTION_GAUGE_ARGS);
2020
+
2021
+ /*
2022
+ * Same widget, same thresholds, same title — only the number moved.
2023
+ * The critical branch is reachable, so the Healthy above is a
2024
+ * statement about the scale of the value, not about dead thresholds.
2025
+ */
2026
+ await waitFor((): void => {
2027
+ expect(screen.getByText("Critical")).toBeInTheDocument();
2028
+ });
2029
+
2030
+ expect(getBigNumberText(rendered.container)).toBe("90");
2031
+ expect(screen.queryByText("Healthy")).not.toBeInTheDocument();
2032
+ });
2033
+
2034
+ test("the same gauge does render a percent when the UNIT says so", async () => {
2035
+ fetchTimeSeriesMock.mockResolvedValue(
2036
+ buildSeries([buildPoint(0.9, "2026-08-10T09:30:00.000Z")]),
2037
+ );
2038
+
2039
+ const rendered: RenderResult = renderGaugeWidget({
2040
+ ...FRACTION_GAUGE_ARGS,
2041
+ unit: "%",
2042
+ });
2043
+
2044
+ await waitFor((): void => {
2045
+ expect(getBigNumberText(rendered.container)).toBe("0.90%");
2046
+ });
2047
+
2048
+ // A percent SUFFIX, still no rescale — and still below the thresholds.
2049
+ expect(screen.getByText("Healthy")).toBeInTheDocument();
2050
+ });
2051
+
2052
+ test("value widget colours a rise green even under an error-shaped title", async () => {
2053
+ fetchTimeSeriesMock.mockResolvedValue(buildSeries(RISING_SERIES));
2054
+
2055
+ const rendered: RenderResult = renderValueWidget({
2056
+ query: { dataSourceId: "ds-prom", query: "sum(rate(x[5m]))" },
2057
+ title: HIGHER_WORSE_LOOKING_TITLE,
2058
+ // trendDirection deliberately unset — this is the heuristic's branch.
2059
+ });
2060
+
2061
+ await waitFor((): void => {
2062
+ expect(getBigNumberText(rendered.container)).toBe("20");
2063
+ });
2064
+
2065
+ const trend: TrendIndicator = getTrendIndicator(rendered.container);
2066
+ expect(trend.text).toContain("↑");
2067
+ // The empty-metricName default: nothing known, so up is good.
2068
+ expect(trend.tone).toBe("good");
2069
+ });
2070
+
2071
+ test("an explicit Auto is the same heuristic branch, and still reads up as good", async () => {
2072
+ fetchTimeSeriesMock.mockResolvedValue(buildSeries(RISING_SERIES));
2073
+
2074
+ const rendered: RenderResult = renderValueWidget({
2075
+ query: { dataSourceId: "ds-prom", query: "sum(rate(x[5m]))" },
2076
+ title: HIGHER_WORSE_LOOKING_TITLE,
2077
+ trendDirection: DashboardValueTrendDirection.Auto,
2078
+ });
2079
+
2080
+ await waitFor((): void => {
2081
+ expect(getBigNumberText(rendered.container)).toBe("20");
2082
+ });
2083
+
2084
+ expect(getTrendIndicator(rendered.container).tone).toBe("good");
2085
+ });
2086
+
2087
+ test("the same rise IS painted red when the widget explicitly says so", async () => {
2088
+ fetchTimeSeriesMock.mockResolvedValue(buildSeries(RISING_SERIES));
2089
+
2090
+ const rendered: RenderResult = renderValueWidget({
2091
+ query: { dataSourceId: "ds-prom", query: "sum(rate(x[5m]))" },
2092
+ title: HIGHER_WORSE_LOOKING_TITLE,
2093
+ trendDirection: DashboardValueTrendDirection.HigherIsWorse,
2094
+ });
2095
+
2096
+ await waitFor((): void => {
2097
+ expect(getBigNumberText(rendered.container)).toBe("20");
2098
+ });
2099
+
2100
+ /*
2101
+ * The inverted colouring is reachable through configuration, so the
2102
+ * green above is a statement about where the decision came from.
2103
+ */
2104
+ const trend: TrendIndicator = getTrendIndicator(rendered.container);
2105
+ expect(trend.text).toContain("↑");
2106
+ expect(trend.tone).toBe("bad");
2107
+ });
2108
+ });
2109
+ });