@oneuptime/common 14.0.6 → 14.0.8

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 (556) hide show
  1. package/Models/DatabaseModels/Incident.ts +267 -1
  2. package/Models/DatabaseModels/IncidentCustomField.ts +202 -0
  3. package/Models/DatabaseModels/IncidentEpisode.ts +30 -0
  4. package/Models/DatabaseModels/IncidentTemplate.ts +126 -1
  5. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  6. package/Models/DatabaseModels/StatusPage.ts +55 -0
  7. package/Models/DatabaseModels/StatusPageSubscriber.ts +128 -0
  8. package/Server/API/IncidentAPI.ts +162 -0
  9. package/Server/API/StatusPageAPI.ts +487 -171
  10. package/Server/API/StatusPageSubscriberAPI.ts +63 -15
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.ts +49 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.ts +108 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.ts +33 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.ts +51 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.ts +33 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.ts +168 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.ts +44 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +16 -0
  20. package/Server/Infrastructure/Semaphore.ts +10 -0
  21. package/Server/Middleware/IdentityRateLimit.ts +108 -3
  22. package/Server/Middleware/SubscriberNotificationPreviewRateLimit.ts +237 -0
  23. package/Server/Middleware/SubscriberNotificationTestSendRateLimit.ts +304 -0
  24. package/Server/Services/AlertService.ts +32 -36
  25. package/Server/Services/DatabaseService.ts +93 -10
  26. package/Server/Services/IncidentAlertService.ts +609 -2
  27. package/Server/Services/IncidentCustomFieldService.ts +276 -1
  28. package/Server/Services/IncidentEpisodePublicNoteService.ts +28 -0
  29. package/Server/Services/IncidentEpisodeService.ts +15 -0
  30. package/Server/Services/IncidentEpisodeStateTimelineService.ts +27 -1
  31. package/Server/Services/IncidentPublicNoteService.ts +30 -1
  32. package/Server/Services/IncidentService.ts +1663 -103
  33. package/Server/Services/IncidentStateTimelineService.ts +13 -0
  34. package/Server/Services/IncidentTemplateService.ts +109 -0
  35. package/Server/Services/InventoryItemService.ts +38 -0
  36. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  37. package/Server/Services/ScheduledMaintenancePublicNoteService.ts +30 -1
  38. package/Server/Services/ScheduledMaintenanceService.ts +118 -64
  39. package/Server/Services/SmsService.ts +10 -0
  40. package/Server/Services/StatusPageService.ts +65 -8
  41. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +299 -37
  42. package/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.ts +129 -0
  43. package/Server/Services/StatusPageSubscriberService.ts +1161 -64
  44. package/Server/Services/UserNotificationRuleService.ts +55 -53
  45. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  46. package/Server/Types/Markdown.ts +153 -6
  47. package/Server/Types/Workflow/Components/Email.ts +61 -2
  48. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  49. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  50. package/Server/Utils/CustomField/CustomFieldRename.ts +378 -0
  51. package/Server/Utils/DataSource/EgressGuard.ts +66 -21
  52. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  53. package/Server/Utils/Mail/PinnedSmtpSocket.ts +130 -0
  54. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +10 -0
  55. package/Server/Utils/Response.ts +31 -0
  56. package/Server/Utils/SSRFProtection.ts +76 -16
  57. package/Server/Utils/StatusPage/IncidentStatusPageScope.ts +948 -0
  58. package/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.ts +656 -0
  59. package/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.ts +805 -0
  60. package/Server/Utils/StatusPage/StatusPageExclusion.ts +18 -0
  61. package/Server/Utils/StatusPage/StatusPageReadAccess.ts +125 -0
  62. package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.ts +151 -0
  63. package/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.ts +70 -0
  64. package/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.ts +421 -0
  65. package/Server/Utils/StatusPage/SubscriberNotificationClaim.ts +107 -0
  66. package/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.ts +1049 -0
  67. package/Server/Utils/StatusPage/SubscriberNotificationFanOut.ts +180 -0
  68. package/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.ts +839 -0
  69. package/Server/Utils/StatusPage/SubscriberNotificationResendAccess.ts +393 -0
  70. package/Server/Utils/StatusPage/SubscriberNotificationRunLimit.ts +246 -0
  71. package/Server/Utils/StatusPage/SubscriberNotificationTiming.ts +133 -0
  72. package/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.ts +215 -0
  73. package/Server/Utils/StatusPageResource.ts +51 -20
  74. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  75. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  76. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  77. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  78. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  79. package/Server/Utils/VM/VMAPI.ts +1 -0
  80. package/Server/Utils/VM/VMRunner.ts +10 -0
  81. package/Server/Utils/Workflow/OAuth2TokenClient.ts +10 -0
  82. package/Server/Views/Partials/SensitiveUrlToken.ejs +8 -5
  83. package/Tests/App/Accounts/RegisterVerifyEmail.test.tsx +748 -0
  84. package/Tests/App/Accounts/ResendVerificationEmail.test.tsx +1222 -0
  85. package/Tests/App/Accounts/VerificationEmailResendUtils.test.ts +797 -0
  86. package/Tests/App/Accounts/VerifyEmailPending.test.tsx +538 -0
  87. package/Tests/App/Accounts/VerifyEmailResend.test.tsx +779 -0
  88. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  89. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  90. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  91. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  92. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  93. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  94. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  95. package/Tests/App/Dashboard/CustomFieldsSettingsIncidentOptions.test.tsx +357 -0
  96. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  97. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  98. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  99. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  100. package/Tests/App/Dashboard/EventNotes.test.tsx +776 -0
  101. package/Tests/App/Dashboard/EventNotesPages.test.tsx +119 -0
  102. package/Tests/App/Dashboard/EventNotesUtil.test.ts +116 -17
  103. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +534 -6
  104. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  105. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  106. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  107. package/Tests/App/Dashboard/IncidentChangeStateNotifyDefault.test.tsx +91 -1
  108. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  109. package/Tests/App/Dashboard/IncidentCreateDetailsStep.test.tsx +643 -0
  110. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +8 -1
  111. package/Tests/App/Dashboard/IncidentCreateStatusPageScope.test.tsx +601 -0
  112. package/Tests/App/Dashboard/IncidentCustomFieldTemplateVariables.test.tsx +386 -0
  113. package/Tests/App/Dashboard/IncidentNoteTemplatePlaceholders.test.tsx +124 -0
  114. package/Tests/App/Dashboard/IncidentNoteTemplateVariablesLoader.test.ts +305 -0
  115. package/Tests/App/Dashboard/IncidentOverviewNotifyDefault.test.tsx +112 -0
  116. package/Tests/App/Dashboard/IncidentSettingsRenotifyOnPublish.test.tsx +674 -0
  117. package/Tests/App/Dashboard/IncidentSettingsStatusPageScope.test.tsx +1051 -0
  118. package/Tests/App/Dashboard/IncidentTemplateCustomFields.test.tsx +384 -0
  119. package/Tests/App/Dashboard/IncidentTemplateStatusPageScopeCard.test.tsx +215 -0
  120. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  121. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  122. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  123. package/Tests/App/Dashboard/PublicNotePagesNotifyDefault.test.tsx +113 -1
  124. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  125. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  126. package/Tests/App/Dashboard/SubscriberAudienceSummary.test.tsx +512 -0
  127. package/Tests/App/Dashboard/SubscriberNotificationPreviewModal.test.tsx +837 -0
  128. package/Tests/App/Dashboard/SubscriberNotificationStatusLabel.test.tsx +88 -0
  129. package/Tests/App/Dashboard/SubscriberNotificationStatusResend.test.tsx +473 -0
  130. package/Tests/App/Dashboard/SubscriberTemplateLivePreview.test.tsx +196 -0
  131. package/Tests/App/Dashboard/TeamAddedSubscribersUnsubscribedNotice.test.tsx +248 -0
  132. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  133. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  134. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  135. package/Tests/App/SensitiveUrlTokenBootstrap.test.ts +64 -3
  136. package/Tests/App/StatusPage/StatusPageUnsubscribePage.test.tsx +412 -0
  137. package/Tests/Models/IncidentCustomFieldApiSchemaContract.test.ts +217 -0
  138. package/Tests/Models/IncidentCustomFieldCreateSettingsColumns.test.ts +252 -0
  139. package/Tests/Models/StatusPageSubscriberUnsubscribeColumns.test.ts +218 -0
  140. package/Tests/Server/API/ExpiredSessionRouteGuards.test.ts +7 -0
  141. package/Tests/Server/API/IncidentSubscriberAudienceAPI.test.ts +1408 -0
  142. package/Tests/Server/API/StatusPageIncidentScope.test.ts +1868 -0
  143. package/Tests/Server/API/StatusPageIncidentScopePostgres.test.ts +790 -0
  144. package/Tests/Server/API/StatusPageManageSubscriptionRecipient.test.ts +10 -1
  145. package/Tests/Server/API/StatusPageManageSubscriptionTemplateVariables.test.ts +206 -18
  146. package/Tests/Server/API/StatusPagePostmortemVisibility.test.ts +14 -2
  147. package/Tests/Server/API/StatusPageSubscriberCreateResponse.test.ts +217 -0
  148. package/Tests/Server/API/StatusPageSubscriberOwnership.test.ts +103 -2
  149. package/Tests/Server/API/StatusPageUnsubscribeAPI.test.ts +396 -0
  150. package/Tests/Server/Infrastructure/Postgres/AddIncidentStatusPageScopeMigration.test.ts +395 -0
  151. package/Tests/Server/Infrastructure/Postgres/AddIncidentTemplateStatusPageScopeFlagMigration.test.ts +150 -0
  152. package/Tests/Server/Infrastructure/Postgres/AddSubscriberNotificationClaimedAtMigration.test.ts +156 -0
  153. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  154. package/Tests/Server/Infrastructure/Postgres/SkipHiddenIncidentCreatedNotificationsMigration.test.ts +410 -0
  155. package/Tests/Server/Middleware/IdentityRateLimit.test.ts +551 -1
  156. package/Tests/Server/Middleware/SubscriberNotificationPreviewRateLimit.test.ts +238 -0
  157. package/Tests/Server/Middleware/SubscriberNotificationTestSendRateLimit.test.ts +245 -0
  158. package/Tests/Server/Services/AddStatusPageSubscriberUnsubscribeTokenMigration.test.ts +152 -0
  159. package/Tests/Server/Services/CrossProjectRelationWriteGuard.test.ts +221 -36
  160. package/Tests/Server/Services/DatabaseServiceCompareAndSetColumns.test.ts +191 -0
  161. package/Tests/Server/Services/DomainService.test.ts +525 -0
  162. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  163. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  164. package/Tests/Server/Services/IncidentCreatedRenotifyOnPublish.test.ts +681 -0
  165. package/Tests/Server/Services/IncidentCreatedResend.test.ts +775 -0
  166. package/Tests/Server/Services/IncidentCustomFieldRenamePostgres.test.ts +713 -0
  167. package/Tests/Server/Services/IncidentCustomFieldService.test.ts +751 -0
  168. package/Tests/Server/Services/IncidentCustomFieldValueValidation.test.ts +556 -0
  169. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  170. package/Tests/Server/Services/IncidentStatusPageScope.test.ts +2971 -0
  171. package/Tests/Server/Services/IncidentStatusPageScopeFeed.test.ts +131 -0
  172. package/Tests/Server/Services/IncidentStatusPageScopePostgres.test.ts +625 -0
  173. package/Tests/Server/Services/IncidentTemplateCustomFieldsCopy.test.ts +311 -0
  174. package/Tests/Server/Services/IncidentTemplateStatusPageScope.test.ts +363 -0
  175. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  176. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  177. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  178. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  179. package/Tests/Server/Services/PublicNoteEditFeedEventType.test.ts +213 -0
  180. package/Tests/Server/Services/PublicNoteResendAccess.test.ts +994 -0
  181. package/Tests/Server/Services/ScheduledMaintenanceSubscriberTemplateFormats.test.ts +289 -9
  182. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  183. package/Tests/Server/Services/SmsServiceFailIfNotSent.test.ts +67 -0
  184. package/Tests/Server/Services/StatusPageReportIncidentCounts.test.ts +610 -0
  185. package/Tests/Server/Services/StatusPageSubscriberAudienceCounts.test.ts +201 -0
  186. package/Tests/Server/Services/StatusPageSubscriberAudienceCountsPostgres.test.ts +271 -0
  187. package/Tests/Server/Services/StatusPageSubscriberConfirmationTemplateEscaping.test.ts +200 -0
  188. package/Tests/Server/Services/StatusPageSubscriberNotificationLogRedaction.test.ts +54 -0
  189. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateCompile.test.ts +37 -0
  190. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateIncidentRecordAccess.test.ts +772 -0
  191. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookup.test.ts +506 -0
  192. package/Tests/Server/Services/StatusPageSubscriberNotificationTemplateLookupPostgres.test.ts +261 -0
  193. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +21 -5
  194. package/Tests/Server/Services/StatusPageSubscriberUnsubscribe.test.ts +1618 -0
  195. package/Tests/Server/Services/StatusPageSubscriberUnsubscribeLinkSenders.test.ts +373 -0
  196. package/Tests/Server/Services/StatusPageSubscriberUnsubscribePostgres.test.ts +388 -0
  197. package/Tests/Server/Services/SubscriberNotificationInFlightRequeue.test.ts +500 -0
  198. package/Tests/Server/Types/MarkdownEmailLinks.test.ts +320 -0
  199. package/Tests/Server/Types/Workflow/Components/Email.test.ts +491 -1
  200. package/Tests/Server/Types/Workflow/Components/EmailSmtpPinning.test.ts +398 -0
  201. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  202. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  203. package/Tests/Server/Utils/CustomField/CustomFieldRename.test.ts +586 -0
  204. package/Tests/Server/Utils/DataSource/EgressGuard.test.ts +246 -10
  205. package/Tests/Server/Utils/DataSource/EgressGuardDeploymentConfig.test.ts +226 -0
  206. package/Tests/Server/Utils/DataSource/EgressGuardPinning.test.ts +131 -0
  207. package/Tests/Server/Utils/DataSource/HttpFetch.test.ts +4 -0
  208. package/Tests/Server/Utils/EgressPolicyEnvironment.ts +45 -0
  209. package/Tests/Server/Utils/Mail/PinnedSmtpSocket.test.ts +409 -0
  210. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +23 -0
  211. package/Tests/Server/Utils/SSRFProtection.test.ts +4 -0
  212. package/Tests/Server/Utils/SSRFProtectionCloudServiceAddresses.test.ts +4 -0
  213. package/Tests/Server/Utils/SSRFProtectionPrivateNetwork.test.ts +4 -0
  214. package/Tests/Server/Utils/SSRFProtectionResolutionDetail.test.ts +314 -0
  215. package/Tests/Server/Utils/StatusPage/IncidentStatusPageScope.test.ts +1699 -0
  216. package/Tests/Server/Utils/StatusPage/IncidentStatusPageScopeQueriesPostgres.test.ts +508 -0
  217. package/Tests/Server/Utils/StatusPage/IncidentSubscriberAudienceCallerPermission.test.ts +246 -0
  218. package/Tests/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.test.ts +1348 -0
  219. package/Tests/Server/Utils/StatusPage/StatusPageReadAccess.test.ts +221 -0
  220. package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.test.ts +132 -0
  221. package/Tests/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.test.ts +155 -0
  222. package/Tests/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.test.ts +708 -0
  223. package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaim.test.ts +255 -0
  224. package/Tests/Server/Utils/StatusPage/SubscriberNotificationClaimPostgres.test.ts +528 -0
  225. package/Tests/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.test.ts +1238 -0
  226. package/Tests/Server/Utils/StatusPage/SubscriberNotificationFanOut.test.ts +472 -0
  227. package/Tests/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.test.ts +1168 -0
  228. package/Tests/Server/Utils/StatusPage/SubscriberNotificationRunLimit.test.ts +275 -0
  229. package/Tests/Server/Utils/StatusPage/SubscriberNotificationTiming.test.ts +131 -0
  230. package/Tests/Server/Utils/StatusPageResource.test.ts +98 -0
  231. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  232. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  233. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  234. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  235. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  236. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  237. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  238. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  239. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  240. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  241. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  242. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  243. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  244. package/Tests/Server/Utils/VM/VMRunnerPrivateNetworkWiring.test.ts +17 -0
  245. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +75 -0
  246. package/Tests/Types/CustomField/CustomFieldSavedViews.test.ts +247 -0
  247. package/Tests/Types/CustomField/CustomFieldTemplateValues.test.ts +186 -0
  248. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +48 -0
  249. package/Tests/Types/CustomField/CustomFieldValueFormat.test.ts +215 -0
  250. package/Tests/Types/CustomField/CustomFieldValueValidator.test.ts +343 -0
  251. package/Tests/Types/CustomField/CustomFieldVariableKey.test.ts +364 -0
  252. package/Tests/Types/SafeHtml.test.ts +102 -0
  253. package/Tests/Types/StatusPage/IncidentCreatedRenotify.test.ts +419 -0
  254. package/Tests/Types/StatusPage/IncidentCreatedResend.test.ts +118 -0
  255. package/Tests/Types/StatusPage/IncidentScopeAddedPagesNotification.test.ts +558 -0
  256. package/Tests/Types/StatusPage/IncidentSubscriberAudience.test.ts +284 -0
  257. package/Tests/Types/StatusPage/StatusPageSubscriberUnsubscribe.test.ts +341 -0
  258. package/Tests/Types/StatusPage/SubscriberNotificationPreview.test.ts +196 -0
  259. package/Tests/Types/StatusPage/SubscriberNotificationResend.test.ts +201 -0
  260. package/Tests/Types/StatusPage/SubscriberNotificationTemplateCompiler.test.ts +277 -0
  261. package/Tests/Types/StatusPage/SubscriberNotificationTemplatePreview.test.ts +179 -0
  262. package/Tests/Types/StatusPage/SubscriberNotificationTemplateVariables.test.ts +324 -0
  263. package/Tests/Types/StatusPage/SubscriberUpdateNotification.test.ts +60 -13
  264. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  265. package/Tests/UI/Components/CustomFields/CustomFieldFormFields.test.ts +432 -0
  266. package/Tests/UI/Components/CustomFields/CustomFieldModelFormFields.test.tsx +685 -0
  267. package/Tests/UI/Components/CustomFields/CustomFieldsDetailOrder.test.tsx +469 -0
  268. package/Tests/UI/Components/Forms/ModelFormCustomFieldInputs.test.tsx +325 -0
  269. package/Tests/UI/Components/Forms/ModelFormDropdownOptionsRefused.test.tsx +358 -0
  270. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +22 -0
  271. package/Tests/UI/Monitor/NetworkPathView.test.tsx +289 -0
  272. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  273. package/Tests/Utils/Incident/IncidentNoteTemplateVariables.test.ts +623 -0
  274. package/Tests/Utils/Markdown/MarkdownEscape.test.ts +89 -0
  275. package/Tests/Utils/ProbeNetworkFailureUtil.test.ts +992 -0
  276. package/Tests/Utils/RequestFailedDetails.test.ts +575 -0
  277. package/Types/CustomField/CustomFieldOrder.ts +55 -0
  278. package/Types/CustomField/CustomFieldSavedViews.ts +225 -0
  279. package/Types/CustomField/CustomFieldTemplateMerge.ts +71 -0
  280. package/Types/CustomField/CustomFieldType.ts +14 -0
  281. package/Types/CustomField/CustomFieldValueFormat.ts +161 -0
  282. package/Types/CustomField/CustomFieldValueValidator.ts +488 -0
  283. package/Types/CustomField/CustomFieldVariableKey.ts +211 -0
  284. package/Types/Incident/IncidentAlertLink.ts +19 -0
  285. package/Types/SafeHtml.ts +93 -0
  286. package/Types/StatusPage/IncidentCreatedRenotify.ts +224 -0
  287. package/Types/StatusPage/IncidentCreatedResend.ts +108 -0
  288. package/Types/StatusPage/IncidentScopeAddedPagesNotification.ts +343 -0
  289. package/Types/StatusPage/IncidentSubscriberAudience.ts +332 -0
  290. package/Types/StatusPage/StatusPageSubscriberUnsubscribe.ts +405 -0
  291. package/Types/StatusPage/SubscriberNotificationInterruption.ts +32 -0
  292. package/Types/StatusPage/SubscriberNotificationPreview.ts +322 -0
  293. package/Types/StatusPage/SubscriberNotificationResend.ts +132 -0
  294. package/Types/StatusPage/SubscriberNotificationTemplateCompiler.ts +180 -0
  295. package/Types/StatusPage/SubscriberNotificationTemplatePreview.ts +301 -0
  296. package/Types/StatusPage/SubscriberNotificationTemplateVariables.ts +217 -4
  297. package/Types/StatusPage/SubscriberUpdateNotification.ts +32 -11
  298. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  299. package/Types/Topology/TopologyApi.ts +428 -0
  300. package/Types/Topology/TopologyTypeRules.ts +191 -0
  301. package/UI/Components/CustomFields/CustomFieldFormFields.ts +317 -0
  302. package/UI/Components/CustomFields/CustomFieldModelFormFields.tsx +395 -0
  303. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +95 -55
  304. package/UI/Components/Forms/ModelForm.tsx +70 -8
  305. package/UI/Components/Forms/Validation.ts +1 -1
  306. package/UI/Components/ModelFormModal/ModelFormModal.tsx +2 -4
  307. package/UI/Components/ModelTable/AnalyticsModelTable.tsx +2 -6
  308. package/UI/Components/ModelTable/BaseModelTable.tsx +9 -8
  309. package/UI/Components/ModelTable/CustomFieldColumns.tsx +14 -1
  310. package/UI/Components/ModelTable/ModelTable.tsx +6 -7
  311. package/UI/Utils/SensitiveUrlToken.ts +10 -5
  312. package/Utils/API.ts +247 -2
  313. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  314. package/Utils/Incident/IncidentNoteTemplateVariables.ts +304 -0
  315. package/Utils/Markdown/MarkdownEscape.ts +65 -0
  316. package/Utils/ProbeNetworkFailureUtil.ts +673 -0
  317. package/build/dist/Models/DatabaseModels/Incident.js +267 -1
  318. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  319. package/build/dist/Models/DatabaseModels/IncidentCustomField.js +207 -1
  320. package/build/dist/Models/DatabaseModels/IncidentCustomField.js.map +1 -1
  321. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +31 -0
  322. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  323. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +124 -1
  324. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  325. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  326. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  327. package/build/dist/Models/DatabaseModels/StatusPage.js +56 -0
  328. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  329. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +131 -0
  330. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
  331. package/build/dist/Server/API/IncidentAPI.js +100 -1
  332. package/build/dist/Server/API/IncidentAPI.js.map +1 -1
  333. package/build/dist/Server/API/StatusPageAPI.js +417 -176
  334. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  335. package/build/dist/Server/API/StatusPageSubscriberAPI.js +52 -13
  336. package/build/dist/Server/API/StatusPageSubscriberAPI.js.map +1 -1
  337. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  338. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  339. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js +41 -0
  340. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795300000000-SkipHiddenIncidentCreatedNotifications.js.map +1 -0
  341. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js +57 -0
  342. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795400000000-AddIncidentStatusPageScope.js.map +1 -0
  343. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js +23 -0
  344. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795500000000-AddIncidentTemplateStatusPageScopeFlag.js.map +1 -0
  345. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js +40 -0
  346. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795600000000-AddStatusPageSubscriberUnsubscribeToken.js.map +1 -0
  347. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js +26 -0
  348. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795700000000-AddStatusPageSubscriberIsAddedByTeam.js.map +1 -0
  349. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js +89 -0
  350. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795800000000-AddIncidentCustomFieldCreateAndNotificationSettings.js.map +1 -0
  351. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js +29 -0
  352. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795900000000-AddSubscriberNotificationClaimedAt.js.map +1 -0
  353. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +16 -0
  354. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  355. package/build/dist/Server/Infrastructure/Semaphore.js +3 -0
  356. package/build/dist/Server/Infrastructure/Semaphore.js.map +1 -1
  357. package/build/dist/Server/Middleware/IdentityRateLimit.js +89 -3
  358. package/build/dist/Server/Middleware/IdentityRateLimit.js.map +1 -1
  359. package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js +149 -0
  360. package/build/dist/Server/Middleware/SubscriberNotificationPreviewRateLimit.js.map +1 -0
  361. package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js +190 -0
  362. package/build/dist/Server/Middleware/SubscriberNotificationTestSendRateLimit.js.map +1 -0
  363. package/build/dist/Server/Services/AlertService.js +21 -33
  364. package/build/dist/Server/Services/AlertService.js.map +1 -1
  365. package/build/dist/Server/Services/DatabaseService.js +56 -7
  366. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  367. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  368. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  369. package/build/dist/Server/Services/IncidentCustomFieldService.js +204 -8
  370. package/build/dist/Server/Services/IncidentCustomFieldService.js.map +1 -1
  371. package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js +24 -0
  372. package/build/dist/Server/Services/IncidentEpisodePublicNoteService.js.map +1 -1
  373. package/build/dist/Server/Services/IncidentEpisodeService.js +14 -0
  374. package/build/dist/Server/Services/IncidentEpisodeService.js.map +1 -1
  375. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +26 -0
  376. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
  377. package/build/dist/Server/Services/IncidentPublicNoteService.js +26 -1
  378. package/build/dist/Server/Services/IncidentPublicNoteService.js.map +1 -1
  379. package/build/dist/Server/Services/IncidentService.js +1148 -42
  380. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  381. package/build/dist/Server/Services/IncidentStateTimelineService.js +12 -0
  382. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  383. package/build/dist/Server/Services/IncidentTemplateService.js +83 -0
  384. package/build/dist/Server/Services/IncidentTemplateService.js.map +1 -1
  385. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  386. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  387. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  388. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  389. package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js +26 -1
  390. package/build/dist/Server/Services/ScheduledMaintenancePublicNoteService.js.map +1 -1
  391. package/build/dist/Server/Services/ScheduledMaintenanceService.js +84 -57
  392. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  393. package/build/dist/Server/Services/SmsService.js +2 -22
  394. package/build/dist/Server/Services/SmsService.js.map +1 -1
  395. package/build/dist/Server/Services/StatusPageService.js +47 -9
  396. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  397. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +237 -32
  398. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  399. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js +109 -0
  400. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateStatusPageService.js.map +1 -1
  401. package/build/dist/Server/Services/StatusPageSubscriberService.js +925 -99
  402. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  403. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  404. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  405. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  406. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  407. package/build/dist/Server/Types/Markdown.js +110 -6
  408. package/build/dist/Server/Types/Markdown.js.map +1 -1
  409. package/build/dist/Server/Types/Workflow/Components/Email.js +55 -6
  410. package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
  411. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  412. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  413. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  414. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  415. package/build/dist/Server/Utils/CustomField/CustomFieldRename.js +211 -0
  416. package/build/dist/Server/Utils/CustomField/CustomFieldRename.js.map +1 -0
  417. package/build/dist/Server/Utils/DataSource/EgressGuard.js +41 -12
  418. package/build/dist/Server/Utils/DataSource/EgressGuard.js.map +1 -1
  419. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  420. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  421. package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js +101 -0
  422. package/build/dist/Server/Utils/Mail/PinnedSmtpSocket.js.map +1 -0
  423. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +9 -0
  424. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  425. package/build/dist/Server/Utils/Response.js +30 -0
  426. package/build/dist/Server/Utils/Response.js.map +1 -1
  427. package/build/dist/Server/Utils/SSRFProtection.js +48 -6
  428. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  429. package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js +631 -0
  430. package/build/dist/Server/Utils/StatusPage/IncidentStatusPageScope.js.map +1 -0
  431. package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js +403 -0
  432. package/build/dist/Server/Utils/StatusPage/IncidentSubscriberAudienceBuilder.js.map +1 -0
  433. package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js +451 -0
  434. package/build/dist/Server/Utils/StatusPage/IncidentTemplateVariableBuilder.js.map +1 -0
  435. package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js +13 -0
  436. package/build/dist/Server/Utils/StatusPage/StatusPageExclusion.js.map +1 -0
  437. package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js +91 -0
  438. package/build/dist/Server/Utils/StatusPage/StatusPageReadAccess.js.map +1 -0
  439. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js +101 -0
  440. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeNotice.js.map +1 -0
  441. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js +47 -0
  442. package/build/dist/Server/Utils/StatusPage/StatusPageSubscriberUnsubscribeToken.js.map +1 -0
  443. package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js +273 -0
  444. package/build/dist/Server/Utils/StatusPage/SubscriberIncidentEmailBuilder.js.map +1 -0
  445. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js +73 -0
  446. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationClaim.js.map +1 -0
  447. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js +722 -0
  448. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationDeliveryRecord.js.map +1 -0
  449. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js +102 -0
  450. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationFanOut.js.map +1 -0
  451. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js +527 -0
  452. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationPreviewBuilder.js.map +1 -0
  453. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js +278 -0
  454. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationResendAccess.js.map +1 -0
  455. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js +137 -0
  456. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationRunLimit.js.map +1 -0
  457. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js +103 -0
  458. package/build/dist/Server/Utils/StatusPage/SubscriberNotificationTiming.js.map +1 -0
  459. package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js +114 -0
  460. package/build/dist/Server/Utils/StatusPage/SubscriberTemplateIncidentRecordAccess.js.map +1 -0
  461. package/build/dist/Server/Utils/StatusPageResource.js +39 -7
  462. package/build/dist/Server/Utils/StatusPageResource.js.map +1 -1
  463. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  464. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  465. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  466. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  467. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  468. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  469. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  470. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  471. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  472. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  473. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  474. package/build/dist/Server/Utils/VM/VMRunner.js +1 -0
  475. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  476. package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js +3 -0
  477. package/build/dist/Server/Utils/Workflow/OAuth2TokenClient.js.map +1 -1
  478. package/build/dist/Types/CustomField/CustomFieldOrder.js +36 -0
  479. package/build/dist/Types/CustomField/CustomFieldOrder.js.map +1 -0
  480. package/build/dist/Types/CustomField/CustomFieldSavedViews.js +135 -0
  481. package/build/dist/Types/CustomField/CustomFieldSavedViews.js.map +1 -0
  482. package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js +27 -0
  483. package/build/dist/Types/CustomField/CustomFieldTemplateMerge.js.map +1 -0
  484. package/build/dist/Types/CustomField/CustomFieldType.js +12 -0
  485. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  486. package/build/dist/Types/CustomField/CustomFieldValueFormat.js +126 -0
  487. package/build/dist/Types/CustomField/CustomFieldValueFormat.js.map +1 -0
  488. package/build/dist/Types/CustomField/CustomFieldValueValidator.js +280 -0
  489. package/build/dist/Types/CustomField/CustomFieldValueValidator.js.map +1 -0
  490. package/build/dist/Types/CustomField/CustomFieldVariableKey.js +150 -0
  491. package/build/dist/Types/CustomField/CustomFieldVariableKey.js.map +1 -0
  492. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  493. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  494. package/build/dist/Types/SafeHtml.js +84 -0
  495. package/build/dist/Types/SafeHtml.js.map +1 -0
  496. package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js +117 -0
  497. package/build/dist/Types/StatusPage/IncidentCreatedRenotify.js.map +1 -0
  498. package/build/dist/Types/StatusPage/IncidentCreatedResend.js +86 -0
  499. package/build/dist/Types/StatusPage/IncidentCreatedResend.js.map +1 -0
  500. package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js +246 -0
  501. package/build/dist/Types/StatusPage/IncidentScopeAddedPagesNotification.js.map +1 -0
  502. package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js +183 -0
  503. package/build/dist/Types/StatusPage/IncidentSubscriberAudience.js.map +1 -0
  504. package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js +277 -0
  505. package/build/dist/Types/StatusPage/StatusPageSubscriberUnsubscribe.js.map +1 -0
  506. package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js +27 -0
  507. package/build/dist/Types/StatusPage/SubscriberNotificationInterruption.js.map +1 -0
  508. package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js +190 -0
  509. package/build/dist/Types/StatusPage/SubscriberNotificationPreview.js.map +1 -0
  510. package/build/dist/Types/StatusPage/SubscriberNotificationResend.js +102 -0
  511. package/build/dist/Types/StatusPage/SubscriberNotificationResend.js.map +1 -0
  512. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js +97 -0
  513. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateCompiler.js.map +1 -0
  514. package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js +175 -0
  515. package/build/dist/Types/StatusPage/SubscriberNotificationTemplatePreview.js.map +1 -0
  516. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js +144 -4
  517. package/build/dist/Types/StatusPage/SubscriberNotificationTemplateVariables.js.map +1 -1
  518. package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js +29 -9
  519. package/build/dist/Types/StatusPage/SubscriberUpdateNotification.js.map +1 -1
  520. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  521. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  522. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  523. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  524. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  525. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  526. package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js +169 -0
  527. package/build/dist/UI/Components/CustomFields/CustomFieldFormFields.js.map +1 -0
  528. package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js +212 -0
  529. package/build/dist/UI/Components/CustomFields/CustomFieldModelFormFields.js.map +1 -0
  530. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +64 -49
  531. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  532. package/build/dist/UI/Components/Forms/ModelForm.js +39 -5
  533. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  534. package/build/dist/UI/Components/Forms/Validation.js +1 -1
  535. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  536. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  537. package/build/dist/UI/Components/ModelTable/AnalyticsModelTable.js.map +1 -1
  538. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +2 -2
  539. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  540. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +13 -1
  541. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  542. package/build/dist/UI/Components/ModelTable/ModelTable.js +1 -1
  543. package/build/dist/UI/Components/ModelTable/ModelTable.js.map +1 -1
  544. package/build/dist/UI/Utils/SensitiveUrlToken.js +10 -5
  545. package/build/dist/UI/Utils/SensitiveUrlToken.js.map +1 -1
  546. package/build/dist/Utils/API.js +177 -1
  547. package/build/dist/Utils/API.js.map +1 -1
  548. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  549. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  550. package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js +155 -0
  551. package/build/dist/Utils/Incident/IncidentNoteTemplateVariables.js.map +1 -0
  552. package/build/dist/Utils/Markdown/MarkdownEscape.js +48 -0
  553. package/build/dist/Utils/Markdown/MarkdownEscape.js.map +1 -1
  554. package/build/dist/Utils/ProbeNetworkFailureUtil.js +460 -0
  555. package/build/dist/Utils/ProbeNetworkFailureUtil.js.map +1 -0
  556. package/package.json +1 -1
@@ -17,49 +17,72 @@ import {
17
17
  } from "@testing-library/react";
18
18
  import React from "react";
19
19
  import getJestMockFunction, { MockFunction } from "../../MockType";
20
- import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
21
- import InventoryItemRelationship from "../../../Models/DatabaseModels/InventoryItemRelationship";
22
- import GreaterThanOrEqual from "../../../Types/BaseDatabase/GreaterThanOrEqual";
20
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
21
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
22
+ import URL from "../../../Types/API/URL";
23
23
  import InBetween from "../../../Types/BaseDatabase/InBetween";
24
- import ListResult from "../../../Types/BaseDatabase/ListResult";
24
+ import Dictionary from "../../../Types/Dictionary";
25
+ import { JSONObject } from "../../../Types/JSON";
25
26
  import ObjectID from "../../../Types/ObjectID";
27
+ import EntityType from "../../../Types/Telemetry/EntityType";
26
28
  import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
27
29
  import TimeRange from "../../../Types/Time/TimeRange";
28
- import useTopologyData from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/UseTopologyData";
30
+ import {
31
+ TOPOLOGY_API_FORMAT_VERSION,
32
+ TopologyApiPath,
33
+ } from "../../../Types/Topology/TopologyApi";
34
+ import useTopologyData, {
35
+ TOPOLOGY_RANGE_REPIN_AFTER_MS,
36
+ TopologyData,
37
+ TopologyView,
38
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/UseTopologyData";
39
+ import { TopologyEntity } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyData";
40
+
41
+ /*
42
+ * useTopologyData decides WHEN the Topology page talks to the server: one
43
+ * POST per telemetry tab, the first time that tab is opened, pinned to one
44
+ * range start per generation. These tests drive it through the one seam the
45
+ * browser really has — API.post — with a fake server that records every
46
+ * request and answers when the test says so.
47
+ */
29
48
 
30
- const getListMock: MockFunction = getJestMockFunction();
49
+ const postMock: MockFunction = getJestMockFunction();
31
50
  const getProjectIdMock: MockFunction = getJestMockFunction();
32
51
 
33
- jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
52
+ jest.mock("../../../UI/Utils/API/API", () => {
34
53
  return {
35
54
  __esModule: true,
36
55
  default: {
37
- getList: (...args: Array<unknown>) => {
38
- return getListMock(...args);
56
+ post: (...args: Array<unknown>) => {
57
+ return postMock(...args);
58
+ },
59
+ getFriendlyMessage: (error: unknown): string => {
60
+ if (error && typeof error === "object" && "message" in error) {
61
+ return String((error as { message: unknown }).message);
62
+ }
63
+ return "Unable to load topology";
39
64
  },
40
65
  },
41
66
  };
42
67
  });
43
68
 
44
- jest.mock("../../../UI/Utils/Project", () => {
69
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
45
70
  return {
46
71
  __esModule: true,
47
72
  default: {
48
- getCurrentProjectId: () => {
49
- return getProjectIdMock();
73
+ getCommonHeaders: (): Dictionary<string> => {
74
+ return { tenantid: String(getProjectIdMock()) };
50
75
  },
51
76
  },
52
77
  };
53
78
  });
54
79
 
55
- jest.mock("../../../UI/Utils/API/API", () => {
80
+ jest.mock("../../../UI/Utils/Project", () => {
56
81
  return {
57
82
  __esModule: true,
58
83
  default: {
59
- getFriendlyMessage: (error: unknown): string => {
60
- return error instanceof Error
61
- ? error.message
62
- : "Unable to load topology";
84
+ getCurrentProjectId: () => {
85
+ return getProjectIdMock();
63
86
  },
64
87
  },
65
88
  };
@@ -71,7 +94,7 @@ const PROJECT_ID: ObjectID = new ObjectID(
71
94
  const FIRST_RANGE: RangeStartAndEndDateTime = {
72
95
  range: TimeRange.CUSTOM,
73
96
  startAndEndDate: new InBetween<Date>(
74
- new Date("2026-09-01T09:15:00.000Z"),
97
+ new Date("2026-09-01T09:15:27.000Z"),
75
98
  new Date("2026-09-01T10:15:00.000Z"),
76
99
  ),
77
100
  };
@@ -82,297 +105,1038 @@ const SECOND_RANGE: RangeStartAndEndDateTime = {
82
105
  new Date("2026-09-02T10:15:00.000Z"),
83
106
  ),
84
107
  };
108
+ const LAST_HOUR: RangeStartAndEndDateTime = { range: TimeRange.PAST_ONE_HOUR };
85
109
 
86
- type TopologyListResult = ListResult<InventoryItem | InventoryItemRelationship>;
110
+ interface PostOptions {
111
+ url: URL;
112
+ data: JSONObject;
113
+ headers: Dictionary<string>;
114
+ options?: { signal?: AbortSignal };
115
+ }
87
116
 
88
- interface DeferredResult {
89
- promise: Promise<TopologyListResult>;
90
- resolve: (result: TopologyListResult) => void;
117
+ /* One request the fake server received, answerable by the test. */
118
+ interface RecordedRequest {
119
+ path: string;
120
+ rangeStart: string;
121
+ /* The whole body, to tell a `fresh` request from an ordinary one. */
122
+ body: JSONObject;
123
+ headers: Dictionary<string>;
124
+ signal: AbortSignal | undefined;
125
+ respond: (payload: JSONObject) => void;
126
+ fail: (statusCode: number, message: string) => void;
91
127
  reject: (error: Error) => void;
92
128
  }
93
129
 
94
- function deferred(): DeferredResult {
95
- let resolve!: (result: TopologyListResult) => void;
96
- let reject!: (error: Error) => void;
97
- const promise: Promise<TopologyListResult> = new Promise(
98
- (
99
- resolvePromise: (result: TopologyListResult) => void,
100
- rejectPromise: (error: Error) => void,
101
- ): void => {
102
- resolve = resolvePromise;
103
- reject = rejectPromise;
104
- },
105
- );
106
- return { promise, resolve, reject };
130
+ let requests: Array<RecordedRequest> = [];
131
+ /* When true, every request is answered at once with defaultPayload. */
132
+ let autoRespond: boolean = true;
133
+
134
+ function pathOf(url: URL): string {
135
+ return url.toString().replace(/^.*\/api(?=\/)/, "");
136
+ }
137
+
138
+ function requestsTo(path: TopologyApiPath): Array<RecordedRequest> {
139
+ return requests.filter((request: RecordedRequest): boolean => {
140
+ return request.path === path;
141
+ });
142
+ }
143
+
144
+ function lastRequestTo(path: TopologyApiPath): RecordedRequest {
145
+ const matching: Array<RecordedRequest> = requestsTo(path);
146
+ return matching[matching.length - 1]!;
147
+ }
148
+
149
+ /*
150
+ * The server floors the range start to the minute and echoes it; each answer
151
+ * names its own ordinal so a test can tell which response is on screen.
152
+ */
153
+ function floorToMinute(iso: string): string {
154
+ const time: number = new Date(iso).getTime();
155
+ return new Date(time - (time % 60000)).toISOString();
156
+ }
157
+
158
+ function serviceMapPayload(
159
+ rangeStart: string,
160
+ serviceName: string,
161
+ extra: JSONObject = {},
162
+ ): JSONObject {
163
+ return {
164
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION,
165
+ rangeStart: floorToMinute(rangeStart),
166
+ generatedAt: "2026-09-02T10:15:30.000Z",
167
+ entities: [
168
+ {
169
+ key: `service:${serviceName}`,
170
+ type: EntityType.Service,
171
+ name: serviceName,
172
+ source: "discovered",
173
+ lastSeenAt: null,
174
+ },
175
+ ],
176
+ dependencies: [],
177
+ runsOn: [],
178
+ entityTruncation: null,
179
+ dependencyTruncation: null,
180
+ ...extra,
181
+ };
182
+ }
183
+
184
+ function infrastructurePayload(
185
+ rangeStart: string,
186
+ hostName: string,
187
+ ): JSONObject {
188
+ return {
189
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION,
190
+ rangeStart: floorToMinute(rangeStart),
191
+ generatedAt: "2026-09-02T10:15:30.000Z",
192
+ nodes: [
193
+ {
194
+ key: `host:${hostName}`,
195
+ type: EntityType.Host,
196
+ name: hostName,
197
+ source: "discovered",
198
+ lastSeenAt: null,
199
+ },
200
+ ],
201
+ services: [],
202
+ placements: [],
203
+ collections: [],
204
+ totals: { resources: 1, activeResources: 1 },
205
+ truncation: null,
206
+ };
107
207
  }
108
208
 
109
- function result(name?: string, count?: number): TopologyListResult {
110
- const data: Array<InventoryItem> = [];
111
- if (name) {
112
- const entity: InventoryItem = new InventoryItem();
113
- entity.entityKey = name;
114
- entity.displayName = name;
115
- data.push(entity);
209
+ function defaultPayload(request: RecordedRequest): JSONObject {
210
+ const ordinal: number = requestsTo(request.path as TopologyApiPath).length;
211
+ if (request.path === TopologyApiPath.ServiceMap) {
212
+ return serviceMapPayload(request.rangeStart, `service map #${ordinal}`);
116
213
  }
117
- return { data, count: count ?? data.length, skip: 0, limit: 1000 };
214
+ return infrastructurePayload(
215
+ request.rangeStart,
216
+ `infrastructure #${ordinal}`,
217
+ );
218
+ }
219
+
220
+ function installFakeServer(): void {
221
+ postMock.mockImplementation((...args: Array<unknown>) => {
222
+ const options: PostOptions = args[0] as PostOptions;
223
+ return new Promise(
224
+ (
225
+ resolve: (value: HTTPResponse<JSONObject> | HTTPErrorResponse) => void,
226
+ reject: (error: Error) => void,
227
+ ): void => {
228
+ const request: RecordedRequest = {
229
+ path: pathOf(options.url),
230
+ rangeStart: String(options.data["rangeStart"]),
231
+ body: options.data,
232
+ headers: options.headers,
233
+ signal: options.options?.signal,
234
+ respond: (payload: JSONObject): void => {
235
+ resolve(new HTTPResponse<JSONObject>(200, payload, {}));
236
+ },
237
+ fail: (statusCode: number, message: string): void => {
238
+ resolve(new HTTPErrorResponse(statusCode, { message }, {}));
239
+ },
240
+ reject: reject,
241
+ };
242
+ requests.push(request);
243
+ if (autoRespond) {
244
+ request.respond(defaultPayload(request));
245
+ }
246
+ },
247
+ );
248
+ });
249
+ }
250
+
251
+ /*
252
+ * How each request asked: "fresh" when it told the server to bypass its
253
+ * response cache, "cached" when it sent the ordinary body (no `fresh` key at
254
+ * all — the flag is never sent as false).
255
+ */
256
+ function freshness(list: Array<RecordedRequest>): Array<string> {
257
+ return list.map((request: RecordedRequest): string => {
258
+ if (request.body["fresh"] === true) {
259
+ return "fresh";
260
+ }
261
+ return "fresh" in request.body
262
+ ? `unexpected fresh: ${String(request.body["fresh"])}`
263
+ : "cached";
264
+ });
118
265
  }
119
266
 
120
- function Probe(props: { range: RangeStartAndEndDateTime }): React.ReactElement {
121
- const data: ReturnType<typeof useTopologyData> = useTopologyData(props.range);
267
+ function names(entities: Array<TopologyEntity> | undefined): string {
268
+ return (entities || [])
269
+ .map((entity: TopologyEntity): string => {
270
+ return entity.displayName || "";
271
+ })
272
+ .join(",");
273
+ }
274
+
275
+ function Probe(props: {
276
+ range: RangeStartAndEndDateTime;
277
+ view: TopologyView;
278
+ }): React.ReactElement {
279
+ const data: TopologyData = useTopologyData(props.range, props.view);
122
280
  return (
123
281
  <div>
124
- <span data-testid="loading">{String(data.isLoading)}</span>
125
- <span data-testid="error">{data.error}</span>
126
- <span data-testid="entities">
127
- {data.entities
128
- .map((entity: InventoryItem) => {
129
- return entity.displayName;
130
- })
131
- .join(",")}
282
+ <span data-testid="generation">{data.generation}</span>
283
+ <span data-testid="pinned">
284
+ {data.pinnedRangeStart?.toISOString() || ""}
285
+ </span>
286
+ <span data-testid="sm-status">{data.serviceMap.status}</span>
287
+ <span data-testid="sm-error">{data.serviceMap.error?.message || ""}</span>
288
+ <span data-testid="sm-outdated">
289
+ {String(Boolean(data.serviceMap.error?.isOutdated))}
290
+ </span>
291
+ <span data-testid="sm-entities">
292
+ {names(data.serviceMap.data?.entities)}
293
+ </span>
294
+ <span data-testid="sm-range-start">
295
+ {data.serviceMap.data?.rangeStart.toISOString() || ""}
132
296
  </span>
133
- <span data-testid="relationships">{data.relationships.length}</span>
134
- <span data-testid="truncated">{String(data.isTruncated)}</span>
135
- <span data-testid="updated">
136
- {data.lastUpdatedAt?.toISOString() || ""}
297
+ <span data-testid="sm-loaded-at">
298
+ {data.serviceMap.loadedAt?.toISOString() || ""}
137
299
  </span>
138
- <span data-testid="range-start">
139
- {data.rangeStart?.toISOString() || ""}
300
+ <span data-testid="infra-status">{data.infrastructure.status}</span>
301
+ <span data-testid="infra-error">
302
+ {data.infrastructure.error?.message || ""}
303
+ </span>
304
+ <span data-testid="infra-outdated">
305
+ {String(Boolean(data.infrastructure.error?.isOutdated))}
306
+ </span>
307
+ <span data-testid="infra-entities">
308
+ {names(data.infrastructure.data?.entities)}
309
+ </span>
310
+ <span data-testid="infra-range-start">
311
+ {data.infrastructure.data?.rangeStart.toISOString() || ""}
140
312
  </span>
141
313
  <button type="button" onClick={data.reload}>
142
314
  Reload topology
143
315
  </button>
316
+ <button
317
+ type="button"
318
+ onClick={() => {
319
+ data.retry("Service Map");
320
+ }}
321
+ >
322
+ Retry service map
323
+ </button>
324
+ <button
325
+ type="button"
326
+ onClick={() => {
327
+ data.retry("Infrastructure");
328
+ }}
329
+ >
330
+ Retry infrastructure
331
+ </button>
332
+ <button
333
+ type="button"
334
+ onClick={() => {
335
+ data.retry("Network");
336
+ }}
337
+ >
338
+ Retry network
339
+ </button>
144
340
  </div>
145
341
  );
146
342
  }
147
343
 
344
+ function text(testId: string): string {
345
+ return screen.getByTestId(testId).textContent || "";
346
+ }
347
+
348
+ async function flush(): Promise<void> {
349
+ await act(async () => {
350
+ await Promise.resolve();
351
+ });
352
+ }
353
+
148
354
  beforeEach(() => {
149
- getListMock.mockReset();
355
+ requests = [];
356
+ autoRespond = true;
357
+ postMock.mockReset();
150
358
  getProjectIdMock.mockReset();
151
359
  getProjectIdMock.mockReturnValue(PROJECT_ID);
152
- getListMock.mockResolvedValue(result());
360
+ installFakeServer();
153
361
  });
154
362
 
155
363
  afterEach(() => {
156
364
  cleanup();
365
+ jest.useRealTimers();
157
366
  jest.restoreAllMocks();
158
367
  });
159
368
 
160
- describe("topology inventory loading", () => {
161
- test("loads the complete current catalog with a full-precision connection recency bound", async () => {
162
- render(<Probe range={FIRST_RANGE} />);
163
- await waitFor(() => {
164
- expect(screen.getByTestId("loading")).toHaveTextContent("false");
165
- });
166
- expect(getListMock).toHaveBeenCalledTimes(2);
167
- const entityRequest: { query: Record<string, unknown> } =
168
- getListMock.mock.calls[0]![0];
169
- const relationshipRequest: { query: Record<string, unknown> } =
170
- getListMock.mock.calls[1]![0];
171
- expect(entityRequest.query["projectId"]).toEqual(PROJECT_ID);
172
- expect(entityRequest.query["isArchived"]).toBe(false);
173
- expect(entityRequest.query).not.toHaveProperty("lastSeenAt");
174
- expect(relationshipRequest.query["projectId"]).toEqual(PROJECT_ID);
175
- expect(relationshipRequest.query["lastSeenAt"]).toEqual(
176
- new GreaterThanOrEqual<Date>(FIRST_RANGE.startAndEndDate!.startValue),
177
- );
178
- expect(screen.getByTestId("updated").textContent).not.toBe("");
369
+ describe("which tab loads, and when", () => {
370
+ test("the active tab loads with exactly one POST pinned to the range start", async () => {
371
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
372
+ await waitFor(() => {
373
+ expect(text("sm-status")).toBe("ready");
374
+ });
375
+ expect(requests).toHaveLength(1);
376
+ const request: RecordedRequest = requests[0]!;
377
+ expect(request.path).toBe(TopologyApiPath.ServiceMap);
378
+ /* The exact start, unfloored: flooring is the server's job. */
379
+ expect(request.rangeStart).toBe("2026-09-01T09:15:27.000Z");
380
+ expect(request.headers).toEqual({ tenantid: PROJECT_ID.toString() });
381
+ expect(text("pinned")).toBe("2026-09-01T09:15:27.000Z");
382
+ expect(text("sm-entities")).toBe("service map #1");
383
+ /* Views judge activity against the server's echo, not the request. */
384
+ expect(text("sm-range-start")).toBe("2026-09-01T09:15:00.000Z");
385
+ expect(text("sm-loaded-at")).toBe("2026-09-02T10:15:30.000Z");
386
+ expect(text("infra-status")).toBe("idle");
387
+ });
388
+
389
+ test("the Network tab loads nothing at all", async () => {
390
+ render(<Probe range={FIRST_RANGE} view="Network" />);
391
+ await flush();
392
+ expect(postMock).not.toHaveBeenCalled();
393
+ expect(text("sm-status")).toBe("idle");
394
+ expect(text("infra-status")).toBe("idle");
395
+ fireEvent.click(screen.getByRole("button", { name: "Retry network" }));
396
+ await flush();
397
+ expect(postMock).not.toHaveBeenCalled();
179
398
  });
180
399
 
181
- test("loads what the maps need to judge activity and describe resources", async () => {
182
- render(<Probe range={FIRST_RANGE} />);
183
- await waitFor(() => {
184
- expect(screen.getByTestId("loading")).toHaveTextContent("false");
185
- });
186
- const entityRequest: { select: Record<string, boolean> } =
187
- getListMock.mock.calls[0]![0];
188
- const relationshipRequest: { select: Record<string, boolean> } =
189
- getListMock.mock.calls[1]![0];
190
- expect(entityRequest.select).toMatchObject({
191
- entityKey: true,
192
- entityType: true,
193
- source: true,
194
- lastSeenAt: true,
195
- descriptiveAttributes: true,
196
- identifyingAttributes: true,
197
- });
198
- expect(relationshipRequest.select).toMatchObject({
199
- callCount: true,
200
- lastSeenAt: true,
201
- });
202
- // The snapshot remembers the range start it was loaded for.
203
- expect(screen.getByTestId("range-start")).toHaveTextContent(
204
- FIRST_RANGE.startAndEndDate!.startValue.toISOString(),
400
+ test("a shared Infrastructure link loads only Infrastructure", async () => {
401
+ render(<Probe range={FIRST_RANGE} view="Infrastructure" />);
402
+ await waitFor(() => {
403
+ expect(text("infra-status")).toBe("ready");
404
+ });
405
+ expect(
406
+ requests.map((request: RecordedRequest) => {
407
+ return request.path;
408
+ }),
409
+ ).toEqual([TopologyApiPath.Infrastructure]);
410
+ expect(text("infra-entities")).toBe("infrastructure #1");
411
+ expect(text("infra-range-start")).toBe("2026-09-01T09:15:00.000Z");
412
+ expect(text("sm-status")).toBe("idle");
413
+ });
414
+
415
+ test("a tab loads on first activation only; switching back and forth never refetches", async () => {
416
+ const view: ReturnType<typeof render> = render(
417
+ <Probe range={FIRST_RANGE} view="Service Map" />,
205
418
  );
419
+ await waitFor(() => {
420
+ expect(text("sm-status")).toBe("ready");
421
+ });
422
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
423
+ await waitFor(() => {
424
+ expect(text("infra-status")).toBe("ready");
425
+ });
426
+ for (const next of [
427
+ "Service Map",
428
+ "Network",
429
+ "Infrastructure",
430
+ "Service Map",
431
+ ] as Array<TopologyView>) {
432
+ view.rerender(<Probe range={FIRST_RANGE} view={next} />);
433
+ await flush();
434
+ }
435
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(1);
436
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
437
+ expect(text("sm-entities")).toBe("service map #1");
438
+ expect(text("infra-entities")).toBe("infrastructure #1");
439
+ /* Both tabs describe the same moment. */
440
+ expect(requests[0]!.rangeStart).toBe(requests[1]!.rangeStart);
441
+ expect(text("generation")).toBe("1");
206
442
  });
207
443
 
208
- test("only publishes a snapshot after inventory and relationships both finish", async () => {
209
- const relationships: DeferredResult = deferred();
210
- getListMock.mockResolvedValueOnce(result("Checkout API"));
211
- getListMock.mockReturnValueOnce(relationships.promise);
212
- render(<Probe range={FIRST_RANGE} />);
444
+ test("switching away from a loading tab neither cancels nor repeats it", async () => {
445
+ autoRespond = false;
446
+ const view: ReturnType<typeof render> = render(
447
+ <Probe range={FIRST_RANGE} view="Service Map" />,
448
+ );
449
+ await flush();
450
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
451
+ await flush();
452
+ view.rerender(<Probe range={FIRST_RANGE} view="Network" />);
453
+ await flush();
454
+ view.rerender(<Probe range={FIRST_RANGE} view="Service Map" />);
455
+ await flush();
456
+ expect(requests).toHaveLength(2);
457
+ expect(text("sm-status")).toBe("loading");
458
+ expect(text("infra-status")).toBe("loading");
459
+ expect(requests[0]!.signal?.aborted).toBe(false);
460
+ expect(requests[1]!.signal?.aborted).toBe(false);
213
461
  await act(async () => {
214
- await Promise.resolve();
462
+ requests[1]!.respond(
463
+ infrastructurePayload(requests[1]!.rangeStart, "late infrastructure"),
464
+ );
465
+ requests[0]!.respond(
466
+ serviceMapPayload(requests[0]!.rangeStart, "late service map"),
467
+ );
468
+ });
469
+ expect(text("sm-entities")).toBe("late service map");
470
+ expect(text("infra-entities")).toBe("late infrastructure");
471
+ });
472
+ });
473
+
474
+ describe("generations: range, project and reload", () => {
475
+ test("a range change aborts in-flight work, drops both tabs and reloads only the active one", async () => {
476
+ const view: ReturnType<typeof render> = render(
477
+ <Probe range={FIRST_RANGE} view="Service Map" />,
478
+ );
479
+ await waitFor(() => {
480
+ expect(text("sm-status")).toBe("ready");
481
+ });
482
+ autoRespond = false;
483
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
484
+ await flush();
485
+ const obsoleteInfrastructure: RecordedRequest = lastRequestTo(
486
+ TopologyApiPath.Infrastructure,
487
+ );
488
+ expect(text("infra-status")).toBe("loading");
489
+
490
+ autoRespond = true;
491
+ view.rerender(<Probe range={SECOND_RANGE} view="Infrastructure" />);
492
+ await waitFor(() => {
493
+ expect(text("infra-status")).toBe("ready");
215
494
  });
216
- expect(screen.getByTestId("loading")).toHaveTextContent("true");
217
- expect(screen.getByTestId("entities")).toBeEmptyDOMElement();
495
+ expect(obsoleteInfrastructure.signal?.aborted).toBe(true);
496
+ expect(text("generation")).toBe("2");
497
+ expect(text("pinned")).toBe("2026-09-02T09:15:00.000Z");
498
+ expect(lastRequestTo(TopologyApiPath.Infrastructure).rangeStart).toBe(
499
+ "2026-09-02T09:15:00.000Z",
500
+ );
501
+ /* The Service Map's old-range data is gone and was NOT refetched. */
502
+ expect(text("sm-status")).toBe("idle");
503
+ expect(text("sm-entities")).toBe("");
504
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(1);
505
+
506
+ /* The obsolete answer arriving now changes nothing. */
218
507
  await act(async () => {
219
- relationships.resolve(result());
508
+ obsoleteInfrastructure.respond(
509
+ infrastructurePayload(
510
+ obsoleteInfrastructure.rangeStart,
511
+ "obsolete range",
512
+ ),
513
+ );
220
514
  });
221
- expect(screen.getByTestId("entities")).toHaveTextContent("Checkout API");
222
- expect(screen.getByTestId("loading")).toHaveTextContent("false");
515
+ expect(text("infra-entities")).toBe("infrastructure #2");
516
+
517
+ /* Opening the Service Map again loads it for the new range. */
518
+ view.rerender(<Probe range={SECOND_RANGE} view="Service Map" />);
519
+ await waitFor(() => {
520
+ expect(text("sm-status")).toBe("ready");
521
+ });
522
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(2);
523
+ expect(lastRequestTo(TopologyApiPath.ServiceMap).rangeStart).toBe(
524
+ "2026-09-02T09:15:00.000Z",
525
+ );
223
526
  });
224
527
 
225
528
  test("an older range finishing last cannot replace the selected range", async () => {
226
- const oldEntities: DeferredResult = deferred();
227
- const oldRelationships: DeferredResult = deferred();
228
- getListMock.mockReturnValueOnce(oldEntities.promise);
229
- getListMock.mockReturnValueOnce(oldRelationships.promise);
529
+ autoRespond = false;
530
+ const view: ReturnType<typeof render> = render(
531
+ <Probe range={FIRST_RANGE} view="Service Map" />,
532
+ );
533
+ await flush();
534
+ view.rerender(<Probe range={SECOND_RANGE} view="Service Map" />);
535
+ await flush();
536
+ expect(requests).toHaveLength(2);
537
+ await act(async () => {
538
+ requests[1]!.respond(
539
+ serviceMapPayload(requests[1]!.rangeStart, "latest range"),
540
+ );
541
+ });
542
+ expect(text("sm-entities")).toBe("latest range");
543
+ await act(async () => {
544
+ requests[0]!.respond(
545
+ serviceMapPayload(requests[0]!.rangeStart, "obsolete range", {
546
+ entityTruncation: { shown: 1, total: 100 },
547
+ }),
548
+ );
549
+ });
550
+ expect(text("sm-entities")).toBe("latest range");
551
+ expect(text("sm-range-start")).toBe("2026-09-02T09:15:00.000Z");
552
+ });
553
+
554
+ test("an obsolete failure cannot clear the loading state or show an error", async () => {
555
+ autoRespond = false;
230
556
  const view: ReturnType<typeof render> = render(
231
- <Probe range={FIRST_RANGE} />,
557
+ <Probe range={FIRST_RANGE} view="Service Map" />,
232
558
  );
233
- getListMock.mockResolvedValueOnce(result("Latest range"));
234
- getListMock.mockResolvedValueOnce(result());
235
- view.rerender(<Probe range={SECOND_RANGE} />);
559
+ await flush();
560
+ view.rerender(<Probe range={SECOND_RANGE} view="Service Map" />);
561
+ await flush();
562
+ await act(async () => {
563
+ requests[0]!.reject(new Error("An old request failed"));
564
+ });
565
+ expect(text("sm-status")).toBe("loading");
566
+ expect(text("sm-error")).toBe("");
567
+ await act(async () => {
568
+ requests[1]!.respond(
569
+ serviceMapPayload(requests[1]!.rangeStart, "current inventory"),
570
+ );
571
+ });
572
+ expect(text("sm-entities")).toBe("current inventory");
573
+ expect(text("sm-error")).toBe("");
574
+ });
575
+
576
+ test("reload() starts a new generation and loads only the active tab", async () => {
577
+ const view: ReturnType<typeof render> = render(
578
+ <Probe range={FIRST_RANGE} view="Service Map" />,
579
+ );
580
+ await waitFor(() => {
581
+ expect(text("sm-status")).toBe("ready");
582
+ });
583
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
236
584
  await waitFor(() => {
237
- expect(screen.getByTestId("entities")).toHaveTextContent("Latest range");
585
+ expect(text("infra-status")).toBe("ready");
586
+ });
587
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
588
+ await waitFor(() => {
589
+ expect(text("infra-entities")).toBe("infrastructure #2");
590
+ });
591
+ expect(text("generation")).toBe("2");
592
+ expect(text("sm-status")).toBe("idle");
593
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(1);
594
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(2);
595
+ view.rerender(<Probe range={FIRST_RANGE} view="Service Map" />);
596
+ await waitFor(() => {
597
+ expect(text("sm-entities")).toBe("service map #2");
598
+ });
599
+ });
600
+
601
+ test("reload() while a request is pending aborts it and ignores its answer", async () => {
602
+ autoRespond = false;
603
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
604
+ await flush();
605
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
606
+ await flush();
607
+ expect(requests).toHaveLength(2);
608
+ expect(requests[0]!.signal?.aborted).toBe(true);
609
+ expect(requests[1]!.signal?.aborted).toBe(false);
610
+ await act(async () => {
611
+ requests[1]!.respond(
612
+ serviceMapPayload(requests[1]!.rangeStart, "reloaded"),
613
+ );
614
+ });
615
+ await act(async () => {
616
+ requests[0]!.reject(new Error("Request Canceled."));
617
+ });
618
+ expect(text("sm-entities")).toBe("reloaded");
619
+ expect(text("sm-error")).toBe("");
620
+ });
621
+
622
+ test("reload() on the Network tab re-pins the range but requests nothing", async () => {
623
+ render(<Probe range={FIRST_RANGE} view="Network" />);
624
+ await flush();
625
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
626
+ await flush();
627
+ expect(text("generation")).toBe("2");
628
+ expect(postMock).not.toHaveBeenCalled();
629
+ });
630
+
631
+ test("changing projects refetches and ignores the previous project's response", async () => {
632
+ autoRespond = false;
633
+ const view: ReturnType<typeof render> = render(
634
+ <Probe range={FIRST_RANGE} view="Service Map" />,
635
+ );
636
+ await flush();
637
+ const nextProject: ObjectID = new ObjectID(
638
+ "22222222-2222-4222-8222-222222222222",
639
+ );
640
+ getProjectIdMock.mockReturnValue(nextProject);
641
+ view.rerender(<Probe range={FIRST_RANGE} view="Service Map" />);
642
+ await flush();
643
+ expect(requests).toHaveLength(2);
644
+ expect(requests[0]!.signal?.aborted).toBe(true);
645
+ expect(requests[1]!.headers).toEqual({ tenantid: nextProject.toString() });
646
+ await act(async () => {
647
+ requests[1]!.respond(
648
+ serviceMapPayload(requests[1]!.rangeStart, "project two"),
649
+ );
238
650
  });
239
651
  await act(async () => {
240
- oldEntities.resolve(result("Obsolete range", 100));
241
- oldRelationships.resolve(result());
652
+ requests[0]!.respond(
653
+ serviceMapPayload(requests[0]!.rangeStart, "project one"),
654
+ );
655
+ });
656
+ expect(text("sm-entities")).toBe("project two");
657
+ });
658
+
659
+ test("never sends an unscoped request without a project", async () => {
660
+ getProjectIdMock.mockReturnValue(null);
661
+ const view: ReturnType<typeof render> = render(
662
+ <Probe range={FIRST_RANGE} view="Service Map" />,
663
+ );
664
+ await waitFor(() => {
665
+ expect(text("sm-status")).toBe("error");
242
666
  });
243
- expect(screen.getByTestId("entities")).toHaveTextContent("Latest range");
244
- expect(screen.getByTestId("truncated")).toHaveTextContent("false");
667
+ expect(text("sm-error")).toContain("Select a project");
668
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
669
+ await waitFor(() => {
670
+ expect(text("infra-status")).toBe("error");
671
+ });
672
+ fireEvent.click(
673
+ screen.getByRole("button", { name: "Retry infrastructure" }),
674
+ );
675
+ await flush();
676
+ expect(postMock).not.toHaveBeenCalled();
245
677
  });
246
678
 
247
- test("an obsolete failure cannot clear the current loading state or show an error", async () => {
248
- const oldEntities: DeferredResult = deferred();
249
- const latestEntities: DeferredResult = deferred();
250
- getListMock.mockReturnValueOnce(oldEntities.promise);
251
- getListMock.mockResolvedValueOnce(result());
679
+ test("unmounting aborts in-flight requests", async () => {
680
+ autoRespond = false;
252
681
  const view: ReturnType<typeof render> = render(
253
- <Probe range={FIRST_RANGE} />,
682
+ <Probe range={FIRST_RANGE} view="Service Map" />,
254
683
  );
255
- getListMock.mockReturnValueOnce(latestEntities.promise);
256
- getListMock.mockResolvedValueOnce(result());
257
- view.rerender(<Probe range={SECOND_RANGE} />);
684
+ await flush();
685
+ view.unmount();
686
+ expect(requests[0]!.signal?.aborted).toBe(true);
258
687
  await act(async () => {
259
- oldEntities.reject(new Error("An old request failed"));
688
+ requests[0]!.respond(
689
+ serviceMapPayload(requests[0]!.rangeStart, "after unmount"),
690
+ );
691
+ });
692
+ });
693
+ });
694
+
695
+ describe("per-tab errors and retry", () => {
696
+ test("a failed tab reports its own error; retry reloads only that tab", async () => {
697
+ const view: ReturnType<typeof render> = render(
698
+ <Probe range={FIRST_RANGE} view="Service Map" />,
699
+ );
700
+ await waitFor(() => {
701
+ expect(text("sm-status")).toBe("ready");
260
702
  });
261
- expect(screen.getByTestId("loading")).toHaveTextContent("true");
262
- expect(screen.getByTestId("error")).toBeEmptyDOMElement();
703
+ autoRespond = false;
704
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
705
+ await flush();
263
706
  await act(async () => {
264
- latestEntities.resolve(result("Current inventory"));
707
+ lastRequestTo(TopologyApiPath.Infrastructure).fail(500, "Please retry");
265
708
  });
266
- expect(screen.getByTestId("entities")).toHaveTextContent(
267
- "Current inventory",
709
+ expect(text("infra-status")).toBe("error");
710
+ expect(text("infra-error")).toBe("Please retry");
711
+ expect(text("infra-outdated")).toBe("false");
712
+ /* The other tab is untouched. */
713
+ expect(text("sm-status")).toBe("ready");
714
+ expect(text("sm-entities")).toBe("service map #1");
715
+
716
+ autoRespond = true;
717
+ fireEvent.click(
718
+ screen.getByRole("button", { name: "Retry infrastructure" }),
268
719
  );
269
- expect(screen.getByTestId("error")).toBeEmptyDOMElement();
720
+ await waitFor(() => {
721
+ expect(text("infra-status")).toBe("ready");
722
+ });
723
+ expect(text("infra-error")).toBe("");
724
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(2);
725
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(1);
726
+ /* Same generation, same pinned range as the Service Map. */
727
+ expect(text("generation")).toBe("1");
728
+ expect(lastRequestTo(TopologyApiPath.Infrastructure).rangeStart).toBe(
729
+ requests[0]!.rangeStart,
730
+ );
731
+ expect(text("sm-entities")).toBe("service map #1");
270
732
  });
271
733
 
272
- test.each(["inventory", "relationships"])(
273
- "reports a failed %s request without presenting a partial graph, then retries",
274
- async (failedRequest: string) => {
275
- if (failedRequest === "inventory") {
276
- getListMock.mockRejectedValueOnce(new Error("Please retry"));
277
- getListMock.mockResolvedValueOnce(result());
278
- } else {
279
- getListMock.mockResolvedValueOnce(result("Partial inventory"));
280
- getListMock.mockRejectedValueOnce(new Error("Please retry"));
281
- }
282
- render(<Probe range={FIRST_RANGE} />);
283
- await waitFor(() => {
284
- expect(screen.getByTestId("error")).toHaveTextContent("Please retry");
734
+ test.each([
735
+ ["a transport error", "reject"],
736
+ ["an HTTP error", "fail"],
737
+ ])(
738
+ "%s never presents partial data, and retry recovers",
739
+ async (_label: string, mode: string) => {
740
+ autoRespond = false;
741
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
742
+ await flush();
743
+ await act(async () => {
744
+ if (mode === "reject") {
745
+ requests[0]!.reject(new Error("Network is down"));
746
+ } else {
747
+ requests[0]!.fail(503, "Network is down");
748
+ }
285
749
  });
286
- expect(screen.getByTestId("loading")).toHaveTextContent("false");
287
- expect(screen.getByTestId("entities")).toBeEmptyDOMElement();
288
- expect(screen.getByTestId("updated")).toBeEmptyDOMElement();
289
- getListMock.mockResolvedValueOnce(result("Recovered inventory"));
290
- getListMock.mockResolvedValueOnce(result());
291
- fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
750
+ expect(text("sm-status")).toBe("error");
751
+ expect(text("sm-error")).toBe("Network is down");
752
+ expect(text("sm-entities")).toBe("");
753
+ expect(text("sm-loaded-at")).toBe("");
754
+ autoRespond = true;
755
+ fireEvent.click(
756
+ screen.getByRole("button", { name: "Retry service map" }),
757
+ );
292
758
  await waitFor(() => {
293
- expect(screen.getByTestId("entities")).toHaveTextContent(
294
- "Recovered inventory",
295
- );
759
+ expect(text("sm-status")).toBe("ready");
296
760
  });
297
- expect(screen.getByTestId("error")).toBeEmptyDOMElement();
298
- expect(getListMock).toHaveBeenCalledTimes(4);
761
+ expect(text("sm-entities")).toBe("service map #2");
299
762
  },
300
763
  );
301
764
 
302
- test.each(["inventory", "relationships"])(
303
- "flags a truncated %s response and clears the warning after a complete reload",
304
- async (truncatedRequest: string) => {
305
- getListMock.mockResolvedValueOnce(
306
- result("API", truncatedRequest === "inventory" ? 2000 : 1),
307
- );
308
- getListMock.mockResolvedValueOnce(
309
- result(undefined, truncatedRequest === "relationships" ? 2000 : 0),
765
+ test("a retry supersedes a still-pending attempt of the same tab", async () => {
766
+ autoRespond = false;
767
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
768
+ await flush();
769
+ fireEvent.click(screen.getByRole("button", { name: "Retry service map" }));
770
+ await flush();
771
+ expect(requests).toHaveLength(2);
772
+ expect(requests[0]!.signal?.aborted).toBe(true);
773
+ await act(async () => {
774
+ requests[1]!.respond(
775
+ serviceMapPayload(requests[1]!.rangeStart, "retry result"),
310
776
  );
311
- render(<Probe range={FIRST_RANGE} />);
312
- await waitFor(() => {
313
- expect(screen.getByTestId("truncated")).toHaveTextContent("true");
314
- });
315
- fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
316
- await waitFor(() => {
317
- expect(screen.getByTestId("loading")).toHaveTextContent("false");
777
+ });
778
+ await act(async () => {
779
+ requests[0]!.reject(new Error("Original attempt failed"));
780
+ });
781
+ expect(text("sm-entities")).toBe("retry result");
782
+ expect(text("sm-error")).toBe("");
783
+ expect(text("generation")).toBe("1");
784
+ });
785
+
786
+ test.each([
787
+ ["a newer payload format", "version"],
788
+ ["a server without the endpoint (404)", "404"],
789
+ ])(
790
+ "%s is reported as an outdated page, not a transient failure",
791
+ async (_label: string, mode: string) => {
792
+ autoRespond = false;
793
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
794
+ await flush();
795
+ await act(async () => {
796
+ if (mode === "version") {
797
+ requests[0]!.respond(
798
+ serviceMapPayload(requests[0]!.rangeStart, "future", {
799
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION + 1,
800
+ }),
801
+ );
802
+ } else {
803
+ requests[0]!.fail(404, "Not found");
804
+ }
318
805
  });
319
- expect(screen.getByTestId("truncated")).toHaveTextContent("false");
806
+ expect(text("sm-status")).toBe("error");
807
+ expect(text("sm-outdated")).toBe("true");
808
+ expect(text("sm-error")).toBe("Topology was updated. Reload the page.");
809
+ expect(text("sm-entities")).toBe("");
320
810
  },
321
811
  );
812
+ });
322
813
 
323
- test("changing projects refetches and ignores the previous project's response", async () => {
324
- const oldEntities: DeferredResult = deferred();
325
- getListMock.mockReturnValueOnce(oldEntities.promise);
326
- getListMock.mockResolvedValueOnce(result());
814
+ describe("an explicit refresh bypasses the server's response cache", () => {
815
+ /*
816
+ * The server keeps each map for a minute, keyed by the floored range
817
+ * start. A custom range (or a click within the same minute) would get the
818
+ * same payload back, so reload() — and only reload() — asks for a rebuild.
819
+ */
820
+ test("ordinary loads, tab switches, range and project changes are never fresh", async () => {
327
821
  const view: ReturnType<typeof render> = render(
328
- <Probe range={FIRST_RANGE} />,
822
+ <Probe range={FIRST_RANGE} view="Service Map" />,
329
823
  );
330
- const nextProject: ObjectID = new ObjectID(
331
- "22222222-2222-4222-8222-222222222222",
824
+ await waitFor(() => {
825
+ expect(text("sm-status")).toBe("ready");
826
+ });
827
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
828
+ await waitFor(() => {
829
+ expect(text("infra-status")).toBe("ready");
830
+ });
831
+ view.rerender(<Probe range={SECOND_RANGE} view="Infrastructure" />);
832
+ await waitFor(() => {
833
+ expect(text("infra-entities")).toBe("infrastructure #2");
834
+ });
835
+ getProjectIdMock.mockReturnValue(
836
+ new ObjectID("22222222-2222-4222-8222-222222222222"),
332
837
  );
333
- getProjectIdMock.mockReturnValue(nextProject);
334
- getListMock.mockResolvedValueOnce(result("Project two inventory"));
335
- getListMock.mockResolvedValueOnce(result());
336
- view.rerender(<Probe range={FIRST_RANGE} />);
838
+ view.rerender(<Probe range={SECOND_RANGE} view="Infrastructure" />);
839
+ await waitFor(() => {
840
+ expect(text("infra-entities")).toBe("infrastructure #3");
841
+ });
842
+ view.rerender(<Probe range={SECOND_RANGE} view="Service Map" />);
843
+ await waitFor(() => {
844
+ expect(text("sm-status")).toBe("ready");
845
+ });
846
+ expect(requests).toHaveLength(5);
847
+ expect(freshness(requests)).toEqual([
848
+ "cached",
849
+ "cached",
850
+ "cached",
851
+ "cached",
852
+ "cached",
853
+ ]);
854
+ });
855
+
856
+ test("reload() sends the active tab fresh, and the other tab's first load in that generation too", async () => {
857
+ const view: ReturnType<typeof render> = render(
858
+ <Probe range={FIRST_RANGE} view="Service Map" />,
859
+ );
860
+ await waitFor(() => {
861
+ expect(text("sm-status")).toBe("ready");
862
+ });
863
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
864
+ await waitFor(() => {
865
+ expect(text("sm-entities")).toBe("service map #2");
866
+ });
867
+ expect(text("generation")).toBe("2");
868
+ expect(freshness(requestsTo(TopologyApiPath.ServiceMap))).toEqual([
869
+ "cached",
870
+ "fresh",
871
+ ]);
872
+ /* The refresh re-pinned the same custom start: only the flag differs. */
873
+ expect(requests[1]!.rangeStart).toBe(requests[0]!.rangeStart);
874
+
875
+ /*
876
+ * Infrastructure was never loaded in this generation: opened after the
877
+ * refresh, it must not be answered from the pre-refresh cache either.
878
+ */
879
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
337
880
  await waitFor(() => {
338
- expect(screen.getByTestId("entities")).toHaveTextContent(
339
- "Project two inventory",
881
+ expect(text("infra-status")).toBe("ready");
882
+ });
883
+ expect(freshness(requestsTo(TopologyApiPath.Infrastructure))).toEqual([
884
+ "fresh",
885
+ ]);
886
+
887
+ /* Switching back and forth loads nothing more. */
888
+ view.rerender(<Probe range={FIRST_RANGE} view="Service Map" />);
889
+ await flush();
890
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
891
+ await flush();
892
+ expect(requests).toHaveLength(3);
893
+
894
+ /* The next ordinary generation is back to the cache. */
895
+ view.rerender(<Probe range={SECOND_RANGE} view="Infrastructure" />);
896
+ await waitFor(() => {
897
+ expect(text("infra-entities")).toBe("infrastructure #2");
898
+ });
899
+ view.rerender(<Probe range={SECOND_RANGE} view="Service Map" />);
900
+ await waitFor(() => {
901
+ expect(text("sm-entities")).toBe("service map #3");
902
+ });
903
+ expect(freshness(requests.slice(3))).toEqual(["cached", "cached"]);
904
+ });
905
+
906
+ test("a refresh stays fresh until it succeeds: 'Try again' after a failed refresh is fresh too", async () => {
907
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
908
+ await waitFor(() => {
909
+ expect(text("sm-status")).toBe("ready");
910
+ });
911
+ autoRespond = false;
912
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
913
+ await flush();
914
+ await act(async () => {
915
+ lastRequestTo(TopologyApiPath.ServiceMap).fail(500, "Please retry");
916
+ });
917
+ expect(text("sm-status")).toBe("error");
918
+
919
+ autoRespond = true;
920
+ fireEvent.click(screen.getByRole("button", { name: "Retry service map" }));
921
+ await waitFor(() => {
922
+ expect(text("sm-status")).toBe("ready");
923
+ });
924
+ expect(text("generation")).toBe("2");
925
+ /* A cached answer here would silently undo the refresh the user asked for. */
926
+ expect(freshness(requests)).toEqual(["cached", "fresh", "fresh"]);
927
+
928
+ /* Once it succeeded, further loads in the generation are ordinary. */
929
+ fireEvent.click(screen.getByRole("button", { name: "Retry service map" }));
930
+ await waitFor(() => {
931
+ expect(requests).toHaveLength(4);
932
+ });
933
+ expect(freshness(requests)[3]).toBe("cached");
934
+ });
935
+
936
+ test("a second reload is fresh again, and a reload that supersedes a pending one aborts it", async () => {
937
+ autoRespond = false;
938
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
939
+ await flush();
940
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
941
+ await flush();
942
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
943
+ await flush();
944
+ expect(freshness(requests)).toEqual(["cached", "fresh", "fresh"]);
945
+ expect(requests[1]!.signal?.aborted).toBe(true);
946
+ expect(requests[2]!.signal?.aborted).toBe(false);
947
+ await act(async () => {
948
+ requests[2]!.respond(
949
+ serviceMapPayload(requests[2]!.rangeStart, "rebuilt"),
340
950
  );
341
951
  });
342
- expect(getListMock.mock.calls[2]![0].query.projectId).toEqual(nextProject);
952
+ expect(text("sm-entities")).toBe("rebuilt");
953
+ expect(text("generation")).toBe("3");
954
+ });
955
+
956
+ test("a refresh on the Network tab makes the next opened tab fresh", async () => {
957
+ const view: ReturnType<typeof render> = render(
958
+ <Probe range={FIRST_RANGE} view="Network" />,
959
+ );
960
+ await flush();
961
+ fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
962
+ await flush();
963
+ expect(postMock).not.toHaveBeenCalled();
964
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
965
+ await waitFor(() => {
966
+ expect(text("infra-status")).toBe("ready");
967
+ });
968
+ expect(freshness(requests)).toEqual(["fresh"]);
969
+ });
970
+ });
971
+
972
+ describe("a busy server", () => {
973
+ /*
974
+ * The server caps concurrent topology work and answers 429 when it is
975
+ * full. That passes: the tab says so in plain words and keeps "Try
976
+ * again", rather than the "Reload the page" path of an outdated bundle.
977
+ */
978
+ test("a 429 is a friendly, retryable error, and retry recovers", async () => {
979
+ autoRespond = false;
980
+ render(<Probe range={FIRST_RANGE} view="Service Map" />);
981
+ await flush();
343
982
  await act(async () => {
344
- oldEntities.resolve(result("Project one inventory"));
983
+ requests[0]!.fail(429, "Too many topology requests are running.");
345
984
  });
346
- expect(screen.getByTestId("entities")).toHaveTextContent(
347
- "Project two inventory",
985
+ expect(text("sm-status")).toBe("error");
986
+ expect(text("sm-error")).toBe(
987
+ "The topology service is busy. Try again in a moment.",
348
988
  );
989
+ expect(text("sm-outdated")).toBe("false");
990
+ expect(text("sm-entities")).toBe("");
991
+
992
+ autoRespond = true;
993
+ fireEvent.click(screen.getByRole("button", { name: "Retry service map" }));
994
+ await waitFor(() => {
995
+ expect(text("sm-status")).toBe("ready");
996
+ });
997
+ expect(text("sm-error")).toBe("");
998
+ expect(requests).toHaveLength(2);
349
999
  });
1000
+ });
350
1001
 
351
- test("never sends an unscoped inventory request without a project", async () => {
352
- getProjectIdMock.mockReturnValue(null);
353
- render(<Probe range={FIRST_RANGE} />);
1002
+ describe("a relative range drifts while a tab waits to be opened", () => {
1003
+ /*
1004
+ * Only Date is faked: promises, timers and Testing Library's polling keep
1005
+ * running for real.
1006
+ */
1007
+ function fakeClock(now: string): void {
1008
+ jest.useFakeTimers({
1009
+ now: new Date(now),
1010
+ doNotFake: [
1011
+ "hrtime",
1012
+ "nextTick",
1013
+ "performance",
1014
+ "queueMicrotask",
1015
+ "requestAnimationFrame",
1016
+ "cancelAnimationFrame",
1017
+ "requestIdleCallback",
1018
+ "cancelIdleCallback",
1019
+ "setImmediate",
1020
+ "clearImmediate",
1021
+ "setInterval",
1022
+ "clearInterval",
1023
+ "setTimeout",
1024
+ "clearTimeout",
1025
+ ],
1026
+ });
1027
+ }
1028
+
1029
+ test("opening a tab more than 5 minutes after a relative pin re-pins both tabs", async () => {
1030
+ fakeClock("2026-09-02T12:00:00.000Z");
1031
+ const view: ReturnType<typeof render> = render(
1032
+ <Probe range={LAST_HOUR} view="Service Map" />,
1033
+ );
354
1034
  await waitFor(() => {
355
- expect(screen.getByTestId("loading")).toHaveTextContent("false");
1035
+ expect(text("sm-status")).toBe("ready");
356
1036
  });
357
- expect(getListMock).not.toHaveBeenCalled();
358
- expect(screen.getByTestId("error")).toHaveTextContent("Select a project");
1037
+ expect(requests[0]!.rangeStart).toBe("2026-09-02T11:00:00.000Z");
1038
+
1039
+ jest.setSystemTime(
1040
+ new Date(
1041
+ new Date("2026-09-02T12:00:00.000Z").getTime() +
1042
+ TOPOLOGY_RANGE_REPIN_AFTER_MS +
1043
+ 60 * 1000,
1044
+ ),
1045
+ );
1046
+ view.rerender(<Probe range={LAST_HOUR} view="Infrastructure" />);
1047
+ await waitFor(() => {
1048
+ expect(text("infra-status")).toBe("ready");
1049
+ });
1050
+ expect(text("generation")).toBe("2");
1051
+ expect(lastRequestTo(TopologyApiPath.Infrastructure).rangeStart).toBe(
1052
+ "2026-09-02T11:06:00.000Z",
1053
+ );
1054
+ /* The Service Map was drawn for the old window: it is dropped too. */
1055
+ expect(text("sm-status")).toBe("idle");
1056
+ view.rerender(<Probe range={LAST_HOUR} view="Service Map" />);
1057
+ await waitFor(() => {
1058
+ expect(text("sm-status")).toBe("ready");
1059
+ });
1060
+ expect(lastRequestTo(TopologyApiPath.ServiceMap).rangeStart).toBe(
1061
+ "2026-09-02T11:06:00.000Z",
1062
+ );
1063
+ expect(text("generation")).toBe("2");
1064
+ });
1065
+
1066
+ test("within 5 minutes the lazy tab reuses the pinned range", async () => {
1067
+ fakeClock("2026-09-02T12:00:00.000Z");
1068
+ const view: ReturnType<typeof render> = render(
1069
+ <Probe range={LAST_HOUR} view="Service Map" />,
1070
+ );
1071
+ await waitFor(() => {
1072
+ expect(text("sm-status")).toBe("ready");
1073
+ });
1074
+ jest.setSystemTime(new Date("2026-09-02T12:04:59.000Z"));
1075
+ view.rerender(<Probe range={LAST_HOUR} view="Infrastructure" />);
1076
+ await waitFor(() => {
1077
+ expect(text("infra-status")).toBe("ready");
1078
+ });
1079
+ expect(text("generation")).toBe("1");
1080
+ expect(lastRequestTo(TopologyApiPath.Infrastructure).rangeStart).toBe(
1081
+ "2026-09-02T11:00:00.000Z",
1082
+ );
1083
+ expect(text("sm-status")).toBe("ready");
359
1084
  });
360
1085
 
361
- test("a retry supersedes a still-pending request for the same range", async () => {
362
- const firstAttempt: DeferredResult = deferred();
363
- getListMock.mockReturnValueOnce(firstAttempt.promise);
364
- getListMock.mockResolvedValueOnce(result());
365
- render(<Probe range={FIRST_RANGE} />);
366
- getListMock.mockResolvedValueOnce(result("Retry result"));
367
- getListMock.mockResolvedValueOnce(result());
1086
+ test("a custom range never drifts", async () => {
1087
+ fakeClock("2026-09-02T12:00:00.000Z");
1088
+ const view: ReturnType<typeof render> = render(
1089
+ <Probe range={FIRST_RANGE} view="Service Map" />,
1090
+ );
1091
+ await waitFor(() => {
1092
+ expect(text("sm-status")).toBe("ready");
1093
+ });
1094
+ jest.setSystemTime(new Date("2026-09-02T15:00:00.000Z"));
1095
+ view.rerender(<Probe range={FIRST_RANGE} view="Infrastructure" />);
1096
+ await waitFor(() => {
1097
+ expect(text("infra-status")).toBe("ready");
1098
+ });
1099
+ expect(text("generation")).toBe("1");
1100
+ expect(text("sm-status")).toBe("ready");
1101
+ });
1102
+
1103
+ test("a drifted lazy tab after a refresh re-pins as an ordinary load", async () => {
1104
+ fakeClock("2026-09-02T12:00:00.000Z");
1105
+ const view: ReturnType<typeof render> = render(
1106
+ <Probe range={LAST_HOUR} view="Service Map" />,
1107
+ );
1108
+ await waitFor(() => {
1109
+ expect(text("sm-status")).toBe("ready");
1110
+ });
368
1111
  fireEvent.click(screen.getByRole("button", { name: "Reload topology" }));
369
1112
  await waitFor(() => {
370
- expect(screen.getByTestId("entities")).toHaveTextContent("Retry result");
1113
+ expect(text("sm-entities")).toBe("service map #2");
1114
+ });
1115
+ jest.setSystemTime(new Date("2026-09-02T12:10:00.000Z"));
1116
+ view.rerender(<Probe range={LAST_HOUR} view="Infrastructure" />);
1117
+ await waitFor(() => {
1118
+ expect(text("infra-status")).toBe("ready");
371
1119
  });
1120
+ expect(text("generation")).toBe("3");
1121
+ expect(freshness(requests)).toEqual(["cached", "fresh", "cached"]);
1122
+ });
1123
+
1124
+ test("a drifted retry re-pins instead of loading the old window", async () => {
1125
+ fakeClock("2026-09-02T12:00:00.000Z");
1126
+ autoRespond = false;
1127
+ render(<Probe range={LAST_HOUR} view="Service Map" />);
1128
+ await flush();
372
1129
  await act(async () => {
373
- firstAttempt.reject(new Error("Original attempt failed"));
1130
+ requests[0]!.fail(500, "Please retry");
1131
+ });
1132
+ expect(text("sm-status")).toBe("error");
1133
+ autoRespond = true;
1134
+ jest.setSystemTime(new Date("2026-09-02T12:10:00.000Z"));
1135
+ fireEvent.click(screen.getByRole("button", { name: "Retry service map" }));
1136
+ await waitFor(() => {
1137
+ expect(text("sm-status")).toBe("ready");
374
1138
  });
375
- expect(screen.getByTestId("entities")).toHaveTextContent("Retry result");
376
- expect(screen.getByTestId("error")).toBeEmptyDOMElement();
1139
+ expect(text("generation")).toBe("2");
1140
+ expect(requests[1]!.rangeStart).toBe("2026-09-02T11:10:00.000Z");
377
1141
  });
378
1142
  });