@oneuptime/common 12.0.2 → 12.0.4

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 (384) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +14 -0
  4. package/Models/DatabaseModels/DockerHost.ts +14 -0
  5. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  6. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  7. package/Models/DatabaseModels/Index.ts +2 -0
  8. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  9. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  10. package/Models/DatabaseModels/LogDropFilter.ts +9 -20
  11. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  12. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  13. package/Models/DatabaseModels/Project.ts +16 -2
  14. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  15. package/Models/DatabaseModels/Service.ts +30 -0
  16. package/Models/DatabaseModels/StatusPageMonitorRule.ts +911 -0
  17. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  18. package/Models/DatabaseModels/StatusPageResource.ts +73 -0
  19. package/Models/DatabaseModels/TraceDropFilter.ts +9 -20
  20. package/Models/DatabaseModels/User.ts +30 -0
  21. package/Server/API/AIAgentDataAPI.ts +31 -0
  22. package/Server/API/BaseAPI.ts +13 -2
  23. package/Server/API/DashboardAPI.ts +458 -12
  24. package/Server/API/GitHubAPI.ts +119 -283
  25. package/Server/API/UserAPI.ts +263 -1
  26. package/Server/EnvironmentConfig.ts +38 -4
  27. package/Server/Infrastructure/GlobalCache.ts +152 -0
  28. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +16 -0
  29. package/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.ts +123 -0
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  35. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +12 -0
  36. package/Server/Services/AlertStateTimelineService.ts +20 -0
  37. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  38. package/Server/Services/CephClusterService.ts +26 -86
  39. package/Server/Services/CloudResourceService.ts +23 -83
  40. package/Server/Services/CodeRepositoryService.ts +105 -2
  41. package/Server/Services/DatabaseService.ts +296 -2
  42. package/Server/Services/DockerHostService.ts +19 -79
  43. package/Server/Services/DockerSwarmClusterService.ts +27 -88
  44. package/Server/Services/HostService.ts +46 -99
  45. package/Server/Services/IncidentStateTimelineService.ts +20 -0
  46. package/Server/Services/Index.ts +2 -0
  47. package/Server/Services/IoTFleetService.ts +19 -79
  48. package/Server/Services/KubernetesClusterService.ts +17 -78
  49. package/Server/Services/LabelService.ts +57 -10
  50. package/Server/Services/MetricTypeService.ts +84 -0
  51. package/Server/Services/MonitorService.ts +89 -14
  52. package/Server/Services/MonitorStatusTimelineService.ts +29 -0
  53. package/Server/Services/MonitorTemplateService.ts +65 -0
  54. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  55. package/Server/Services/PodmanHostService.ts +19 -79
  56. package/Server/Services/ProxmoxClusterService.ts +23 -83
  57. package/Server/Services/RumApplicationService.ts +19 -80
  58. package/Server/Services/ScheduledMaintenanceStateTimelineService.ts +21 -0
  59. package/Server/Services/ServerlessFunctionService.ts +24 -85
  60. package/Server/Services/ServiceService.ts +299 -73
  61. package/Server/Services/StatusPageMonitorRuleEngineService.ts +752 -0
  62. package/Server/Services/StatusPageMonitorRuleService.ts +471 -0
  63. package/Server/Services/TelemetryEntityRelationshipService.ts +1 -0
  64. package/Server/Services/TelemetryEntityService.ts +1 -0
  65. package/Server/Services/UserService.ts +184 -0
  66. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  67. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  68. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  69. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  70. package/Server/Utils/Database/ProjectScopedReferenceValidator.ts +170 -39
  71. package/Server/Utils/Monitor/MonitorResource.ts +63 -36
  72. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +36 -0
  73. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +148 -127
  74. package/Server/Utils/Monitor/MonitorStepsReferenceExtractor.ts +434 -0
  75. package/Server/Utils/PasswordHash.ts +306 -0
  76. package/Server/Utils/SingleFlight.ts +83 -0
  77. package/Server/Utils/StatusPage/MonitorRulePatternValidator.ts +46 -0
  78. package/Server/Utils/Telemetry/EntityRegistry.ts +107 -26
  79. package/Server/Utils/Telemetry/ResourceHeartbeat.ts +314 -0
  80. package/Server/Utils/Telemetry/Telemetry.ts +351 -122
  81. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  82. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  83. package/Tests/App/Dashboard/WidgetCatalog.test.ts +2 -0
  84. package/Tests/Models/DatabaseModels/ColumnTransformerInvariants.test.ts +306 -0
  85. package/Tests/Models/DatabaseModels/NumberColumnDeserialization.test.ts +333 -0
  86. package/Tests/Models/DatabaseModels/StatusPageMonitorRuleModel.test.ts +190 -0
  87. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  88. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  89. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  90. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +26 -0
  91. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  92. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  93. package/Tests/Server/API/Helpers.ts +6 -1
  94. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  95. package/Tests/Server/Infrastructure/GlobalCache.test.ts +354 -0
  96. package/Tests/Server/Infrastructure/PostgresLockTimeoutOptions.test.ts +181 -0
  97. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  98. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  99. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  100. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  101. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  102. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  103. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  104. package/Tests/Server/Services/DatabaseServiceUpdateColumnsIfUnlocked.test.ts +342 -0
  105. package/Tests/Server/Services/DropFilterSaveValidation.test.ts +245 -0
  106. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +163 -15
  107. package/Tests/Server/Services/MetricTypeAttachServices.test.ts +222 -0
  108. package/Tests/Server/Services/MissingReferenceWriteGuard.test.ts +614 -0
  109. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +23 -0
  110. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  111. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +305 -56
  112. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  113. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  114. package/Tests/Server/Services/ServiceUpdateLastSeenSplitGates.test.ts +705 -0
  115. package/Tests/Server/Services/StatusPageMonitorRuleEngineService.test.ts +1719 -0
  116. package/Tests/Server/Services/StatusPageMonitorRuleHooks.test.ts +1212 -0
  117. package/Tests/Server/Services/StatusPageMonitorRuleMonitorCreateHook.test.ts +228 -0
  118. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  119. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  120. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  121. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  122. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +267 -0
  123. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  124. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  125. package/Tests/Server/Utils/Database/ProjectScopedReferenceValidator.test.ts +260 -13
  126. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +302 -0
  127. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  128. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  129. package/Tests/Server/Utils/MetricTypeIndexConvergence.test.ts +598 -0
  130. package/Tests/Server/Utils/Monitor/MonitorStatusTimelineUnusableStatus.test.ts +154 -0
  131. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +638 -92
  132. package/Tests/Server/Utils/Monitor/MonitorStepsReferenceExtractor.test.ts +724 -0
  133. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  134. package/Tests/Server/Utils/SingleFlight.test.ts +221 -0
  135. package/Tests/Types/Dashboard/NetworkDashboardTemplate.test.ts +793 -0
  136. package/Tests/Types/Database/BigIntColumnTransformer.test.ts +147 -0
  137. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  138. package/Tests/Types/Database/DatabasePropertyTransformer.test.ts +140 -0
  139. package/Tests/Types/Database/NumericColumnValue.test.ts +127 -0
  140. package/Tests/Types/Decimal.test.ts +37 -0
  141. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  142. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  143. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  144. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  145. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  146. package/Tests/Types/Monitor/SnmpVersionParsing.test.ts +152 -0
  147. package/Tests/Types/Port.test.ts +38 -0
  148. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  149. package/Tests/UI/Components/LogsHistogram.test.tsx +276 -0
  150. package/Tests/UI/Components/UseLiveLogsRefresh.test.tsx +489 -0
  151. package/Tests/UI/Components/UseLogsHistogram.test.tsx +500 -0
  152. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  153. package/Tests/Utils/Dashboard/Components/DashboardComponentsUtil.test.ts +120 -0
  154. package/Tests/Utils/Dashboard/DashboardNetworkMapComponent.test.ts +327 -0
  155. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  156. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  157. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  158. package/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.ts +40 -0
  159. package/Types/Dashboard/DashboardTemplates.ts +397 -0
  160. package/Types/Database/BigIntColumnTransformer.ts +74 -0
  161. package/Types/Database/ColumnLength.ts +9 -1
  162. package/Types/Database/DatabaseProperty.ts +26 -0
  163. package/Types/Database/NumericColumnValue.ts +128 -0
  164. package/Types/Database/TableColumn.ts +11 -0
  165. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  166. package/Types/Decimal.ts +16 -4
  167. package/Types/HashedString.ts +139 -4
  168. package/Types/Permission.ts +48 -0
  169. package/Types/Port.ts +16 -4
  170. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  171. package/UI/Components/LogsViewer/useLiveLogsRefresh.ts +86 -0
  172. package/UI/Components/LogsViewer/useLogsHistogram.ts +113 -0
  173. package/UI/Components/Navbar/NavBar.tsx +12 -2
  174. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  175. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  176. package/Utils/Dashboard/Components/DashboardNetworkMapComponent.ts +136 -0
  177. package/Utils/Dashboard/Components/Index.ts +7 -0
  178. package/Utils/TeamMembersByProject.ts +237 -0
  179. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  180. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  181. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  182. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  183. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +12 -0
  184. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  185. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  186. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  187. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  188. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  189. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  190. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  191. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  192. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  193. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  194. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  195. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  196. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  197. package/build/dist/Models/DatabaseModels/LogDropFilter.js +9 -20
  198. package/build/dist/Models/DatabaseModels/LogDropFilter.js.map +1 -1
  199. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  200. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  202. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  204. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  205. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  206. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  207. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  208. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  209. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js +925 -0
  210. package/build/dist/Models/DatabaseModels/StatusPageMonitorRule.js.map +1 -0
  211. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  212. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  213. package/build/dist/Models/DatabaseModels/StatusPageResource.js +72 -0
  214. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  215. package/build/dist/Models/DatabaseModels/TraceDropFilter.js +9 -20
  216. package/build/dist/Models/DatabaseModels/TraceDropFilter.js.map +1 -1
  217. package/build/dist/Models/DatabaseModels/User.js +32 -0
  218. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  219. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  220. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  221. package/build/dist/Server/API/BaseAPI.js +11 -1
  222. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  223. package/build/dist/Server/API/DashboardAPI.js +343 -10
  224. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  225. package/build/dist/Server/API/GitHubAPI.js +94 -167
  226. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  227. package/build/dist/Server/API/UserAPI.js +187 -2
  228. package/build/dist/Server/API/UserAPI.js.map +1 -1
  229. package/build/dist/Server/EnvironmentConfig.js +34 -4
  230. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  231. package/build/dist/Server/Infrastructure/GlobalCache.js +120 -0
  232. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  233. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +5 -3
  234. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  235. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js +48 -0
  236. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786005052769-AddStatusPageMonitorRule.js.map +1 -0
  237. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  238. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  239. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  240. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  241. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  242. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  243. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  244. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  245. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  246. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  247. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +12 -0
  248. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  249. package/build/dist/Server/Services/AlertStateTimelineService.js +19 -0
  250. package/build/dist/Server/Services/AlertStateTimelineService.js.map +1 -1
  251. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  252. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  253. package/build/dist/Server/Services/CephClusterService.js +26 -76
  254. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  255. package/build/dist/Server/Services/CloudResourceService.js +23 -73
  256. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  257. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  258. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  259. package/build/dist/Server/Services/DatabaseService.js +246 -2
  260. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  261. package/build/dist/Server/Services/DockerHostService.js +19 -69
  262. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  263. package/build/dist/Server/Services/DockerSwarmClusterService.js +27 -78
  264. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  265. package/build/dist/Server/Services/HostService.js +58 -102
  266. package/build/dist/Server/Services/HostService.js.map +1 -1
  267. package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
  268. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  269. package/build/dist/Server/Services/Index.js +2 -0
  270. package/build/dist/Server/Services/Index.js.map +1 -1
  271. package/build/dist/Server/Services/IoTFleetService.js +19 -69
  272. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  273. package/build/dist/Server/Services/KubernetesClusterService.js +17 -68
  274. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  275. package/build/dist/Server/Services/LabelService.js +42 -7
  276. package/build/dist/Server/Services/LabelService.js.map +1 -1
  277. package/build/dist/Server/Services/MetricTypeService.js +74 -0
  278. package/build/dist/Server/Services/MetricTypeService.js.map +1 -1
  279. package/build/dist/Server/Services/MonitorService.js +81 -9
  280. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  281. package/build/dist/Server/Services/MonitorStatusTimelineService.js +28 -0
  282. package/build/dist/Server/Services/MonitorStatusTimelineService.js.map +1 -1
  283. package/build/dist/Server/Services/MonitorTemplateService.js +60 -0
  284. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  285. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  286. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  287. package/build/dist/Server/Services/PodmanHostService.js +19 -69
  288. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  289. package/build/dist/Server/Services/ProxmoxClusterService.js +23 -73
  290. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  291. package/build/dist/Server/Services/RumApplicationService.js +19 -70
  292. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  293. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js +20 -0
  294. package/build/dist/Server/Services/ScheduledMaintenanceStateTimelineService.js.map +1 -1
  295. package/build/dist/Server/Services/ServerlessFunctionService.js +24 -75
  296. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  297. package/build/dist/Server/Services/ServiceService.js +236 -61
  298. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  299. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +603 -0
  300. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -0
  301. package/build/dist/Server/Services/StatusPageMonitorRuleService.js +401 -0
  302. package/build/dist/Server/Services/StatusPageMonitorRuleService.js.map +1 -0
  303. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +1 -0
  304. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  305. package/build/dist/Server/Services/TelemetryEntityService.js +1 -0
  306. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  307. package/build/dist/Server/Services/UserService.js +165 -0
  308. package/build/dist/Server/Services/UserService.js.map +1 -1
  309. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  310. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  311. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  312. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  313. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  314. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  315. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  316. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  317. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js +105 -29
  318. package/build/dist/Server/Utils/Database/ProjectScopedReferenceValidator.js.map +1 -1
  319. package/build/dist/Server/Utils/Monitor/MonitorResource.js +56 -32
  320. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  321. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +30 -3
  322. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  323. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +110 -98
  324. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -1
  325. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js +312 -0
  326. package/build/dist/Server/Utils/Monitor/MonitorStepsReferenceExtractor.js.map +1 -0
  327. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  328. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  329. package/build/dist/Server/Utils/SingleFlight.js +76 -0
  330. package/build/dist/Server/Utils/SingleFlight.js.map +1 -0
  331. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js +29 -0
  332. package/build/dist/Server/Utils/StatusPage/MonitorRulePatternValidator.js.map +1 -0
  333. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +78 -19
  334. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  335. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js +160 -0
  336. package/build/dist/Server/Utils/Telemetry/ResourceHeartbeat.js.map +1 -0
  337. package/build/dist/Server/Utils/Telemetry/Telemetry.js +279 -104
  338. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  339. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  340. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  341. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  342. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  343. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js +2 -0
  344. package/build/dist/Types/Dashboard/DashboardComponents/DashboardNetworkMapComponent.js.map +1 -0
  345. package/build/dist/Types/Dashboard/DashboardTemplates.js +368 -0
  346. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  347. package/build/dist/Types/Database/BigIntColumnTransformer.js +66 -0
  348. package/build/dist/Types/Database/BigIntColumnTransformer.js.map +1 -0
  349. package/build/dist/Types/Database/ColumnLength.js +9 -1
  350. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  351. package/build/dist/Types/Database/DatabaseProperty.js +25 -0
  352. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  353. package/build/dist/Types/Database/NumericColumnValue.js +97 -0
  354. package/build/dist/Types/Database/NumericColumnValue.js.map +1 -0
  355. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  356. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  357. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  358. package/build/dist/Types/Decimal.js +14 -3
  359. package/build/dist/Types/Decimal.js.map +1 -1
  360. package/build/dist/Types/HashedString.js +95 -5
  361. package/build/dist/Types/HashedString.js.map +1 -1
  362. package/build/dist/Types/Permission.js +42 -0
  363. package/build/dist/Types/Permission.js.map +1 -1
  364. package/build/dist/Types/Port.js +15 -5
  365. package/build/dist/Types/Port.js.map +1 -1
  366. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  367. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  368. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js +44 -0
  369. package/build/dist/UI/Components/LogsViewer/useLiveLogsRefresh.js.map +1 -0
  370. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js +75 -0
  371. package/build/dist/UI/Components/LogsViewer/useLogsHistogram.js.map +1 -0
  372. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  373. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  374. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  375. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  376. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  377. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  378. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js +114 -0
  379. package/build/dist/Utils/Dashboard/Components/DashboardNetworkMapComponent.js.map +1 -0
  380. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  381. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  382. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  383. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  384. package/package.json +1 -1
@@ -0,0 +1,1296 @@
1
+ import Dashboard from "../../../Models/DatabaseModels/Dashboard";
2
+ import DashboardAPI from "../../../Server/API/DashboardAPI";
3
+ import DashboardService from "../../../Server/Services/DashboardService";
4
+ import MetricService from "../../../Server/Services/MetricService";
5
+ import {
6
+ ExpressRequest,
7
+ ExpressResponse,
8
+ NextFunction,
9
+ } from "../../../Server/Utils/Express";
10
+ import Response from "../../../Server/Utils/Response";
11
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
12
+ import Includes from "../../../Types/BaseDatabase/Includes";
13
+ import DashboardComponentType from "../../../Types/Dashboard/DashboardComponentType";
14
+ import { DashboardVariableType } from "../../../Types/Dashboard/DashboardVariable";
15
+ import DashboardViewConfig from "../../../Types/Dashboard/DashboardViewConfig";
16
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
17
+ import BadDataException from "../../../Types/Exception/BadDataException";
18
+ import NotAuthenticatedException from "../../../Types/Exception/NotAuthenticatedException";
19
+ import NotFoundException from "../../../Types/Exception/NotFoundException";
20
+ import { JSONObject } from "../../../Types/JSON";
21
+ import JSONFunctions from "../../../Types/JSONFunctions";
22
+ import ObjectID from "../../../Types/ObjectID";
23
+ import { mockRouter } from "./Helpers";
24
+ import {
25
+ afterEach,
26
+ beforeAll,
27
+ beforeEach,
28
+ describe,
29
+ expect,
30
+ it,
31
+ } from "@jest/globals";
32
+
33
+ jest.mock("../../../Server/Utils/Express", () => {
34
+ return {
35
+ getRouter: () => {
36
+ return mockRouter;
37
+ },
38
+ };
39
+ });
40
+
41
+ jest.mock("../../../Server/Utils/Response", () => {
42
+ return {
43
+ sendEntityArrayResponse: jest.fn().mockImplementation((...args: []) => {
44
+ return args;
45
+ }),
46
+ sendJsonObjectResponse: jest.fn().mockImplementation((...args: []) => {
47
+ return args;
48
+ }),
49
+ sendEmptySuccessResponse: jest.fn(),
50
+ sendEntityResponse: jest.fn().mockImplementation((...args: []) => {
51
+ return args;
52
+ }),
53
+ sendErrorResponse: jest.fn().mockImplementation((...args: []) => {
54
+ return args;
55
+ }),
56
+ };
57
+ });
58
+
59
+ const METRICS_AGGREGATE_ROUTE: string =
60
+ "/dashboard/metrics-aggregate/:dashboardId";
61
+
62
+ const CHARTED_METRIC_NAME: string = "system.cpu.utilization";
63
+
64
+ const CHARTED_GROUP_BY_KEY: string = "resource.host.name";
65
+
66
+ describe("DashboardAPI public metrics-aggregate", () => {
67
+ let dashboardId: ObjectID;
68
+ let projectId: ObjectID;
69
+ let dashboard: Dashboard;
70
+ let mockResponse: ExpressResponse;
71
+ let nextFunction: NextFunction;
72
+
73
+ beforeAll(() => {
74
+ mockRouter.routes.length = 0;
75
+ new DashboardAPI();
76
+ });
77
+
78
+ type MetricWidgetSpec = {
79
+ metricName: string;
80
+ groupByAttributeKeys?: Array<string> | undefined;
81
+ groupByAttributes?: Array<JSONObject> | undefined;
82
+ groupBy?: JSONObject | undefined;
83
+ /*
84
+ * `metricQueryData.filterData.attributes` — the attribute filter a chart
85
+ * widget persists when the author pins a value in the query editor.
86
+ */
87
+ attributes?: JSONObject | undefined;
88
+ /*
89
+ * `arguments.attributeFilters` — the table widget's widget-level filter,
90
+ * spread into every column's query by resolveQueries().
91
+ */
92
+ attributeFilters?: JSONObject | undefined;
93
+ };
94
+
95
+ type BuildViewConfigFunction = (
96
+ widgets: Array<MetricWidgetSpec>,
97
+ variableAttributeKeys?: Array<string> | undefined,
98
+ ) => DashboardViewConfig;
99
+
100
+ /*
101
+ * A stored chart widget, shaped the way the canvas persists it: the query
102
+ * lives under `arguments.metricQueryConfigs[].metricQueryData`.
103
+ */
104
+ const buildViewConfig: BuildViewConfigFunction = (
105
+ widgets: Array<MetricWidgetSpec>,
106
+ variableAttributeKeys?: Array<string> | undefined,
107
+ ): DashboardViewConfig => {
108
+ return {
109
+ _type: "DashboardViewConfig",
110
+ heightInDashboardUnits: 24,
111
+ variables: (variableAttributeKeys || []).map(
112
+ (attributeKey: string, index: number): JSONObject => {
113
+ return {
114
+ id: ObjectID.generate().toString(),
115
+ name: `variable-${index}`,
116
+ label: `Variable ${index}`,
117
+ type: DashboardVariableType.TelemetryAttribute,
118
+ attributeKey: attributeKey,
119
+ };
120
+ },
121
+ ),
122
+ components: widgets.map(
123
+ (widget: MetricWidgetSpec, index: number): JSONObject => {
124
+ return {
125
+ _type: "DashboardComponent",
126
+ componentId: ObjectID.generate().toString(),
127
+ componentType: DashboardComponentType.Chart,
128
+ topInDashboardUnits: index,
129
+ leftInDashboardUnits: 0,
130
+ widthInDashboardUnits: 12,
131
+ heightInDashboardUnits: 6,
132
+ arguments: {
133
+ ...(widget.groupByAttributes
134
+ ? { groupByAttributes: widget.groupByAttributes }
135
+ : {}),
136
+ ...(widget.attributeFilters
137
+ ? { attributeFilters: widget.attributeFilters }
138
+ : {}),
139
+ metricQueryConfigs: [
140
+ {
141
+ metricQueryData: {
142
+ filterData: {
143
+ metricName: widget.metricName,
144
+ ...(widget.attributes
145
+ ? { attributes: widget.attributes }
146
+ : {}),
147
+ },
148
+ ...(widget.groupByAttributeKeys
149
+ ? { groupByAttributeKeys: widget.groupByAttributeKeys }
150
+ : {}),
151
+ ...(widget.groupBy ? { groupBy: widget.groupBy } : {}),
152
+ },
153
+ },
154
+ ],
155
+ },
156
+ };
157
+ },
158
+ ),
159
+ } as unknown as DashboardViewConfig;
160
+ };
161
+
162
+ type SetWidgetsFunction = (
163
+ widgets: Array<MetricWidgetSpec>,
164
+ variableAttributeKeys?: Array<string> | undefined,
165
+ ) => void;
166
+
167
+ const setWidgets: SetWidgetsFunction = (
168
+ widgets: Array<MetricWidgetSpec>,
169
+ variableAttributeKeys?: Array<string> | undefined,
170
+ ): void => {
171
+ dashboard.dashboardViewConfig = buildViewConfig(
172
+ widgets,
173
+ variableAttributeKeys,
174
+ );
175
+ };
176
+
177
+ type BuildAggregateByFunction = (overrides?: JSONObject) => JSONObject;
178
+
179
+ const buildAggregateBy: BuildAggregateByFunction = (
180
+ overrides?: JSONObject,
181
+ ): JSONObject => {
182
+ return {
183
+ query: {
184
+ name: CHARTED_METRIC_NAME,
185
+ },
186
+ aggregateColumnName: "value",
187
+ aggregationType: "Avg",
188
+ aggregationTimestampColumnName: "time",
189
+ startTimestamp: "2026-01-01T00:00:00.000Z",
190
+ endTimestamp: "2026-01-02T00:00:00.000Z",
191
+ limit: 100,
192
+ skip: 0,
193
+ ...(overrides || {}),
194
+ };
195
+ };
196
+
197
+ type CallRouteFunction = (body?: JSONObject) => Promise<void>;
198
+
199
+ const callRoute: CallRouteFunction = async (
200
+ body?: JSONObject,
201
+ ): Promise<void> => {
202
+ const request: ExpressRequest = {
203
+ params: {
204
+ dashboardId: dashboardId.toString(),
205
+ },
206
+ body: body === undefined ? { aggregateBy: buildAggregateBy() } : body,
207
+ query: {},
208
+ cookies: {},
209
+ headers: {},
210
+ socket: {},
211
+ ips: [],
212
+ } as unknown as ExpressRequest;
213
+
214
+ await mockRouter
215
+ .match("post", METRICS_AGGREGATE_ROUTE)
216
+ .handlerFunction(request, mockResponse, nextFunction);
217
+ };
218
+
219
+ type CallWithAggregateFunction = (overrides?: JSONObject) => Promise<void>;
220
+
221
+ const callWithAggregate: CallWithAggregateFunction = async (
222
+ overrides?: JSONObject,
223
+ ): Promise<void> => {
224
+ await callRoute({ aggregateBy: buildAggregateBy(overrides) });
225
+ };
226
+
227
+ type GetAggregateArgsFunction = () => JSONObject;
228
+
229
+ const getAggregateArgs: GetAggregateArgsFunction = (): JSONObject => {
230
+ const calls: Array<Array<unknown>> = (
231
+ MetricService.aggregateBy as jest.Mock
232
+ ).mock.calls as Array<Array<unknown>>;
233
+
234
+ expect(calls.length).toBe(1);
235
+
236
+ return calls[0]![0] as JSONObject;
237
+ };
238
+
239
+ type GetThrownErrorFunction = () => unknown;
240
+
241
+ const getThrownError: GetThrownErrorFunction = (): unknown => {
242
+ const calls: Array<Array<unknown>> = (nextFunction as jest.Mock).mock
243
+ .calls as Array<Array<unknown>>;
244
+
245
+ expect(calls.length).toBe(1);
246
+
247
+ return calls[0]![0];
248
+ };
249
+
250
+ type ExpectNothingAggregatedFunction = () => void;
251
+
252
+ const expectNothingAggregated: ExpectNothingAggregatedFunction = (): void => {
253
+ expect(MetricService.aggregateBy).not.toHaveBeenCalled();
254
+ };
255
+
256
+ beforeEach(() => {
257
+ jest.clearAllMocks();
258
+
259
+ dashboardId = ObjectID.generate();
260
+ projectId = ObjectID.generate();
261
+
262
+ dashboard = new Dashboard();
263
+ dashboard.id = dashboardId;
264
+ dashboard.projectId = projectId;
265
+ dashboard.isPublicDashboard = true;
266
+ dashboard.enableMasterPassword = false;
267
+ setWidgets([{ metricName: CHARTED_METRIC_NAME }]);
268
+
269
+ jest.spyOn(DashboardService, "findOneById").mockResolvedValue(dashboard);
270
+
271
+ jest.spyOn(MetricService, "aggregateBy").mockResolvedValue({ data: [] });
272
+
273
+ mockResponse = {
274
+ cookie: jest.fn(),
275
+ send: jest.fn(),
276
+ json: jest.fn(),
277
+ status: jest.fn().mockReturnThis(),
278
+ } as unknown as ExpressResponse;
279
+
280
+ nextFunction = jest.fn();
281
+ });
282
+
283
+ afterEach(() => {
284
+ jest.restoreAllMocks();
285
+ });
286
+
287
+ describe("metric allowlist", () => {
288
+ it("aggregates a metric the dashboard charts", async () => {
289
+ await callWithAggregate();
290
+
291
+ expect(nextFunction).not.toHaveBeenCalled();
292
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
293
+ });
294
+
295
+ it("refuses a metric the dashboard does not chart", async () => {
296
+ await callWithAggregate({ query: { name: "billing.revenue.total" } });
297
+
298
+ const error: unknown = getThrownError();
299
+
300
+ expect(error).toBeInstanceOf(BadDataException);
301
+ expect((error as BadDataException).message).toBe(
302
+ "This metric is not part of this dashboard.",
303
+ );
304
+ expectNothingAggregated();
305
+ });
306
+
307
+ it("refuses a non-string metric name used as an operator", async () => {
308
+ await callWithAggregate({
309
+ query: { name: { $ne: "nothing" } as unknown as string },
310
+ });
311
+
312
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
313
+ expectNothingAggregated();
314
+ });
315
+
316
+ it("requires an aggregateBy body", async () => {
317
+ await callRoute({});
318
+
319
+ const error: unknown = getThrownError();
320
+
321
+ expect(error).toBeInstanceOf(BadDataException);
322
+ expect((error as BadDataException).message).toBe(
323
+ "aggregateBy is required.",
324
+ );
325
+ expectNothingAggregated();
326
+ });
327
+ });
328
+
329
+ describe("group-by attribute allowlist (GHSA-w332-x78m-vf3v, related)", () => {
330
+ it("refuses to group an allowlisted metric by an attribute the widget never renders", async () => {
331
+ /*
332
+ * Grouped results echo each group's key values back to the caller, so
333
+ * an unrestricted groupByAttributeKeys turns a charted metric into a
334
+ * reader for arbitrary attribute values.
335
+ */
336
+ setWidgets([
337
+ {
338
+ metricName: CHARTED_METRIC_NAME,
339
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
340
+ },
341
+ ]);
342
+
343
+ await callWithAggregate({ groupByAttributeKeys: ["enduser.id"] });
344
+
345
+ const error: unknown = getThrownError();
346
+
347
+ expect(error).toBeInstanceOf(BadDataException);
348
+ expect((error as BadDataException).message).toBe(
349
+ "This attribute is not part of this dashboard.",
350
+ );
351
+ expectNothingAggregated();
352
+ });
353
+
354
+ it("refuses any group-by attribute at all when the widget groups by none", async () => {
355
+ for (const attributeKey of [
356
+ "user.email",
357
+ "http.url",
358
+ "db.statement",
359
+ "resource.host.name",
360
+ ]) {
361
+ jest.clearAllMocks();
362
+ setWidgets([{ metricName: CHARTED_METRIC_NAME }]);
363
+
364
+ await callWithAggregate({ groupByAttributeKeys: [attributeKey] });
365
+
366
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
367
+ expectNothingAggregated();
368
+ }
369
+ });
370
+
371
+ it("allows the attribute keys the widget is configured to group by", async () => {
372
+ setWidgets([
373
+ {
374
+ metricName: CHARTED_METRIC_NAME,
375
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY, "disk.device"],
376
+ },
377
+ ]);
378
+
379
+ await callWithAggregate({
380
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY, "disk.device"],
381
+ });
382
+
383
+ expect(nextFunction).not.toHaveBeenCalled();
384
+ expect(getAggregateArgs()["groupByAttributeKeys"]).toEqual([
385
+ CHARTED_GROUP_BY_KEY,
386
+ "disk.device",
387
+ ]);
388
+ });
389
+
390
+ it("allows keys configured through the table widget's groupByAttributes form", async () => {
391
+ setWidgets([
392
+ {
393
+ metricName: CHARTED_METRIC_NAME,
394
+ groupByAttributes: [{ key: "resource.service.name" }],
395
+ },
396
+ ]);
397
+
398
+ await callWithAggregate({
399
+ groupByAttributeKeys: ["resource.service.name"],
400
+ });
401
+
402
+ expect(nextFunction).not.toHaveBeenCalled();
403
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
404
+ });
405
+
406
+ it("rejects a request that smuggles one forbidden key in beside allowed ones", async () => {
407
+ setWidgets([
408
+ {
409
+ metricName: CHARTED_METRIC_NAME,
410
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
411
+ },
412
+ ]);
413
+
414
+ await callWithAggregate({
415
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY, "user.email"],
416
+ });
417
+
418
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
419
+ expectNothingAggregated();
420
+ });
421
+
422
+ it("rejects a groupByAttributeKeys that is not an array of strings", async () => {
423
+ setWidgets([
424
+ {
425
+ metricName: CHARTED_METRIC_NAME,
426
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
427
+ },
428
+ ]);
429
+
430
+ const badValues: Array<unknown> = [
431
+ CHARTED_GROUP_BY_KEY,
432
+ 42,
433
+ { 0: CHARTED_GROUP_BY_KEY },
434
+ [42],
435
+ [null],
436
+ [{ key: CHARTED_GROUP_BY_KEY }],
437
+ ];
438
+
439
+ for (const badValue of badValues) {
440
+ jest.clearAllMocks();
441
+
442
+ await callWithAggregate({
443
+ groupByAttributeKeys: badValue as Array<string>,
444
+ });
445
+
446
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
447
+ expectNothingAggregated();
448
+ }
449
+ });
450
+
451
+ it("leaves an ungrouped request untouched", async () => {
452
+ await callWithAggregate();
453
+
454
+ expect(nextFunction).not.toHaveBeenCalled();
455
+ expect(getAggregateArgs()["groupByAttributeKeys"]).toBeUndefined();
456
+ });
457
+
458
+ it("accepts an explicitly empty group-by list", async () => {
459
+ await callWithAggregate({ groupByAttributeKeys: [] });
460
+
461
+ expect(nextFunction).not.toHaveBeenCalled();
462
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
463
+ });
464
+ });
465
+
466
+ describe("group-by column allowlist", () => {
467
+ it("refuses to hand back the whole attribute map of a charted metric", async () => {
468
+ /*
469
+ * `groupBy: { attributes: true }` groups on the ENTIRE attribute map
470
+ * and echoes it back in every result row — a project-wide attribute
471
+ * dump for any metric the dashboard happens to chart.
472
+ */
473
+ await callWithAggregate({ groupBy: { attributes: true } });
474
+
475
+ const error: unknown = getThrownError();
476
+
477
+ expect(error).toBeInstanceOf(BadDataException);
478
+ expect((error as BadDataException).message).toBe(
479
+ "This grouping is not part of this dashboard.",
480
+ );
481
+ expectNothingAggregated();
482
+ });
483
+
484
+ it("refuses a column the widget does not group by", async () => {
485
+ setWidgets([
486
+ {
487
+ metricName: CHARTED_METRIC_NAME,
488
+ groupBy: { serviceId: true },
489
+ },
490
+ ]);
491
+
492
+ await callWithAggregate({ groupBy: { attributes: true } });
493
+
494
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
495
+ expectNothingAggregated();
496
+ });
497
+
498
+ it("allows the column the widget is configured to group by", async () => {
499
+ setWidgets([
500
+ {
501
+ metricName: CHARTED_METRIC_NAME,
502
+ groupBy: { serviceId: true },
503
+ },
504
+ ]);
505
+
506
+ await callWithAggregate({ groupBy: { serviceId: true } });
507
+
508
+ expect(nextFunction).not.toHaveBeenCalled();
509
+ expect(getAggregateArgs()["groupBy"]).toEqual({ serviceId: true });
510
+ });
511
+
512
+ it("does not treat a disabled group-by column as configured", async () => {
513
+ setWidgets([
514
+ {
515
+ metricName: CHARTED_METRIC_NAME,
516
+ groupBy: { attributes: false },
517
+ },
518
+ ]);
519
+
520
+ await callWithAggregate({ groupBy: { attributes: true } });
521
+
522
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
523
+ expectNothingAggregated();
524
+ });
525
+
526
+ it("rejects a groupBy that is not an object", async () => {
527
+ const badValues: Array<unknown> = [
528
+ "attributes",
529
+ ["attributes"],
530
+ 42,
531
+ true,
532
+ ];
533
+
534
+ for (const badValue of badValues) {
535
+ jest.clearAllMocks();
536
+
537
+ await callWithAggregate({ groupBy: badValue as JSONObject });
538
+
539
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
540
+ expectNothingAggregated();
541
+ }
542
+ });
543
+
544
+ it("accepts an explicitly empty groupBy", async () => {
545
+ await callWithAggregate({ groupBy: {} });
546
+
547
+ expect(nextFunction).not.toHaveBeenCalled();
548
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
549
+ });
550
+
551
+ it("does not let one widget's grouping unlock another widget's metric", async () => {
552
+ /*
553
+ * The allowlists are dashboard-wide by design (the config is a single
554
+ * tenant-owned document), but neither may be widened by the client.
555
+ */
556
+ setWidgets([
557
+ { metricName: CHARTED_METRIC_NAME },
558
+ {
559
+ metricName: "system.memory.usage",
560
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
561
+ },
562
+ ]);
563
+
564
+ await callWithAggregate({
565
+ query: { name: "system.disk.io" },
566
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
567
+ });
568
+
569
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
570
+ expectNothingAggregated();
571
+ });
572
+ });
573
+
574
+ describe("query filter allowlist", () => {
575
+ /*
576
+ * Pinning `query.name` left the rest of the client's query merged in.
577
+ * An `attributes` filter on an arbitrary key is a blind oracle: the
578
+ * caller cannot see the values, but "did this aggregation return rows"
579
+ * leaks them a character at a time through operators like StartsWith.
580
+ * MetricService deliberately leaves the pre-aggregated fast path when an
581
+ * attributes filter is present, so the probe runs against raw rows.
582
+ */
583
+ it("refuses an attributes filter on a key the dashboard never filters by", async () => {
584
+ await callWithAggregate({
585
+ query: {
586
+ name: CHARTED_METRIC_NAME,
587
+ attributes: {
588
+ "enduser.id": { _type: "StartsWith", value: "a" },
589
+ },
590
+ },
591
+ });
592
+
593
+ const error: unknown = getThrownError();
594
+
595
+ expect(error).toBeInstanceOf(BadDataException);
596
+ expect((error as BadDataException).message).toBe(
597
+ "This attribute is not part of this dashboard.",
598
+ );
599
+ expectNothingAggregated();
600
+ });
601
+
602
+ it("refuses every operator shape usable as a blind oracle", async () => {
603
+ const operatorTypes: Array<string> = [
604
+ "Search",
605
+ "StartsWith",
606
+ "EndsWith",
607
+ "NotContains",
608
+ "EqualTo",
609
+ "NotEqual",
610
+ "Includes",
611
+ ];
612
+
613
+ for (const operatorType of operatorTypes) {
614
+ jest.clearAllMocks();
615
+
616
+ await callWithAggregate({
617
+ query: {
618
+ name: CHARTED_METRIC_NAME,
619
+ attributes: {
620
+ "user.email": { _type: operatorType, value: "a" },
621
+ },
622
+ },
623
+ });
624
+
625
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
626
+ expectNothingAggregated();
627
+ }
628
+ });
629
+
630
+ it("forwards an attributes filter on a key the widget itself filters by", async () => {
631
+ setWidgets([
632
+ {
633
+ metricName: CHARTED_METRIC_NAME,
634
+ attributes: { "host.name": "web-1" },
635
+ },
636
+ ]);
637
+
638
+ await callWithAggregate({
639
+ query: {
640
+ name: CHARTED_METRIC_NAME,
641
+ attributes: { "host.name": "web-2" },
642
+ },
643
+ });
644
+
645
+ expect(nextFunction).not.toHaveBeenCalled();
646
+
647
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
648
+
649
+ expect((query["attributes"] as JSONObject)["host.name"]).toBe("web-2");
650
+ });
651
+
652
+ it("forwards an attributes filter on a key only a dashboard variable binds to", async () => {
653
+ /*
654
+ * The shipped templates persist `filterData: { metricName,
655
+ * aggegationType }` and no attributes at all — the filter on
656
+ * `resource.k8s.cluster.name` is injected at render time by
657
+ * DashboardVariableInterpolation once the viewer picks a cluster. An
658
+ * allowlist built only from stored filters would reject every real
659
+ * templated dashboard the moment its variable is used.
660
+ */
661
+ setWidgets(
662
+ [{ metricName: CHARTED_METRIC_NAME }],
663
+ ["resource.k8s.cluster.name"],
664
+ );
665
+
666
+ await callWithAggregate({
667
+ query: {
668
+ name: CHARTED_METRIC_NAME,
669
+ attributes: { "resource.k8s.cluster.name": "prod-eu" },
670
+ },
671
+ });
672
+
673
+ expect(nextFunction).not.toHaveBeenCalled();
674
+
675
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
676
+
677
+ expect(
678
+ (query["attributes"] as JSONObject)["resource.k8s.cluster.name"],
679
+ ).toBe("prod-eu");
680
+ });
681
+
682
+ it("forwards the widget-level attributeFilters a table widget persists", async () => {
683
+ setWidgets([
684
+ {
685
+ metricName: CHARTED_METRIC_NAME,
686
+ attributeFilters: { environment: "production" },
687
+ },
688
+ ]);
689
+
690
+ await callWithAggregate({
691
+ query: {
692
+ name: CHARTED_METRIC_NAME,
693
+ attributes: { environment: "production" },
694
+ },
695
+ });
696
+
697
+ expect(nextFunction).not.toHaveBeenCalled();
698
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
699
+ });
700
+
701
+ it("drops every other client-supplied query key", async () => {
702
+ await callWithAggregate({
703
+ query: {
704
+ name: CHARTED_METRIC_NAME,
705
+ serviceId: ObjectID.generate().toString(),
706
+ primaryEntityId: "smuggled",
707
+ retentionDate: "2020-01-01",
708
+ value: { _type: "GreaterThan", value: 0 },
709
+ },
710
+ });
711
+
712
+ expect(nextFunction).not.toHaveBeenCalled();
713
+
714
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
715
+
716
+ expect(Object.keys(query).sort()).toEqual(["name", "projectId"]);
717
+ });
718
+
719
+ it("keeps the time window the widget asks for", async () => {
720
+ await callWithAggregate({
721
+ query: {
722
+ name: CHARTED_METRIC_NAME,
723
+ time: {
724
+ _type: "InBetween",
725
+ value: {
726
+ startValue: "2026-01-01T00:00:00.000Z",
727
+ endValue: "2026-01-02T00:00:00.000Z",
728
+ },
729
+ },
730
+ },
731
+ });
732
+
733
+ expect(nextFunction).not.toHaveBeenCalled();
734
+
735
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
736
+
737
+ expect(query["time"]).toBeDefined();
738
+ });
739
+
740
+ it("pins the metric name as an own property of the query it aggregates", async () => {
741
+ await callWithAggregate();
742
+
743
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
744
+
745
+ expect(Object.prototype.hasOwnProperty.call(query, "name")).toBe(true);
746
+ expect(query["name"]).toBe(CHARTED_METRIC_NAME);
747
+ });
748
+
749
+ it("refuses a query that is not an object", async () => {
750
+ const badQueries: Array<unknown> = [null, "name", ["name"], 42, true];
751
+
752
+ for (const badQuery of badQueries) {
753
+ jest.clearAllMocks();
754
+
755
+ await callWithAggregate({ query: badQuery as JSONObject });
756
+
757
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
758
+ expectNothingAggregated();
759
+ }
760
+ });
761
+ });
762
+
763
+ /*
764
+ * JSON.parse turns a literal "__proto__" member into a real own key, and
765
+ * JSONFunctions.deserialize copies it with `newVal[key] = ...`, which fires
766
+ * Object.prototype's __proto__ setter. Every one of these bodies must be
767
+ * built with JSON.parse — a JS object literal sets the prototype at parse
768
+ * time and never produces the own key that makes the attack work.
769
+ */
770
+ describe("prototype-carried keys", () => {
771
+ type ProtoBodyFunction = (json: string) => JSONObject;
772
+
773
+ const protoBody: ProtoBodyFunction = (json: string): JSONObject => {
774
+ return JSON.parse(json) as JSONObject;
775
+ };
776
+
777
+ it("refuses a metric name inherited through __proto__", async () => {
778
+ /*
779
+ * The allowlist check used to read `query["name"]` (which walks the
780
+ * prototype chain and saw the allowlisted name) while the project pin
781
+ * spread only own properties (which dropped it) — so the aggregation
782
+ * reached ClickHouse with NO name predicate at all: every metric in
783
+ * the project, from one anonymous request.
784
+ */
785
+ await callRoute(
786
+ protoBody(
787
+ `{"aggregateBy":{"query":{"__proto__":{"name":"${CHARTED_METRIC_NAME}"}},` +
788
+ `"aggregateColumnName":"value","aggregationType":"Avg",` +
789
+ `"aggregationTimestampColumnName":"time",` +
790
+ `"startTimestamp":"2026-01-01T00:00:00.000Z",` +
791
+ `"endTimestamp":"2026-01-02T00:00:00.000Z","limit":100,"skip":0}}`,
792
+ ),
793
+ );
794
+
795
+ const error: unknown = getThrownError();
796
+
797
+ expect(error).toBeInstanceOf(BadDataException);
798
+ expect((error as BadDataException).message).toBe(
799
+ "This metric is not part of this dashboard.",
800
+ );
801
+ expectNothingAggregated();
802
+ });
803
+
804
+ it("refuses an attributes filter smuggled through __proto__", async () => {
805
+ await callRoute(
806
+ protoBody(
807
+ `{"aggregateBy":{"query":{"name":"${CHARTED_METRIC_NAME}",` +
808
+ `"attributes":{"__proto__":{"user.email":"a"}}},` +
809
+ `"aggregateColumnName":"value","aggregationType":"Avg",` +
810
+ `"aggregationTimestampColumnName":"time",` +
811
+ `"startTimestamp":"2026-01-01T00:00:00.000Z",` +
812
+ `"endTimestamp":"2026-01-02T00:00:00.000Z","limit":100,"skip":0}}`,
813
+ ),
814
+ );
815
+
816
+ expect(nextFunction).not.toHaveBeenCalled();
817
+
818
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
819
+ const attributes: unknown = query["attributes"];
820
+
821
+ // Nothing inherited may survive into the object handed to the service.
822
+ for (const key in (attributes || {}) as Record<string, unknown>) {
823
+ expect(key).not.toBe("user.email");
824
+ }
825
+ });
826
+
827
+ it("does not let a group-by column hide on the prototype", async () => {
828
+ setWidgets([
829
+ {
830
+ metricName: CHARTED_METRIC_NAME,
831
+ groupBy: { serviceEntityKey: true },
832
+ },
833
+ ]);
834
+
835
+ await callRoute(
836
+ protoBody(
837
+ `{"aggregateBy":{"query":{"name":"${CHARTED_METRIC_NAME}"},` +
838
+ `"groupBy":{"serviceEntityKey":true,"__proto__":{"attributes":true}},` +
839
+ `"aggregateColumnName":"value","aggregationType":"Avg",` +
840
+ `"aggregationTimestampColumnName":"time",` +
841
+ `"startTimestamp":"2026-01-01T00:00:00.000Z",` +
842
+ `"endTimestamp":"2026-01-02T00:00:00.000Z","limit":100,"skip":0}}`,
843
+ ),
844
+ );
845
+
846
+ expect(nextFunction).not.toHaveBeenCalled();
847
+
848
+ /*
849
+ * The guard uses Object.keys but the SQL builder walks groupBy with
850
+ * for...in, so an inherited key would reach GROUP BY unvalidated.
851
+ * Assert the two agree on the object actually handed to the service.
852
+ */
853
+ const groupBy: Record<string, unknown> = getAggregateArgs()[
854
+ "groupBy"
855
+ ] as Record<string, unknown>;
856
+ const enumerated: Array<string> = [];
857
+
858
+ for (const key in groupBy) {
859
+ enumerated.push(key);
860
+ }
861
+
862
+ expect(enumerated).toEqual(["serviceEntityKey"]);
863
+ });
864
+
865
+ it("does not treat a prototype key as a configured grouping", async () => {
866
+ await callWithAggregate({
867
+ groupByAttributeKeys: ["__proto__", "constructor", "toString"],
868
+ });
869
+
870
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
871
+ expectNothingAggregated();
872
+ });
873
+ });
874
+
875
+ describe("aggregate column and pagination", () => {
876
+ /*
877
+ * `aggregateColumnName` was forwarded verbatim. A non-`value` column
878
+ * skips every MetricService fast path and falls through to
879
+ * `max(<column>) as <column>`; the row mapper only parseFloats strings
880
+ * and copies anything else into `value` untouched — so asking to
881
+ * aggregate `attributes` returned a whole attribute map per time bucket.
882
+ */
883
+ it("refuses an aggregateColumnName the widgets never aggregate", async () => {
884
+ const columns: Array<string> = [
885
+ "attributes",
886
+ "name",
887
+ "serviceEntityKey",
888
+ "projectId",
889
+ "time",
890
+ ];
891
+
892
+ for (const column of columns) {
893
+ jest.clearAllMocks();
894
+
895
+ await callWithAggregate({ aggregateColumnName: column });
896
+
897
+ const error: unknown = getThrownError();
898
+
899
+ expect(error).toBeInstanceOf(BadDataException);
900
+ expect((error as BadDataException).message).toBe(
901
+ "Invalid aggregateColumnName.",
902
+ );
903
+ expectNothingAggregated();
904
+ }
905
+ });
906
+
907
+ it("refuses an aggregationTimestampColumnName other than time", async () => {
908
+ for (const column of ["createdAt", "attributes", "value"]) {
909
+ jest.clearAllMocks();
910
+
911
+ await callWithAggregate({ aggregationTimestampColumnName: column });
912
+
913
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
914
+ expectNothingAggregated();
915
+ }
916
+ });
917
+
918
+ it("accepts the value/time pair every widget actually sends", async () => {
919
+ await callWithAggregate({
920
+ aggregateColumnName: "value",
921
+ aggregationTimestampColumnName: "time",
922
+ });
923
+
924
+ expect(nextFunction).not.toHaveBeenCalled();
925
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
926
+ });
927
+
928
+ it("clamps an oversized limit and a negative skip", async () => {
929
+ await callWithAggregate({ limit: 10000000, skip: -5 });
930
+
931
+ expect(nextFunction).not.toHaveBeenCalled();
932
+
933
+ const args: JSONObject = getAggregateArgs();
934
+
935
+ expect(args["limit"]).toBe(LIMIT_PER_PROJECT);
936
+ expect(args["skip"]).toBe(0);
937
+ });
938
+
939
+ it("falls back to a sane limit for a nonsensical one", async () => {
940
+ for (const limit of [0, -1, "not-a-number", null]) {
941
+ jest.clearAllMocks();
942
+
943
+ await callWithAggregate({ limit: limit as number });
944
+
945
+ expect(nextFunction).not.toHaveBeenCalled();
946
+
947
+ const limitSent: unknown = getAggregateArgs()["limit"];
948
+
949
+ expect(typeof limitSent).toBe("number");
950
+ expect(limitSent as number).toBeGreaterThan(0);
951
+ expect(limitSent as number).toBeLessThanOrEqual(LIMIT_PER_PROJECT);
952
+ }
953
+ });
954
+
955
+ it("honours a sensible limit and skip", async () => {
956
+ await callWithAggregate({ limit: 25, skip: 50 });
957
+
958
+ const args: JSONObject = getAggregateArgs();
959
+
960
+ expect(args["limit"]).toBe(25);
961
+ expect(args["skip"]).toBe(50);
962
+ });
963
+ });
964
+
965
+ describe("null handling", () => {
966
+ /*
967
+ * JSONFunctions.serialize drops `undefined` but preserves `null`, and
968
+ * both downstream sinks already treat a null groupBy as absent — so the
969
+ * route must too, or an ungrouped widget round-tripped through the wire
970
+ * would 400.
971
+ */
972
+ it("treats an explicit null groupBy and groupByAttributeKeys as absent", async () => {
973
+ await callWithAggregate({
974
+ groupBy: null as unknown as JSONObject,
975
+ groupByAttributeKeys: null as unknown as Array<string>,
976
+ });
977
+
978
+ expect(nextFunction).not.toHaveBeenCalled();
979
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
980
+ });
981
+ });
982
+
983
+ describe("authorization", () => {
984
+ it("rejects a dashboard that is not public before aggregating anything", async () => {
985
+ dashboard.isPublicDashboard = false;
986
+
987
+ await callWithAggregate();
988
+
989
+ expect(getThrownError()).toBeInstanceOf(NotAuthenticatedException);
990
+ expectNothingAggregated();
991
+ });
992
+
993
+ it("rejects a dashboard that cannot be found", async () => {
994
+ jest.spyOn(DashboardService, "findOneById").mockResolvedValue(null);
995
+
996
+ await callWithAggregate();
997
+
998
+ expect(getThrownError()).toBeInstanceOf(NotAuthenticatedException);
999
+ expectNothingAggregated();
1000
+ });
1001
+
1002
+ it("rejects a dashboard with no project", async () => {
1003
+ delete dashboard.projectId;
1004
+
1005
+ await callWithAggregate();
1006
+
1007
+ expect(getThrownError()).toBeInstanceOf(NotFoundException);
1008
+ expectNothingAggregated();
1009
+ });
1010
+ });
1011
+
1012
+ describe("stored config shapes", () => {
1013
+ it("collects group-by keys from every shape the widgets persist", async () => {
1014
+ const shapes: Array<{ label: string; config: JSONObject }> = [
1015
+ {
1016
+ label: "table widget groupByAttributes",
1017
+ config: {
1018
+ arguments: {
1019
+ groupByAttributes: [{ key: "shape.key" }],
1020
+ metricQueryConfigs: [
1021
+ { metricQueryData: { filterData: { metricName: "m" } } },
1022
+ ],
1023
+ },
1024
+ },
1025
+ },
1026
+ {
1027
+ label: "table widget groupByAttributeKeys",
1028
+ config: {
1029
+ arguments: {
1030
+ groupByAttributeKeys: ["shape.key"],
1031
+ metricQueryConfigs: [
1032
+ { metricQueryData: { filterData: { metricName: "m" } } },
1033
+ ],
1034
+ },
1035
+ },
1036
+ },
1037
+ {
1038
+ label: "legacy singular metricQueryConfig",
1039
+ config: {
1040
+ arguments: {
1041
+ metricQueryConfig: {
1042
+ metricQueryData: {
1043
+ filterData: { metricName: "m" },
1044
+ groupByAttributeKeys: ["shape.key"],
1045
+ },
1046
+ },
1047
+ },
1048
+ },
1049
+ },
1050
+ {
1051
+ label: "plural metricQueryConfigs",
1052
+ config: {
1053
+ arguments: {
1054
+ metricQueryConfigs: [
1055
+ {
1056
+ metricQueryData: {
1057
+ filterData: { metricName: "m" },
1058
+ groupByAttributeKeys: ["shape.key"],
1059
+ },
1060
+ },
1061
+ ],
1062
+ },
1063
+ },
1064
+ },
1065
+ ];
1066
+
1067
+ for (const shape of shapes) {
1068
+ jest.clearAllMocks();
1069
+
1070
+ dashboard.dashboardViewConfig = {
1071
+ _type: "DashboardViewConfig",
1072
+ heightInDashboardUnits: 24,
1073
+ components: [
1074
+ {
1075
+ componentType: DashboardComponentType.Chart,
1076
+ ...shape.config,
1077
+ },
1078
+ ],
1079
+ } as unknown as DashboardViewConfig;
1080
+
1081
+ await callWithAggregate({
1082
+ query: { name: "m" },
1083
+ groupByAttributeKeys: ["shape.key"],
1084
+ });
1085
+
1086
+ expect(nextFunction).not.toHaveBeenCalled();
1087
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
1088
+ }
1089
+ });
1090
+
1091
+ it("survives malformed groupByAttributes without widening the allowlist", async () => {
1092
+ const malformed: Array<unknown> = [
1093
+ null,
1094
+ 7,
1095
+ "shape.key",
1096
+ {},
1097
+ { key: 42 },
1098
+ { key: "" },
1099
+ [],
1100
+ ];
1101
+
1102
+ for (const entry of malformed) {
1103
+ jest.clearAllMocks();
1104
+
1105
+ setWidgets([
1106
+ {
1107
+ metricName: CHARTED_METRIC_NAME,
1108
+ groupByAttributes: [entry as JSONObject],
1109
+ },
1110
+ ]);
1111
+
1112
+ await callWithAggregate({ groupByAttributeKeys: ["shape.key"] });
1113
+
1114
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
1115
+ expectNothingAggregated();
1116
+ }
1117
+ });
1118
+
1119
+ it("allows whole-attribute-map grouping when the widget itself is configured that way", async () => {
1120
+ // Legacy stored configs still carry groupBy: { attributes: true }.
1121
+ setWidgets([
1122
+ {
1123
+ metricName: CHARTED_METRIC_NAME,
1124
+ groupBy: { attributes: true },
1125
+ },
1126
+ ]);
1127
+
1128
+ await callWithAggregate({ groupBy: { attributes: true } });
1129
+
1130
+ expect(nextFunction).not.toHaveBeenCalled();
1131
+ expect(MetricService.aggregateBy).toHaveBeenCalledTimes(1);
1132
+ });
1133
+
1134
+ it("accepts an aggregateBy that has been through the wire serializer", async () => {
1135
+ /*
1136
+ * The client serializes AggregateBy before POSTing it, so operators
1137
+ * and dates arrive as `_type`-tagged objects and are revived by
1138
+ * JSONFunctions.deserialize inside the handler. Round-trip a realistic
1139
+ * payload through serialize -> JSON -> the route.
1140
+ */
1141
+ setWidgets([
1142
+ {
1143
+ metricName: CHARTED_METRIC_NAME,
1144
+ attributes: { "host.name": "web-1" },
1145
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
1146
+ },
1147
+ ]);
1148
+
1149
+ const startDate: Date = new Date("2026-01-01T00:00:00.000Z");
1150
+ const endDate: Date = new Date("2026-01-02T00:00:00.000Z");
1151
+
1152
+ const wirePayload: JSONObject = JSON.parse(
1153
+ JSON.stringify(
1154
+ JSONFunctions.serialize({
1155
+ query: {
1156
+ projectId: ObjectID.generate(),
1157
+ name: CHARTED_METRIC_NAME,
1158
+ time: new InBetween(startDate, endDate),
1159
+ attributes: { "host.name": new Includes(["web-1", "web-2"]) },
1160
+ },
1161
+ aggregationType: "Avg",
1162
+ aggregateColumnName: "value",
1163
+ aggregationTimestampColumnName: "time",
1164
+ startTimestamp: startDate,
1165
+ endTimestamp: endDate,
1166
+ limit: LIMIT_PER_PROJECT,
1167
+ skip: 0,
1168
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
1169
+ topK: { count: 10, rankBy: "max" },
1170
+ } as unknown as JSONObject),
1171
+ ),
1172
+ ) as JSONObject;
1173
+
1174
+ await callRoute({ aggregateBy: wirePayload });
1175
+
1176
+ expect(nextFunction).not.toHaveBeenCalled();
1177
+
1178
+ const args: JSONObject = getAggregateArgs();
1179
+ const query: JSONObject = args["query"] as JSONObject;
1180
+
1181
+ expect(args["startTimestamp"]).toBeInstanceOf(Date);
1182
+ expect(args["groupByAttributeKeys"]).toEqual([CHARTED_GROUP_BY_KEY]);
1183
+ expect(args["topK"]).toEqual({ count: 10, rankBy: "max" });
1184
+ expect(query["name"]).toBe(CHARTED_METRIC_NAME);
1185
+ expect(query["projectId"]).toBe(projectId);
1186
+ expect((query["attributes"] as JSONObject)["host.name"]).toBeInstanceOf(
1187
+ Includes,
1188
+ );
1189
+ expect(query["time"]).toBeInstanceOf(InBetween);
1190
+ });
1191
+
1192
+ it("refuses an aggregateBy that is not an object", async () => {
1193
+ const badPayloads: Array<unknown> = [0, "", "aggregate", [], true, 42];
1194
+
1195
+ for (const payload of badPayloads) {
1196
+ jest.clearAllMocks();
1197
+
1198
+ await callRoute({ aggregateBy: payload as JSONObject });
1199
+
1200
+ expect(nextFunction).toHaveBeenCalled();
1201
+ expectNothingAggregated();
1202
+ }
1203
+ });
1204
+ });
1205
+
1206
+ describe("normalization", () => {
1207
+ it("does not trim the requested metric name", async () => {
1208
+ await callWithAggregate({
1209
+ query: { name: ` ${CHARTED_METRIC_NAME} ` },
1210
+ });
1211
+
1212
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
1213
+ expectNothingAggregated();
1214
+ });
1215
+
1216
+ it("does not trim a requested group-by key", async () => {
1217
+ setWidgets([
1218
+ {
1219
+ metricName: CHARTED_METRIC_NAME,
1220
+ groupByAttributeKeys: [CHARTED_GROUP_BY_KEY],
1221
+ },
1222
+ ]);
1223
+
1224
+ await callWithAggregate({
1225
+ groupByAttributeKeys: [` ${CHARTED_GROUP_BY_KEY} `],
1226
+ });
1227
+
1228
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
1229
+ expectNothingAggregated();
1230
+ });
1231
+
1232
+ it("matches metric names case-sensitively", async () => {
1233
+ await callWithAggregate({
1234
+ query: { name: CHARTED_METRIC_NAME.toUpperCase() },
1235
+ });
1236
+
1237
+ expect(getThrownError()).toBeInstanceOf(BadDataException);
1238
+ expectNothingAggregated();
1239
+ });
1240
+ });
1241
+
1242
+ describe("response", () => {
1243
+ it("returns the aggregated rows unchanged", async () => {
1244
+ const aggregated: JSONObject = {
1245
+ data: [{ time: "2026-01-01T00:00:00.000Z", value: 1 }],
1246
+ totalGroups: 7,
1247
+ };
1248
+
1249
+ jest
1250
+ .spyOn(MetricService, "aggregateBy")
1251
+ .mockResolvedValue(aggregated as never);
1252
+
1253
+ await callWithAggregate();
1254
+
1255
+ expect(Response.sendJsonObjectResponse).toHaveBeenCalledTimes(1);
1256
+
1257
+ const responseCall: Array<unknown> = (
1258
+ Response.sendJsonObjectResponse as jest.Mock
1259
+ ).mock.calls[0] as Array<unknown>;
1260
+
1261
+ expect(responseCall[2]).toEqual(aggregated);
1262
+ });
1263
+
1264
+ it("sends no response at all when the request is refused", async () => {
1265
+ await callWithAggregate({ query: { name: "billing.revenue.total" } });
1266
+
1267
+ expect(Response.sendJsonObjectResponse).not.toHaveBeenCalled();
1268
+ });
1269
+ });
1270
+
1271
+ describe("project scoping", () => {
1272
+ it("pins the aggregation to the dashboard's own project", async () => {
1273
+ await callWithAggregate();
1274
+
1275
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
1276
+
1277
+ expect(query["projectId"]).toBe(projectId);
1278
+ });
1279
+
1280
+ it("ignores a client-supplied projectId pointing at another tenant", async () => {
1281
+ const otherProjectId: ObjectID = ObjectID.generate();
1282
+
1283
+ await callWithAggregate({
1284
+ query: {
1285
+ name: CHARTED_METRIC_NAME,
1286
+ projectId: otherProjectId.toString(),
1287
+ },
1288
+ });
1289
+
1290
+ const query: JSONObject = getAggregateArgs()["query"] as JSONObject;
1291
+
1292
+ expect(query["projectId"]).toBe(projectId);
1293
+ expect(query["projectId"]).not.toBe(otherProjectId.toString());
1294
+ });
1295
+ });
1296
+ });