@oneuptime/common 12.0.34 → 13.0.0

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 (259) hide show
  1. package/Models/DatabaseModels/GlobalConfig.ts +12 -12
  2. package/Models/DatabaseModels/InventoryItem.ts +44 -1
  3. package/Models/DatabaseModels/NetworkDevice.ts +126 -0
  4. package/Server/EnvironmentConfig.ts +47 -16
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.ts +25 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Infrastructure/Redis.ts +8 -8
  8. package/Server/Infrastructure/Status.ts +10 -4
  9. package/Server/Services/DatabaseService.ts +65 -13
  10. package/Server/Services/InventoryItemService.ts +31 -1
  11. package/Server/Services/MonitorTemplateService.ts +140 -19
  12. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +349 -41
  13. package/Server/Services/NetworkDeviceDiscoveryScanService.ts +87 -2
  14. package/Server/Services/NetworkDeviceService.ts +69 -0
  15. package/Server/Services/UserNotificationRuleService.ts +76 -25
  16. package/Server/Types/Database/Permissions/ReadPermission.ts +45 -5
  17. package/Server/Types/Database/QueryUtil.ts +56 -0
  18. package/Server/Types/Markdown.ts +93 -1
  19. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +82 -17
  20. package/Server/Utils/EmailRollup/EmailRollupFlushRunner.ts +10 -1
  21. package/Server/Utils/EmailRollup/EmailRollupRenderer.ts +2 -2
  22. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +82 -35
  23. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +31 -5
  24. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +202 -37
  25. package/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.ts +82 -9
  26. package/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.ts +16 -7
  27. package/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.ts +29 -17
  28. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +107 -48
  29. package/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.ts +158 -0
  30. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +27 -0
  31. package/Server/Utils/Telemetry/EntityRegistry.ts +11 -1
  32. package/Server/Utils/Telemetry/TelemetryEntity.ts +83 -9
  33. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +32 -0
  34. package/Tests/App/Dashboard/AdoptedDeviceDrawer.test.tsx +374 -0
  35. package/Tests/App/Dashboard/DashboardLabelVariables.test.tsx +580 -0
  36. package/Tests/App/Dashboard/DashboardMonitorLabelVariable.test.tsx +438 -0
  37. package/Tests/App/Dashboard/DashboardVariableAllContract.test.tsx +5 -2
  38. package/Tests/App/Dashboard/DeviceAttachmentCard.test.tsx +273 -0
  39. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +505 -0
  40. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +163 -0
  41. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +401 -0
  42. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +232 -0
  43. package/Tests/App/Dashboard/InventoryItems.test.tsx +201 -0
  44. package/Tests/App/Dashboard/InventoryTypeAndStatusFacets.test.tsx +798 -0
  45. package/Tests/App/Dashboard/NetworkTopologyLiveView.test.tsx +320 -0
  46. package/Tests/App/Dashboard/NetworkTopologyToolbar.test.tsx +275 -0
  47. package/Tests/App/Dashboard/OnCallCalendarFeedEmptyState.test.tsx +975 -0
  48. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +63 -0
  49. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +626 -0
  50. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +348 -0
  51. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +331 -0
  52. package/Tests/App/Dashboard/UserSettingsEmailPreferences.test.tsx +603 -0
  53. package/Tests/App/Dashboard/UserSettingsNotificationSettings.test.tsx +324 -0
  54. package/Tests/App/PublicDashboard/DashboardVariableSelector.test.tsx +13 -4
  55. package/Tests/App/StatusPage/StatusPageOidcOrigin.test.tsx +223 -0
  56. package/Tests/Models/InventoryItemStatus.test.ts +147 -0
  57. package/Tests/Models/NetworkDeviceMacAddressColumn.test.ts +294 -0
  58. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +129 -0
  59. package/Tests/Server/EnvironmentConfigValkey.test.ts +227 -0
  60. package/Tests/Server/Infrastructure/Postgres/AddMacAddressToNetworkDeviceMigration.test.ts +365 -0
  61. package/Tests/Server/Infrastructure/Postgres/CustomFieldValueMappingMigration.test.ts +62 -23
  62. package/Tests/Server/Infrastructure/ValkeyDeploymentConfig.test.ts +265 -0
  63. package/Tests/Server/Middleware/HttpMetricsMiddleware.test.ts +382 -0
  64. package/Tests/Server/Middleware/SCIMAuthorization.test.ts +525 -0
  65. package/Tests/Server/Services/DatabaseServiceAggregateBy.test.ts +291 -56
  66. package/Tests/Server/Services/DatabaseServiceSortTiebreaker.test.ts +151 -0
  67. package/Tests/Server/Services/InventoryItemManualCreate.test.ts +105 -0
  68. package/Tests/Server/Services/InventoryItemPromotionGate.test.ts +178 -0
  69. package/Tests/Server/Services/MonitorTemplateServiceCustomFieldSync.test.ts +502 -0
  70. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +1133 -81
  71. package/Tests/Server/Services/NetworkDeviceDiscoveryScanRegistration.test.ts +520 -0
  72. package/Tests/Server/Services/NetworkDeviceMacAddressNormalization.test.ts +572 -0
  73. package/Tests/Server/Types/Database/InventoryStatusPostgres.test.ts +368 -0
  74. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +261 -14
  75. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroups.test.ts +169 -0
  76. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroupsPostgres.test.ts +297 -0
  77. package/Tests/Server/Types/Markdown.test.ts +194 -1
  78. package/Tests/Server/Types/Workflow/Components/IncomingWebhookUtils.test.ts +279 -0
  79. package/Tests/Server/Types/Workflow/Components/TextToJson.test.ts +212 -0
  80. package/Tests/Server/Utils/CustomField/CustomFieldMappingRegistry.test.ts +436 -0
  81. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +214 -0
  82. package/Tests/Server/Utils/EmailRollup/EmailRollupFlushRunnerBehaviour.test.ts +1 -1
  83. package/Tests/Server/Utils/EmailRollup/EmailRollupRenderer.test.ts +10 -3
  84. package/Tests/Server/Utils/EventLoop.test.ts +359 -0
  85. package/Tests/Server/Utils/JsonWebToken.test.ts +30 -0
  86. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +242 -0
  87. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteriaAggregation.test.ts +13 -7
  88. package/Tests/Server/Utils/Monitor/Criteria/DatabaseMonitorCriteria.test.ts +29 -0
  89. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +30 -6
  90. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +246 -0
  91. package/Tests/Server/Utils/Monitor/MonitorCriteriaEmailEvalLogAgreement.test.ts +280 -0
  92. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +224 -10
  93. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +570 -0
  94. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilderUnits.test.ts +30 -5
  95. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageBuilderUnits.test.ts +68 -21
  96. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +36 -19
  97. package/Tests/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.test.ts +573 -0
  98. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +317 -0
  99. package/Tests/Server/Utils/Telemetry/ContextSpanProcessor.test.ts +281 -0
  100. package/Tests/Server/Utils/Telemetry/InventoryDuplicateIdentity.test.ts +316 -0
  101. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +111 -8
  102. package/Tests/Types/Database/IncludesAnyOfGroups.test.ts +106 -0
  103. package/Tests/Types/JSONFunctions.test.ts +40 -0
  104. package/Tests/Types/NetworkAutomation/AutoImportRunMerge.test.ts +235 -0
  105. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +66 -0
  106. package/Tests/Types/NetworkAutomation/RuleRunResultDescribe.test.ts +309 -1
  107. package/Tests/Types/Telemetry/InventoryLiveness.test.ts +99 -0
  108. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +160 -0
  109. package/Tests/UI/ReactRouterSingletonBuild.test.ts +133 -0
  110. package/Tests/UI/Rum/ReplayHeader.test.tsx +10 -2
  111. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +514 -0
  112. package/Tests/UI/Rum/ReplayTimeFormat.test.ts +94 -0
  113. package/Tests/UI/Rum/ReplayUi.test.tsx +559 -0
  114. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  115. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +1 -0
  116. package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +11 -1
  117. package/Tests/Utils/Dashboard/LabelVariable.test.ts +257 -0
  118. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +144 -14
  119. package/Tests/Utils/MetricUnitUtil.test.ts +81 -0
  120. package/Tests/Utils/Monitor/DeviceMacLearningUtil.test.ts +497 -0
  121. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +546 -0
  122. package/Tests/Utils/Monitor/MonitorTemplateCustomFieldUtil.test.ts +218 -0
  123. package/Tests/Utils/Monitor/NetworkTopologyEndpointAdoption.test.ts +1755 -0
  124. package/Tests/Utils/NetworkAutomation/AutoImportRunChain.test.ts +320 -0
  125. package/Types/BaseDatabase/IncludesAnyOfGroups.ts +77 -0
  126. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  127. package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +1 -0
  128. package/Types/Dashboard/DashboardVariable.ts +8 -0
  129. package/Types/JSON.ts +4 -0
  130. package/Types/JSONFunctions.ts +16 -0
  131. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  132. package/Types/NetworkAutomation/RuleRunResult.ts +402 -13
  133. package/Types/SerializableObjectDictionary.ts +4 -0
  134. package/Types/Telemetry/InventoryLiveness.ts +56 -0
  135. package/UI/Components/CSVFileUpload/CSVFileUpload.tsx +9 -2
  136. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +46 -21
  137. package/UI/Components/Dashboard/DashboardVariableControl.tsx +285 -0
  138. package/UI/esbuild-config.js +6 -0
  139. package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +12 -1
  140. package/Utils/Dashboard/LabelVariable.ts +152 -0
  141. package/Utils/Dashboard/VariableUrlState.ts +27 -5
  142. package/Utils/MetricUnitUtil.ts +34 -0
  143. package/Utils/Monitor/DeviceMacLearningUtil.ts +177 -0
  144. package/Utils/Monitor/MetricValueFormatter.ts +342 -0
  145. package/Utils/Monitor/MonitorTemplateCustomFieldUtil.ts +124 -0
  146. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +7 -2
  147. package/Utils/Monitor/NetworkTopologyUtil.ts +693 -0
  148. package/Utils/NetworkAutomation/AutoImportRunChain.ts +138 -0
  149. package/build/dist/Models/DatabaseModels/GlobalConfig.js +12 -12
  150. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/InventoryItem.js +34 -1
  152. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/NetworkDevice.js +128 -0
  154. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  155. package/build/dist/Server/EnvironmentConfig.js +44 -12
  156. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  157. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js +14 -0
  158. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js.map +1 -0
  159. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  160. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  161. package/build/dist/Server/Infrastructure/Redis.js +8 -8
  162. package/build/dist/Server/Infrastructure/Redis.js.map +1 -1
  163. package/build/dist/Server/Infrastructure/Status.js +10 -4
  164. package/build/dist/Server/Infrastructure/Status.js.map +1 -1
  165. package/build/dist/Server/Services/DatabaseService.js +58 -13
  166. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  167. package/build/dist/Server/Services/InventoryItemService.js +28 -1
  168. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  169. package/build/dist/Server/Services/MonitorTemplateService.js +116 -17
  170. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  171. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +270 -37
  172. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  173. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js +65 -0
  174. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js.map +1 -1
  175. package/build/dist/Server/Services/NetworkDeviceService.js +54 -0
  176. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  177. package/build/dist/Server/Services/UserNotificationRuleService.js +60 -25
  178. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  179. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js +29 -4
  180. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js.map +1 -1
  181. package/build/dist/Server/Types/Database/QueryUtil.js +36 -0
  182. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  183. package/build/dist/Server/Types/Markdown.js +75 -0
  184. package/build/dist/Server/Types/Markdown.js.map +1 -1
  185. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +53 -18
  186. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  187. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js +10 -1
  188. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js.map +1 -1
  189. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js +2 -2
  190. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +58 -35
  192. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  193. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +29 -5
  194. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  195. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +166 -35
  196. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  197. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js +58 -9
  198. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js.map +1 -1
  199. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js +9 -3
  200. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js.map +1 -1
  201. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js +26 -12
  202. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js.map +1 -1
  203. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +74 -33
  204. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  205. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js +114 -0
  206. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js.map +1 -0
  207. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +25 -0
  208. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  209. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +9 -1
  210. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  211. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +70 -13
  212. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  213. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js +54 -0
  214. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js.map +1 -0
  215. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  216. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  217. package/build/dist/Types/Dashboard/DashboardVariable.js +1 -0
  218. package/build/dist/Types/Dashboard/DashboardVariable.js.map +1 -1
  219. package/build/dist/Types/JSON.js +1 -0
  220. package/build/dist/Types/JSON.js.map +1 -1
  221. package/build/dist/Types/JSONFunctions.js +13 -0
  222. package/build/dist/Types/JSONFunctions.js.map +1 -1
  223. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +291 -6
  224. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  225. package/build/dist/Types/SerializableObjectDictionary.js +4 -0
  226. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  227. package/build/dist/Types/Telemetry/InventoryLiveness.js +37 -0
  228. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -0
  229. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js +7 -0
  230. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js.map +1 -1
  231. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +9 -2
  232. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  233. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js +137 -0
  234. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js.map +1 -0
  235. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +9 -1
  236. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -1
  237. package/build/dist/Utils/Dashboard/LabelVariable.js +96 -0
  238. package/build/dist/Utils/Dashboard/LabelVariable.js.map +1 -0
  239. package/build/dist/Utils/Dashboard/VariableUrlState.js +19 -4
  240. package/build/dist/Utils/Dashboard/VariableUrlState.js.map +1 -1
  241. package/build/dist/Utils/MetricUnitUtil.js +26 -0
  242. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  243. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js +112 -0
  244. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js.map +1 -0
  245. package/build/dist/Utils/Monitor/MetricValueFormatter.js +276 -0
  246. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -0
  247. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js +101 -0
  248. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js.map +1 -0
  249. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +7 -2
  250. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  251. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +484 -0
  252. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  253. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js +59 -0
  254. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js.map +1 -0
  255. package/jest.config.json +2 -0
  256. package/package.json +1 -1
  257. package/test-setup.sh +9 -9
  258. package/tsconfig.json +51 -11
  259. package/Tests/App/Dashboard/NotificationEmailPreferences.test.tsx +0 -399
@@ -117,6 +117,7 @@ import NetworkDeviceAutoImportRuleEngineService, {
117
117
  AUTO_IMPORT_SWEEP_LOCK_TIMEOUT_MS,
118
118
  ExistingHostnamesByProjectId,
119
119
  ImportAttemptBudgetsByProjectId,
120
+ MAX_CONSECUTIVE_MONITOR_CREATE_FAILURES,
120
121
  MAX_DEVICES_PER_AUTO_IMPORT_RUN,
121
122
  MAX_MONITORS_PER_AUTO_IMPORT_RUN,
122
123
  MAX_RESULT_AGE_IN_HOURS,
@@ -139,6 +140,7 @@ import NetworkDeviceDiscoveryScan, {
139
140
  } from "../../../Models/DatabaseModels/NetworkDeviceDiscoveryScan";
140
141
  import { DiscoveryScanSnmpConfig } from "../../../Utils/NetworkDiscovery/SnmpScanConfigUtil";
141
142
  import BadDataException from "../../../Types/Exception/BadDataException";
143
+ import { JSONObject } from "../../../Types/JSON";
142
144
  import ObjectID from "../../../Types/ObjectID";
143
145
  import OneUptimeDate from "../../../Types/Date";
144
146
  import MonitorSteps from "../../../Types/Monitor/MonitorSteps";
@@ -587,6 +589,7 @@ describe("NetworkDeviceAutoImportRuleEngineService.processCompletedScan", () =>
587
589
  const unmatchedRow: DiscoveredNetworkDevice = makeHost({
588
590
  ipAddress: "192.168.1.5",
589
591
  sysName: "printer-01",
592
+ isAlreadyRegistered: true,
590
593
  });
591
594
 
592
595
  scanFindOneByMock.mockResolvedValue(
@@ -611,6 +614,10 @@ describe("NetworkDeviceAutoImportRuleEngineService.processCompletedScan", () =>
611
614
  expect(createMock).toHaveBeenCalledTimes(1);
612
615
  expect(monitorFindByMock).not.toHaveBeenCalled();
613
616
  expect(monitorTemplateFindByMock).not.toHaveBeenCalled();
617
+ expect(scanFindOneByMock.mock.calls[0]![0].props).toEqual({
618
+ isRoot: true,
619
+ ignoreHooks: true,
620
+ });
614
621
  const device: NetworkDevice = createdDevice(0);
615
622
  // The address is the hostname AND the dedup key downstream.
616
623
  expect(device.hostname).toBe("10.0.0.5");
@@ -813,6 +820,66 @@ describe("NetworkDeviceAutoImportRuleEngineService.processCompletedScan", () =>
813
820
  ).toBe(TEMPLATE_DEVICE_ID);
814
821
  });
815
822
 
823
+ /*
824
+ * ISSUE #3548. A template's custom fields are the DEFAULTS its monitors
825
+ * are born with, and this is the path that made that worth having: a
826
+ * discovery scan importing a thousand devices used to leave a thousand
827
+ * monitors with every custom field empty, fillable only one monitor at a
828
+ * time.
829
+ *
830
+ * Driven through the engine rather than the builder alone for the same
831
+ * reason as the name test above — the values only reach the create call if
832
+ * `customFields` is still in this service's template select, and a dropped
833
+ * select column is indistinguishable from a template nobody set defaults
834
+ * on. Two hosts, because the cached template serves the whole estate: a
835
+ * shallow copy would hand every monitor in the run the same jsonb object,
836
+ * so one later edit would reach the rest of the fleet and the template.
837
+ */
838
+ it("gives every monitor in one run its own copy of the template's custom field defaults", async () => {
839
+ const template: MonitorTemplate = makeTemplate({
840
+ customFields: { Vendor: "Cisco", Thresholds: { cpu: 80 } },
841
+ });
842
+ scanFindOneByMock.mockResolvedValue(
843
+ makeScan({
844
+ discoveredDevices: [
845
+ makeHost(),
846
+ makeHost({ ipAddress: "10.0.0.6", sysName: "core-switch-02" }),
847
+ ],
848
+ }),
849
+ );
850
+ ruleFindByMock.mockResolvedValue([
851
+ makeRule({ monitorTemplateId: TEMPLATE_ID }),
852
+ ]);
853
+ monitorTemplateFindByMock.mockResolvedValue([template]);
854
+
855
+ const result: AutoImportRuleRunResult | null = await processScan();
856
+
857
+ expect(result).toMatchObject({ devicesCreated: 2, monitorsCreated: 2 });
858
+
859
+ const firstMonitor: Monitor = provisionedMonitor(0);
860
+ const secondMonitor: Monitor = provisionedMonitor(1);
861
+
862
+ for (const monitor of [firstMonitor, secondMonitor]) {
863
+ expect(monitor.customFields).toEqual({
864
+ Vendor: "Cisco",
865
+ Thresholds: { cpu: 80 },
866
+ });
867
+ }
868
+
869
+ expect(firstMonitor.customFields).not.toBe(secondMonitor.customFields);
870
+ expect(firstMonitor.customFields?.["Thresholds"]).not.toBe(
871
+ secondMonitor.customFields?.["Thresholds"],
872
+ );
873
+
874
+ (firstMonitor.customFields!["Thresholds"] as JSONObject)["cpu"] = 10;
875
+ expect(
876
+ (secondMonitor.customFields!["Thresholds"] as JSONObject)["cpu"],
877
+ ).toBe(80);
878
+ expect((template.customFields!["Thresholds"] as JSONObject)["cpu"]).toBe(
879
+ 80,
880
+ );
881
+ });
882
+
816
883
  /*
817
884
  * ISSUE #3486. "Default Monitor Name" is optional, and a template that
818
885
  * leaves it blank names what it provisions after the DEVICE and nothing
@@ -1314,7 +1381,89 @@ describe("NetworkDeviceAutoImportRuleEngineService.processCompletedScan", () =>
1314
1381
  expect(monitorCreateMock).not.toHaveBeenCalled();
1315
1382
  });
1316
1383
 
1317
- it("caps missing-template failures across a large existing estate", async () => {
1384
+ /*
1385
+ * OneUptime/oneuptime#3643, the report this whole block exists for.
1386
+ *
1387
+ * An estate of 501 devices that a previous run already imported, and a
1388
+ * rule whose Monitor Template no longer resolves. The template is a
1389
+ * property of the RULE: it fails for the first device and for all 500
1390
+ * after it, and no amount of running again changes that.
1391
+ *
1392
+ * What the customer saw was the engine re-proving that 500 times, one
1393
+ * wasted monitor budget slot at a time, until the budget ran out — then
1394
+ * reporting "Stopped at the run cap - run again to continue", with no hint
1395
+ * of what was actually wrong. What it must do instead is settle the
1396
+ * verdict once, count every monitor that is genuinely missing, and say
1397
+ * why.
1398
+ */
1399
+ it("settles an unresolvable template once instead of re-failing it per device", async () => {
1400
+ const hosts: Array<DiscoveredNetworkDevice> = Array.from(
1401
+ { length: MAX_MONITORS_PER_AUTO_IMPORT_RUN + 1 },
1402
+ (_value: unknown, index: number): DiscoveredNetworkDevice => {
1403
+ return makeHost({
1404
+ ipAddress: `10.2.${Math.floor(index / 256)}.${index % 256}`,
1405
+ sysName: `missing-template-switch-${index}`,
1406
+ });
1407
+ },
1408
+ );
1409
+ const devices: Array<NetworkDevice> = hosts.map(
1410
+ (host: DiscoveredNetworkDevice): NetworkDevice => {
1411
+ return makeExistingDevice({
1412
+ id: ObjectID.generate(),
1413
+ hostname: host.ipAddress,
1414
+ name: host.sysName,
1415
+ });
1416
+ },
1417
+ );
1418
+
1419
+ ruleFindOneByMock.mockResolvedValue(
1420
+ makeRule({
1421
+ ipMatchTarget: "10.0.0.0/8",
1422
+ monitorTemplateId: TEMPLATE_ID,
1423
+ }),
1424
+ );
1425
+ ruleFindByMock.mockResolvedValue([]);
1426
+ deviceFindByMock.mockResolvedValue(devices);
1427
+ monitorTemplateFindByMock.mockResolvedValue([]);
1428
+ mockRunNowScans([makeScan({ discoveredDevices: hosts })]);
1429
+
1430
+ const result: AutoImportRuleRunResult = await runRule(false);
1431
+
1432
+ expect(result).toMatchObject({
1433
+ hostsMatched: hosts.length,
1434
+ hostsSkippedAlreadyRegistered: hosts.length,
1435
+ devicesCreated: 0,
1436
+ monitorsCreated: 0,
1437
+ // Every device really is missing its monitor, so every one is counted.
1438
+ monitorsFailed: hosts.length,
1439
+ monitorProvisioningHalted: true,
1440
+ /*
1441
+ * Nothing was cut short by a cap, so nothing is waiting for another
1442
+ * run. This is the misleading half of the customer's dialog.
1443
+ */
1444
+ isTruncated: false,
1445
+ });
1446
+
1447
+ expect(result.monitorFailureReasons).toEqual([
1448
+ "This rule's Monitor Template could not be loaded. It may have been deleted, moved to another project, or changed to a monitor type other than Network Device. Edit the rule and select a Network Device Monitor Template.",
1449
+ ]);
1450
+ expect(monitorCreateMock).not.toHaveBeenCalled();
1451
+
1452
+ /*
1453
+ * And it costs one log line and one budget slot, not 500 of each. The
1454
+ * per-device race check is an unindexed search of the monitor step
1455
+ * payload; running it 500 times to reach the same verdict is the other
1456
+ * half of what made this run expensive.
1457
+ */
1458
+ expect(
1459
+ loggerErrorMock.mock.calls.filter((call: Array<unknown>): boolean => {
1460
+ return String(call[0]).includes("is missing, deleted, or not valid");
1461
+ }),
1462
+ ).toHaveLength(1);
1463
+ expect(monitorFindByMock.mock.calls.length).toBeLessThanOrEqual(2);
1464
+ });
1465
+
1466
+ it("reports the same unresolvable template on a dry run without a cap", async () => {
1318
1467
  const hosts: Array<DiscoveredNetworkDevice> = Array.from(
1319
1468
  { length: MAX_MONITORS_PER_AUTO_IMPORT_RUN + 1 },
1320
1469
  (_value: unknown, index: number): DiscoveredNetworkDevice => {
@@ -1349,115 +1498,409 @@ describe("NetworkDeviceAutoImportRuleEngineService.processCompletedScan", () =>
1349
1498
 
1350
1499
  expect(result).toMatchObject({
1351
1500
  monitorsWouldCreate: 0,
1352
- monitorsFailed: MAX_MONITORS_PER_AUTO_IMPORT_RUN,
1353
- isTruncated: true,
1501
+ monitorsFailed: hosts.length,
1502
+ monitorProvisioningHalted: true,
1503
+ isTruncated: false,
1354
1504
  isDryRun: true,
1355
1505
  });
1356
1506
  expect(monitorCreateMock).not.toHaveBeenCalled();
1357
1507
  });
1358
- });
1359
1508
 
1360
- /*
1361
- * An over-long "address" is junk that would fail the varchar(100)
1362
- * hostname on every pass, identically, foreverso it is refused before
1363
- * any counting rather than logged as a devicesFailed per worker tick.
1364
- */
1365
- it("skips a host whose address could never be a hostname, before any counting", async () => {
1366
- scanFindOneByMock.mockResolvedValue(
1367
- makeScan({
1368
- discoveredDevices: [
1369
- makeHost({ ipAddress: "9".repeat(101) }),
1370
- makeHost(),
1371
- ],
1372
- }),
1373
- );
1509
+ /*
1510
+ * The other shape of the same report: the template resolves fine, and
1511
+ * every create fails the same way anyway a plan limit, criteria naming
1512
+ * a monitor status that no longer exists, a database refusing writes.
1513
+ * Indistinguishable from a per-host failure on the first device, so the
1514
+ * engine tries, and stops once the evidence is decisive.
1515
+ */
1516
+ it("stops provisioning after the same create failure repeats for every device", async () => {
1517
+ const hosts: Array<DiscoveredNetworkDevice> = Array.from(
1518
+ { length: MAX_MONITORS_PER_AUTO_IMPORT_RUN + 1 },
1519
+ (_value: unknown, index: number): DiscoveredNetworkDevice => {
1520
+ return makeHost({
1521
+ ipAddress: `10.3.${Math.floor(index / 256)}.${index % 256}`,
1522
+ sysName: `systemic-switch-${index}`,
1523
+ });
1524
+ },
1525
+ );
1526
+ const devices: Array<NetworkDevice> = hosts.map(
1527
+ (host: DiscoveredNetworkDevice): NetworkDevice => {
1528
+ return makeExistingDevice({
1529
+ id: ObjectID.generate(),
1530
+ hostname: host.ipAddress,
1531
+ name: host.sysName,
1532
+ });
1533
+ },
1534
+ );
1374
1535
 
1375
- const result: AutoImportRuleRunResult | null = await processScan();
1536
+ ruleFindOneByMock.mockResolvedValue(
1537
+ makeRule({
1538
+ ipMatchTarget: "10.0.0.0/8",
1539
+ monitorTemplateId: TEMPLATE_ID,
1540
+ }),
1541
+ );
1542
+ ruleFindByMock.mockResolvedValue([]);
1543
+ deviceFindByMock.mockResolvedValue(devices);
1544
+ monitorFindByMock.mockResolvedValue([]);
1545
+ monitorFindOneByMock.mockResolvedValue(null);
1546
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
1547
+ monitorCreateMock.mockRejectedValue(
1548
+ new BadDataException(
1549
+ "You have reached the maximum allowed monitor limit for the free plan.",
1550
+ ),
1551
+ );
1552
+ mockRunNowScans([makeScan({ discoveredDevices: hosts })]);
1376
1553
 
1377
- // The junk row was never evaluated, let alone matched or attempted.
1378
- expect(result).toMatchObject({
1379
- hostsEvaluated: 1,
1380
- hostsMatched: 1,
1381
- devicesCreated: 1,
1382
- devicesFailed: 0,
1554
+ const result: AutoImportRuleRunResult = await runRule(false);
1555
+
1556
+ expect(monitorCreateMock).toHaveBeenCalledTimes(
1557
+ MAX_CONSECUTIVE_MONITOR_CREATE_FAILURES,
1558
+ );
1559
+ expect(result).toMatchObject({
1560
+ monitorsCreated: 0,
1561
+ monitorsFailed: hosts.length,
1562
+ monitorProvisioningHalted: true,
1563
+ isTruncated: false,
1564
+ });
1565
+ expect(result.monitorFailureReasons).toEqual([
1566
+ "You have reached the maximum allowed monitor limit for the free plan.",
1567
+ ]);
1383
1568
  });
1384
- expect(createMock).toHaveBeenCalledTimes(1);
1385
- expect(createdDevice(0).hostname).toBe("10.0.0.5");
1386
- });
1387
1569
 
1388
- describe("the create-failure protocol", () => {
1389
1570
  /*
1390
- * Names are unique per project while addresses are not, and two devices
1391
- * legitimately sharing a sysName is common on real estates. When the
1392
- * ADDRESS is still free after a failed create, the name was the problem
1393
- * — one retry under the address-suffixed fallback settles it.
1571
+ * The guard has to stay narrow, or it becomes a new way to lose monitors.
1572
+ * Failures that differ are per-host failures: every device still gets its
1573
+ * try.
1394
1574
  */
1395
- it("retries a name collision once under the fallback name", async () => {
1396
- scanFindOneByMock.mockResolvedValue(
1397
- makeScan({ discoveredDevices: [makeHost()] }),
1575
+ it("keeps trying every device when the failures are not identical", async () => {
1576
+ const hosts: Array<DiscoveredNetworkDevice> = Array.from(
1577
+ { length: 30 },
1578
+ (_value: unknown, index: number): DiscoveredNetworkDevice => {
1579
+ return makeHost({
1580
+ ipAddress: `10.4.0.${index}`,
1581
+ sysName: `varied-switch-${index}`,
1582
+ });
1583
+ },
1584
+ );
1585
+ const devices: Array<NetworkDevice> = hosts.map(
1586
+ (host: DiscoveredNetworkDevice): NetworkDevice => {
1587
+ return makeExistingDevice({
1588
+ id: ObjectID.generate(),
1589
+ hostname: host.ipAddress,
1590
+ name: host.sysName,
1591
+ });
1592
+ },
1398
1593
  );
1399
- createMock
1400
- .mockRejectedValueOnce(
1401
- new BadDataException("Network Device with this name already exists."),
1402
- )
1403
- .mockResolvedValueOnce({});
1404
- // The address re-check finds nothing: the name really was the problem.
1405
- deviceFindOneByMock.mockResolvedValue(null);
1406
1594
 
1407
- const result: AutoImportRuleRunResult | null = await processScan();
1595
+ ruleFindOneByMock.mockResolvedValue(
1596
+ makeRule({
1597
+ ipMatchTarget: "10.0.0.0/8",
1598
+ monitorTemplateId: TEMPLATE_ID,
1599
+ }),
1600
+ );
1601
+ ruleFindByMock.mockResolvedValue([]);
1602
+ deviceFindByMock.mockResolvedValue(devices);
1603
+ monitorFindByMock.mockResolvedValue([]);
1604
+ monitorFindOneByMock.mockResolvedValue(null);
1605
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
1408
1606
 
1409
- expect(createMock).toHaveBeenCalledTimes(2);
1410
- // Same device both times; only the name changed.
1411
- expect(createdDevice(0).name).toBe("core-switch-01");
1412
- expect(createdDevice(1).name).toBe("core-switch-01 (10.0.0.5)");
1413
- expect(createdDevice(1).name!.endsWith(" (10.0.0.5)")).toBe(true);
1414
- expect(createdDevice(1).hostname).toBe("10.0.0.5");
1607
+ let attempt: number = 0;
1608
+ monitorCreateMock.mockImplementation((): Promise<Monitor> => {
1609
+ attempt++;
1610
+ return Promise.reject(new Error(`name collision on device ${attempt}`));
1611
+ });
1612
+ mockRunNowScans([makeScan({ discoveredDevices: hosts })]);
1613
+
1614
+ const result: AutoImportRuleRunResult = await runRule(false);
1415
1615
 
1616
+ expect(monitorCreateMock).toHaveBeenCalledTimes(hosts.length);
1416
1617
  expect(result).toMatchObject({
1417
- devicesCreated: 1,
1418
- devicesFailed: 0,
1419
- hostsSkippedAlreadyRegistered: 0,
1618
+ monitorsFailed: hosts.length,
1619
+ monitorProvisioningHalted: false,
1420
1620
  });
1621
+ // Distinct reasons are reported, bounded.
1622
+ expect(result.monitorFailureReasons).toHaveLength(3);
1421
1623
  });
1422
1624
 
1423
1625
  /*
1424
- * Losing a race to a concurrent import of the SAME host must read as
1425
- * "already registered" retrying under a new name here would spawn a
1426
- * renamed twin of a device that now exists.
1626
+ * The guard must never cost an established estate its monitors. Once this
1627
+ * run has provisioned even one, the create pipeline is known to work, and
1628
+ * everything after it is a per-host failure however many of them there are
1629
+ * and however alike they read.
1427
1630
  */
1428
- it("reads a lost create race as already-registered, with no rename retry", async () => {
1429
- scanFindOneByMock.mockResolvedValue(
1430
- makeScan({ discoveredDevices: [makeHost()] }),
1631
+ it("never gives up once the run has created a monitor at all", async () => {
1632
+ const hosts: Array<DiscoveredNetworkDevice> = Array.from(
1633
+ { length: MAX_CONSECUTIVE_MONITOR_CREATE_FAILURES * 3 },
1634
+ (_value: unknown, index: number): DiscoveredNetworkDevice => {
1635
+ return makeHost({
1636
+ ipAddress: `10.7.0.${index}`,
1637
+ sysName: `established-switch-${index}`,
1638
+ });
1639
+ },
1431
1640
  );
1432
- createMock.mockRejectedValueOnce(
1433
- new BadDataException("Network Device with this name already exists."),
1641
+ const devices: Array<NetworkDevice> = hosts.map(
1642
+ (host: DiscoveredNetworkDevice): NetworkDevice => {
1643
+ return makeExistingDevice({
1644
+ id: ObjectID.generate(),
1645
+ hostname: host.ipAddress,
1646
+ name: host.sysName,
1647
+ });
1648
+ },
1434
1649
  );
1435
- // The address re-check finds a device: someone imported it meanwhile.
1436
- deviceFindOneByMock.mockResolvedValue({
1437
- id: new ObjectID("44444444-4444-4444-8444-444444444444"),
1438
- } as unknown as NetworkDevice);
1439
1650
 
1440
- const result: AutoImportRuleRunResult | null = await processScan();
1651
+ ruleFindOneByMock.mockResolvedValue(
1652
+ makeRule({
1653
+ ipMatchTarget: "10.0.0.0/8",
1654
+ monitorTemplateId: TEMPLATE_ID,
1655
+ }),
1656
+ );
1657
+ ruleFindByMock.mockResolvedValue([]);
1658
+ deviceFindByMock.mockResolvedValue(devices);
1659
+ monitorFindByMock.mockResolvedValue([]);
1660
+ monitorFindOneByMock.mockResolvedValue(null);
1661
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
1441
1662
 
1442
- expect(createMock).toHaveBeenCalledTimes(1);
1443
- expect(deviceFindOneByMock.mock.calls[0]![0].select.projectId).toBe(true);
1663
+ let call: number = 0;
1664
+ monitorCreateMock.mockImplementation(
1665
+ ({ data }: { data: Monitor }): Promise<Monitor> => {
1666
+ call++;
1667
+ if (call === 1) {
1668
+ return Promise.resolve(data);
1669
+ }
1670
+ return Promise.reject(new Error("name already in use"));
1671
+ },
1672
+ );
1673
+ mockRunNowScans([makeScan({ discoveredDevices: hosts })]);
1674
+
1675
+ const result: AutoImportRuleRunResult = await runRule(false);
1676
+
1677
+ expect(monitorCreateMock).toHaveBeenCalledTimes(hosts.length);
1444
1678
  expect(result).toMatchObject({
1445
- hostsMatched: 1,
1446
- hostsSkippedAlreadyRegistered: 1,
1447
- devicesCreated: 0,
1448
- devicesFailed: 0,
1679
+ monitorsCreated: 1,
1680
+ monitorsFailed: hosts.length - 1,
1681
+ monitorProvisioningHalted: false,
1449
1682
  });
1450
1683
  });
1451
- });
1452
1684
 
1453
- /*
1454
- * The cap turns "a typo'd rule meets a huge subnet" into a few paced
1455
- * worker ticks instead of one burst of thousands of creates — and the
1456
- * UNSTAMPED marker is what makes the next tick resume where this one
1457
- * stopped, with idempotency skipping everything already created.
1458
- */
1459
- it("stops at the device cap and leaves the marker unstamped so the next tick resumes", async () => {
1460
- const hosts: Array<DiscoveredNetworkDevice> = makeHostsBeyondTheCap();
1685
+ it("lets one success reset the systemic-failure streak", async () => {
1686
+ const hosts: Array<DiscoveredNetworkDevice> = Array.from(
1687
+ { length: MAX_CONSECUTIVE_MONITOR_CREATE_FAILURES * 2 - 1 },
1688
+ (_value: unknown, index: number): DiscoveredNetworkDevice => {
1689
+ return makeHost({
1690
+ ipAddress: `10.5.0.${index}`,
1691
+ sysName: `recovering-switch-${index}`,
1692
+ });
1693
+ },
1694
+ );
1695
+ const devices: Array<NetworkDevice> = hosts.map(
1696
+ (host: DiscoveredNetworkDevice): NetworkDevice => {
1697
+ return makeExistingDevice({
1698
+ id: ObjectID.generate(),
1699
+ hostname: host.ipAddress,
1700
+ name: host.sysName,
1701
+ });
1702
+ },
1703
+ );
1704
+
1705
+ ruleFindOneByMock.mockResolvedValue(
1706
+ makeRule({
1707
+ ipMatchTarget: "10.0.0.0/8",
1708
+ monitorTemplateId: TEMPLATE_ID,
1709
+ }),
1710
+ );
1711
+ ruleFindByMock.mockResolvedValue([]);
1712
+ deviceFindByMock.mockResolvedValue(devices);
1713
+ monitorFindByMock.mockResolvedValue([]);
1714
+ monitorFindOneByMock.mockResolvedValue(null);
1715
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
1716
+
1717
+ /*
1718
+ * Fail one short of the threshold, succeed once, then fail one short
1719
+ * again. Without the reset the second stretch would inherit the first
1720
+ * and halt the run.
1721
+ */
1722
+ let call: number = 0;
1723
+ monitorCreateMock.mockImplementation(
1724
+ ({ data }: { data: Monitor }): Promise<Monitor> => {
1725
+ call++;
1726
+ if (call === MAX_CONSECUTIVE_MONITOR_CREATE_FAILURES) {
1727
+ return Promise.resolve(data);
1728
+ }
1729
+ return Promise.reject(new Error("transient write failure"));
1730
+ },
1731
+ );
1732
+ mockRunNowScans([makeScan({ discoveredDevices: hosts })]);
1733
+
1734
+ const result: AutoImportRuleRunResult = await runRule(false);
1735
+
1736
+ expect(monitorCreateMock).toHaveBeenCalledTimes(hosts.length);
1737
+ expect(result).toMatchObject({
1738
+ monitorsCreated: 1,
1739
+ monitorsFailed: hosts.length - 1,
1740
+ monitorProvisioningHalted: false,
1741
+ });
1742
+ });
1743
+
1744
+ /*
1745
+ * Overlapping scans of the same subnet are normal, and a host in both is
1746
+ * ONE device with one missing monitor. The short circuit has to keep the
1747
+ * same (device, template) bookkeeping a real attempt does, or the report
1748
+ * inflates itself once per scan that mentions the host.
1749
+ */
1750
+ it("counts a halted device once even when two scans carry the same host", async () => {
1751
+ const host: DiscoveredNetworkDevice = makeHost({
1752
+ ipAddress: "10.6.0.1",
1753
+ sysName: "overlapping-switch",
1754
+ });
1755
+ const device: NetworkDevice = makeExistingDevice({
1756
+ id: ObjectID.generate(),
1757
+ hostname: host.ipAddress,
1758
+ name: host.sysName,
1759
+ });
1760
+
1761
+ ruleFindOneByMock.mockResolvedValue(
1762
+ makeRule({
1763
+ ipMatchTarget: "10.0.0.0/8",
1764
+ monitorTemplateId: TEMPLATE_ID,
1765
+ }),
1766
+ );
1767
+ ruleFindByMock.mockResolvedValue([]);
1768
+ deviceFindByMock.mockResolvedValue([device]);
1769
+ monitorTemplateFindByMock.mockResolvedValue([]);
1770
+ mockRunNowScans([
1771
+ makeScan({ discoveredDevices: [host] }),
1772
+ makeScan({ discoveredDevices: [host] }),
1773
+ ]);
1774
+
1775
+ const result: AutoImportRuleRunResult = await runRule(false);
1776
+
1777
+ expect(result).toMatchObject({
1778
+ hostsMatched: 2,
1779
+ monitorsFailed: 1,
1780
+ monitorProvisioningHalted: true,
1781
+ });
1782
+ });
1783
+
1784
+ it("records why a device could not be imported", async () => {
1785
+ scanFindOneByMock.mockResolvedValue(
1786
+ makeScan({ discoveredDevices: [makeHost()] }),
1787
+ );
1788
+ ruleFindByMock.mockResolvedValue([makeRule()]);
1789
+ deviceFindOneByMock.mockResolvedValue(null);
1790
+ createMock.mockRejectedValue(
1791
+ new BadDataException("Network device name is too long."),
1792
+ );
1793
+
1794
+ const result: AutoImportRuleRunResult | null = await processScan();
1795
+
1796
+ expect(result).toMatchObject({ devicesCreated: 0, devicesFailed: 1 });
1797
+ expect(result?.deviceFailureReasons).toEqual([
1798
+ "Network device name is too long.",
1799
+ ]);
1800
+ });
1801
+ });
1802
+
1803
+ /*
1804
+ * An over-long "address" is junk that would fail the varchar(100)
1805
+ * hostname on every pass, identically, forever — so it is refused before
1806
+ * any counting rather than logged as a devicesFailed per worker tick.
1807
+ */
1808
+ it("skips a host whose address could never be a hostname, before any counting", async () => {
1809
+ scanFindOneByMock.mockResolvedValue(
1810
+ makeScan({
1811
+ discoveredDevices: [
1812
+ makeHost({ ipAddress: "9".repeat(101) }),
1813
+ makeHost(),
1814
+ ],
1815
+ }),
1816
+ );
1817
+
1818
+ const result: AutoImportRuleRunResult | null = await processScan();
1819
+
1820
+ // The junk row was never evaluated, let alone matched or attempted.
1821
+ expect(result).toMatchObject({
1822
+ hostsEvaluated: 1,
1823
+ hostsMatched: 1,
1824
+ devicesCreated: 1,
1825
+ devicesFailed: 0,
1826
+ });
1827
+ expect(createMock).toHaveBeenCalledTimes(1);
1828
+ expect(createdDevice(0).hostname).toBe("10.0.0.5");
1829
+ });
1830
+
1831
+ describe("the create-failure protocol", () => {
1832
+ /*
1833
+ * Names are unique per project while addresses are not, and two devices
1834
+ * legitimately sharing a sysName is common on real estates. When the
1835
+ * ADDRESS is still free after a failed create, the name was the problem
1836
+ * — one retry under the address-suffixed fallback settles it.
1837
+ */
1838
+ it("retries a name collision once under the fallback name", async () => {
1839
+ scanFindOneByMock.mockResolvedValue(
1840
+ makeScan({ discoveredDevices: [makeHost()] }),
1841
+ );
1842
+ createMock
1843
+ .mockRejectedValueOnce(
1844
+ new BadDataException("Network Device with this name already exists."),
1845
+ )
1846
+ .mockResolvedValueOnce({});
1847
+ // The address re-check finds nothing: the name really was the problem.
1848
+ deviceFindOneByMock.mockResolvedValue(null);
1849
+
1850
+ const result: AutoImportRuleRunResult | null = await processScan();
1851
+
1852
+ expect(createMock).toHaveBeenCalledTimes(2);
1853
+ // Same device both times; only the name changed.
1854
+ expect(createdDevice(0).name).toBe("core-switch-01");
1855
+ expect(createdDevice(1).name).toBe("core-switch-01 (10.0.0.5)");
1856
+ expect(createdDevice(1).name!.endsWith(" (10.0.0.5)")).toBe(true);
1857
+ expect(createdDevice(1).hostname).toBe("10.0.0.5");
1858
+
1859
+ expect(result).toMatchObject({
1860
+ devicesCreated: 1,
1861
+ devicesFailed: 0,
1862
+ hostsSkippedAlreadyRegistered: 0,
1863
+ });
1864
+ });
1865
+
1866
+ /*
1867
+ * Losing a race to a concurrent import of the SAME host must read as
1868
+ * "already registered" — retrying under a new name here would spawn a
1869
+ * renamed twin of a device that now exists.
1870
+ */
1871
+ it("reads a lost create race as already-registered, with no rename retry", async () => {
1872
+ scanFindOneByMock.mockResolvedValue(
1873
+ makeScan({ discoveredDevices: [makeHost()] }),
1874
+ );
1875
+ createMock.mockRejectedValueOnce(
1876
+ new BadDataException("Network Device with this name already exists."),
1877
+ );
1878
+ // The address re-check finds a device: someone imported it meanwhile.
1879
+ deviceFindOneByMock.mockResolvedValue({
1880
+ id: new ObjectID("44444444-4444-4444-8444-444444444444"),
1881
+ } as unknown as NetworkDevice);
1882
+
1883
+ const result: AutoImportRuleRunResult | null = await processScan();
1884
+
1885
+ expect(createMock).toHaveBeenCalledTimes(1);
1886
+ expect(deviceFindOneByMock.mock.calls[0]![0].select.projectId).toBe(true);
1887
+ expect(result).toMatchObject({
1888
+ hostsMatched: 1,
1889
+ hostsSkippedAlreadyRegistered: 1,
1890
+ devicesCreated: 0,
1891
+ devicesFailed: 0,
1892
+ });
1893
+ });
1894
+ });
1895
+
1896
+ /*
1897
+ * The cap turns "a typo'd rule meets a huge subnet" into a few paced
1898
+ * worker ticks instead of one burst of thousands of creates — and the
1899
+ * UNSTAMPED marker is what makes the next tick resume where this one
1900
+ * stopped, with idempotency skipping everything already created.
1901
+ */
1902
+ it("stops at the device cap and leaves the marker unstamped so the next tick resumes", async () => {
1903
+ const hosts: Array<DiscoveredNetworkDevice> = makeHostsBeyondTheCap();
1461
1904
 
1462
1905
  scanFindOneByMock.mockResolvedValue(makeScan({ discoveredDevices: hosts }));
1463
1906
  ruleFindByMock.mockResolvedValue([
@@ -1818,6 +2261,8 @@ describe("NetworkDeviceAutoImportRuleEngineService.applyRuleToCompletedScans", (
1818
2261
  ]);
1819
2262
  expect(rereadCall.query.projectId.toString()).toBe(PROJECT_ID.toString());
1820
2263
  expect(rereadCall.select.discoveredDevices).toBe(true);
2264
+ expect(rereadCall.props).toEqual({ isRoot: true, ignoreHooks: true });
2265
+ expect(listCall.props).toEqual({ isRoot: true });
1821
2266
  });
1822
2267
 
1823
2268
  it("skips a scan that vanished between the stub listing and the re-read", async () => {
@@ -2062,6 +2507,244 @@ describe("NetworkDeviceAutoImportRuleEngineService.applyRuleToCompletedScans", (
2062
2507
  });
2063
2508
  });
2064
2509
 
2510
+ describe("re-import after deleting discovered devices or monitors (issue #3560)", () => {
2511
+ it.each([false, true])(
2512
+ "uses current inventory when a processed scan still marks a deleted device registered (dry run: %s)",
2513
+ async (isDryRun: boolean) => {
2514
+ ruleFindOneByMock.mockResolvedValue(
2515
+ makeRule({ monitorTemplateId: TEMPLATE_ID }),
2516
+ );
2517
+ ruleFindByMock.mockResolvedValue([]);
2518
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
2519
+ mockRunNowScans([
2520
+ makeScan({
2521
+ completedAt: new Date("2020-01-01T00:00:00.000Z"),
2522
+ autoImportProcessedAt: OneUptimeDate.getCurrentDate(),
2523
+ discoveredDevices: [makeHost({ isAlreadyRegistered: true })],
2524
+ }),
2525
+ ]);
2526
+
2527
+ const result: AutoImportRuleRunResult = await runRule(isDryRun);
2528
+
2529
+ expect(result).toMatchObject({
2530
+ hostsMatched: 1,
2531
+ hostsSkippedAlreadyRegistered: 0,
2532
+ devicesCreated: isDryRun ? 0 : 1,
2533
+ monitorsCreated: isDryRun ? 0 : 1,
2534
+ monitorsWouldCreate: isDryRun ? 1 : 0,
2535
+ devicesFailed: 0,
2536
+ monitorsFailed: 0,
2537
+ matchedIpAddressSample: ["10.0.0.5"],
2538
+ isTruncated: false,
2539
+ isDryRun,
2540
+ });
2541
+ expect(createMock).toHaveBeenCalledTimes(isDryRun ? 0 : 1);
2542
+ expect(monitorCreateMock).toHaveBeenCalledTimes(isDryRun ? 0 : 1);
2543
+ expect(scanUpdateMock).toHaveBeenCalledTimes(isDryRun ? 0 : 1);
2544
+
2545
+ if (!isDryRun) {
2546
+ const device: NetworkDevice = createdDevice(0);
2547
+ expect(device.hostname).toBe("10.0.0.5");
2548
+ expect(device.snmpCommunityString).toBe("public");
2549
+ expect(device.projectId?.toString()).toBe(PROJECT_ID.toString());
2550
+ expect(
2551
+ provisionedMonitor(0).autoProvisionedNetworkDeviceId?.toString(),
2552
+ ).toBe(device.id?.toString());
2553
+ expect(scanUpdateMock.mock.calls[0]![0].data).toEqual({
2554
+ discoveredDevices: [makeHost({ isAlreadyRegistered: true })],
2555
+ });
2556
+ }
2557
+ },
2558
+ );
2559
+
2560
+ it("imports a deleted device from fresh automatic results with a stale registration flag", async () => {
2561
+ scanFindOneByMock.mockResolvedValue(
2562
+ makeScan({
2563
+ discoveredDevices: [makeHost({ isAlreadyRegistered: true })],
2564
+ }),
2565
+ );
2566
+
2567
+ const result: AutoImportRuleRunResult | null = await processScan();
2568
+
2569
+ expect(result).toMatchObject({
2570
+ devicesCreated: 1,
2571
+ hostsSkippedAlreadyRegistered: 0,
2572
+ });
2573
+ expect(
2574
+ scanUpdateMock.mock.calls[0]![0].data.autoImportProcessedAt,
2575
+ ).toBeInstanceOf(Date);
2576
+ });
2577
+
2578
+ it.each([false, true])(
2579
+ "restores a deleted template monitor while retaining its registered device (dry run: %s)",
2580
+ async (isDryRun: boolean) => {
2581
+ const existingDevice: NetworkDevice = makeExistingDevice();
2582
+ deviceFindByMock.mockResolvedValue([existingDevice]);
2583
+ ruleFindOneByMock.mockResolvedValue(
2584
+ makeRule({ monitorTemplateId: TEMPLATE_ID }),
2585
+ );
2586
+ ruleFindByMock.mockResolvedValue([]);
2587
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
2588
+ mockRunNowScans([
2589
+ makeScan({
2590
+ autoImportProcessedAt: OneUptimeDate.getCurrentDate(),
2591
+ discoveredDevices: [makeHost({ isAlreadyRegistered: true })],
2592
+ }),
2593
+ ]);
2594
+
2595
+ const result: AutoImportRuleRunResult = await runRule(isDryRun);
2596
+
2597
+ expect(result).toMatchObject({
2598
+ hostsSkippedAlreadyRegistered: 1,
2599
+ devicesCreated: 0,
2600
+ monitorsCreated: isDryRun ? 0 : 1,
2601
+ monitorsWouldCreate: isDryRun ? 1 : 0,
2602
+ monitorsSkippedAlreadyExisting: 0,
2603
+ });
2604
+ expect(createMock).not.toHaveBeenCalled();
2605
+ expect(scanUpdateMock).not.toHaveBeenCalled();
2606
+ if (!isDryRun) {
2607
+ expect(
2608
+ provisionedMonitor(0).autoProvisionedNetworkDeviceId?.toString(),
2609
+ ).toBe(existingDevice.id?.toString());
2610
+ }
2611
+ },
2612
+ );
2613
+
2614
+ it.each([false, true])(
2615
+ "deduplicates stale registered rows within and across scans (dry run: %s)",
2616
+ async (isDryRun: boolean) => {
2617
+ ruleFindOneByMock.mockResolvedValue(
2618
+ makeRule({ monitorTemplateId: TEMPLATE_ID }),
2619
+ );
2620
+ ruleFindByMock.mockResolvedValue([]);
2621
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
2622
+ mockRunNowScans([
2623
+ makeScan({
2624
+ discoveredDevices: [
2625
+ makeHost({ isAlreadyRegistered: true }),
2626
+ makeHost({ ipAddress: " 10.0.0.5 ", isAlreadyRegistered: false }),
2627
+ ],
2628
+ }),
2629
+ makeScan({
2630
+ id: ObjectID.generate(),
2631
+ discoveredDevices: [makeHost({ isAlreadyRegistered: true })],
2632
+ }),
2633
+ ]);
2634
+
2635
+ const result: AutoImportRuleRunResult = await runRule(isDryRun);
2636
+
2637
+ expect(result).toMatchObject({
2638
+ hostsMatched: 3,
2639
+ hostsSkippedAlreadyRegistered: 2,
2640
+ devicesCreated: isDryRun ? 0 : 1,
2641
+ monitorsCreated: isDryRun ? 0 : 1,
2642
+ monitorsWouldCreate: isDryRun ? 1 : 0,
2643
+ monitorsSkippedAlreadyExisting: 2,
2644
+ matchedIpAddressSample: ["10.0.0.5"],
2645
+ });
2646
+ expect(createMock).toHaveBeenCalledTimes(isDryRun ? 0 : 1);
2647
+ expect(monitorCreateMock).toHaveBeenCalledTimes(isDryRun ? 0 : 1);
2648
+ },
2649
+ );
2650
+
2651
+ it("still honors exclusions when the registered device has been deleted", async () => {
2652
+ ruleFindOneByMock.mockResolvedValue(makeRule());
2653
+ ruleFindByMock.mockResolvedValue([
2654
+ makeRule({
2655
+ id: ObjectID.generate(),
2656
+ isExclusion: true,
2657
+ ipMatchTarget: "10.0.0.5",
2658
+ }),
2659
+ ]);
2660
+ mockRunNowScans([
2661
+ makeScan({
2662
+ discoveredDevices: [makeHost({ isAlreadyRegistered: true })],
2663
+ }),
2664
+ ]);
2665
+
2666
+ const result: AutoImportRuleRunResult = await runRule(false);
2667
+
2668
+ expect(result).toMatchObject({
2669
+ hostsExcluded: 1,
2670
+ hostsMatched: 0,
2671
+ devicesCreated: 0,
2672
+ });
2673
+ expect(createMock).not.toHaveBeenCalled();
2674
+ expect(scanUpdateMock).not.toHaveBeenCalled();
2675
+ });
2676
+
2677
+ it("resumes re-importing a deleted 903-device fleet across the run cap without duplicates", async () => {
2678
+ const hosts: Array<DiscoveredNetworkDevice> = Array.from(
2679
+ { length: 903 },
2680
+ (_value: unknown, index: number): DiscoveredNetworkDevice => {
2681
+ return makeHost({
2682
+ ipAddress: `10.0.${Math.floor(index / 256)}.${index % 256}`,
2683
+ sysName: `switch-${index}`,
2684
+ isAlreadyRegistered: true,
2685
+ });
2686
+ },
2687
+ );
2688
+ const scan: NetworkDeviceDiscoveryScan = makeScan({
2689
+ autoImportProcessedAt: OneUptimeDate.getCurrentDate(),
2690
+ discoveredDevices: hosts,
2691
+ });
2692
+ const inventory: Map<string, NetworkDevice> = new Map();
2693
+ devicesByHostnamesMock.mockImplementation(
2694
+ async (data: {
2695
+ hostnames: Array<string>;
2696
+ }): Promise<Map<string, NetworkDevice>> => {
2697
+ const found: Map<string, NetworkDevice> = new Map();
2698
+ for (const hostname of data.hostnames) {
2699
+ const device: NetworkDevice | undefined = inventory.get(hostname);
2700
+ if (device) {
2701
+ found.set(hostname, device);
2702
+ }
2703
+ }
2704
+ return found;
2705
+ },
2706
+ );
2707
+ createMock.mockImplementation(
2708
+ async ({ data }: { data: NetworkDevice }): Promise<NetworkDevice> => {
2709
+ data.id = ObjectID.generate();
2710
+ inventory.set(data.hostname!, data);
2711
+ return data;
2712
+ },
2713
+ );
2714
+ ruleFindOneByMock.mockResolvedValue(
2715
+ makeRule({ ipMatchTarget: "10.0.0.0/8" }),
2716
+ );
2717
+ ruleFindByMock.mockResolvedValue([]);
2718
+
2719
+ mockRunNowScans([scan]);
2720
+ const firstRun: AutoImportRuleRunResult = await runRule(false);
2721
+ expect(firstRun).toMatchObject({
2722
+ devicesCreated: MAX_DEVICES_PER_AUTO_IMPORT_RUN,
2723
+ hostsSkippedAlreadyRegistered: 0,
2724
+ isTruncated: true,
2725
+ });
2726
+
2727
+ mockRunNowScans([scan]);
2728
+ const secondRun: AutoImportRuleRunResult = await runRule(false);
2729
+ expect(secondRun).toMatchObject({
2730
+ devicesCreated: hosts.length - MAX_DEVICES_PER_AUTO_IMPORT_RUN,
2731
+ hostsSkippedAlreadyRegistered: MAX_DEVICES_PER_AUTO_IMPORT_RUN,
2732
+ isTruncated: false,
2733
+ });
2734
+
2735
+ mockRunNowScans([scan]);
2736
+ const thirdRun: AutoImportRuleRunResult = await runRule(false);
2737
+ expect(thirdRun).toMatchObject({
2738
+ devicesCreated: 0,
2739
+ hostsSkippedAlreadyRegistered: hosts.length,
2740
+ isTruncated: false,
2741
+ });
2742
+ expect(inventory.size).toBe(hosts.length);
2743
+ expect(createMock).toHaveBeenCalledTimes(hosts.length);
2744
+ expect(scanUpdateMock).toHaveBeenCalledTimes(2);
2745
+ });
2746
+ });
2747
+
2065
2748
  /*
2066
2749
  * github.com/OneUptime/oneuptime/issues/3599 — "Auto Import Rule can't see
2067
2750
  * already-discovered devices while the Discovery Scan is still running".
@@ -2250,3 +2933,372 @@ describe("auto-import from a scan that is still sweeping (issue #3599)", () => {
2250
2933
  ]);
2251
2934
  });
2252
2935
  });
2936
+
2937
+ /*
2938
+ * OneUptime issue #3642: a discovery scan found 909 routers, the operator ran
2939
+ * the auto-import rule, "500+" arrived, and the remaining 400-odd were
2940
+ * "simply missing, with no error shown".
2941
+ *
2942
+ * The per-run caps were doing their job. What made them read as silence is
2943
+ * that a capped run used to STOP EVALUATING at the cap, so every number it
2944
+ * reported described only the part of the estate it happened to reach: 909
2945
+ * became "matched 500 out of the 500 discovered hosts it looked at", and
2946
+ * nothing in the answer said how many were left or that the monitor half of
2947
+ * the work has its own separate ceiling.
2948
+ *
2949
+ * These pin the fix at the engine: writes stop at the cap, counting does not.
2950
+ */
2951
+ describe("a capped run reports the remainder (issue #3642)", () => {
2952
+ // A fleet whose hosts all match, addressed so no two collide.
2953
+ function makeFleet(size: number): Array<DiscoveredNetworkDevice> {
2954
+ return Array.from(
2955
+ { length: size },
2956
+ (_value: unknown, index: number): DiscoveredNetworkDevice => {
2957
+ return makeHost({
2958
+ ipAddress: `10.5.${Math.floor(index / 256)}.${index % 256}`,
2959
+ sysName: `WANRTR-${index}`,
2960
+ });
2961
+ },
2962
+ );
2963
+ }
2964
+
2965
+ function makeInventoryFor(
2966
+ hosts: Array<DiscoveredNetworkDevice>,
2967
+ ): Array<NetworkDevice> {
2968
+ return hosts.map((host: DiscoveredNetworkDevice): NetworkDevice => {
2969
+ return makeExistingDevice({
2970
+ id: ObjectID.generate(),
2971
+ hostname: host.ipAddress,
2972
+ name: host.sysName,
2973
+ });
2974
+ });
2975
+ }
2976
+
2977
+ it("counts the hosts the device cap left un-imported, and still evaluates them all", async () => {
2978
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(909);
2979
+
2980
+ scanFindOneByMock.mockResolvedValue(makeScan({ discoveredDevices: hosts }));
2981
+ ruleFindByMock.mockResolvedValue([
2982
+ makeRule({ ipMatchTarget: "10.0.0.0/8" }),
2983
+ ]);
2984
+
2985
+ const result: AutoImportRuleRunResult | null = await processScan();
2986
+
2987
+ // Writes stop at the cap…
2988
+ expect(createMock).toHaveBeenCalledTimes(MAX_DEVICES_PER_AUTO_IMPORT_RUN);
2989
+ expect(result).toMatchObject({
2990
+ devicesCreated: MAX_DEVICES_PER_AUTO_IMPORT_RUN,
2991
+ isTruncated: true,
2992
+ // …counting does not: the whole scan is accounted for.
2993
+ hostsEvaluated: 909,
2994
+ hostsMatched: 909,
2995
+ hostsPendingImport: 909 - MAX_DEVICES_PER_AUTO_IMPORT_RUN,
2996
+ monitorsPendingCreation: 0,
2997
+ });
2998
+ });
2999
+
3000
+ /*
3001
+ * The reporter's own shape: 909 devices already in the inventory, zero
3002
+ * monitors, and a rule that has since had a Monitor Template attached. No
3003
+ * device is created at all, so the DEVICE cap is never even approached —
3004
+ * only the separate monitor cap stops the run.
3005
+ */
3006
+ it("counts the devices the monitor cap left unmonitored", async () => {
3007
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(909);
3008
+
3009
+ scanFindOneByMock.mockResolvedValue(makeScan({ discoveredDevices: hosts }));
3010
+ deviceFindByMock.mockResolvedValue(makeInventoryFor(hosts));
3011
+ ruleFindByMock.mockResolvedValue([
3012
+ makeRule({ ipMatchTarget: "10.0.0.0/8", monitorTemplateId: TEMPLATE_ID }),
3013
+ ]);
3014
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
3015
+
3016
+ const result: AutoImportRuleRunResult | null = await processScan();
3017
+
3018
+ expect(monitorCreateMock).toHaveBeenCalledTimes(
3019
+ MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3020
+ );
3021
+ expect(result).toMatchObject({
3022
+ devicesCreated: 0,
3023
+ monitorsCreated: MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3024
+ isTruncated: true,
3025
+ hostsEvaluated: 909,
3026
+ hostsMatched: 909,
3027
+ hostsSkippedAlreadyRegistered: 909,
3028
+ // The number the report was missing.
3029
+ monitorsPendingCreation: 909 - MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3030
+ hostsPendingImport: 0,
3031
+ });
3032
+ });
3033
+
3034
+ /*
3035
+ * A device cap reached first must not be reported as a monitor shortfall:
3036
+ * the hosts past it have no device yet, so what they are waiting on is the
3037
+ * import, not a monitor.
3038
+ */
3039
+ it("does not double-count un-imported hosts as unmonitored devices", async () => {
3040
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(600);
3041
+
3042
+ scanFindOneByMock.mockResolvedValue(makeScan({ discoveredDevices: hosts }));
3043
+ ruleFindByMock.mockResolvedValue([
3044
+ makeRule({ ipMatchTarget: "10.0.0.0/8", monitorTemplateId: TEMPLATE_ID }),
3045
+ ]);
3046
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
3047
+
3048
+ const result: AutoImportRuleRunResult | null = await processScan();
3049
+
3050
+ expect(result).toMatchObject({
3051
+ devicesCreated: MAX_DEVICES_PER_AUTO_IMPORT_RUN,
3052
+ hostsPendingImport: 600 - MAX_DEVICES_PER_AUTO_IMPORT_RUN,
3053
+ monitorsPendingCreation: 0,
3054
+ isTruncated: true,
3055
+ });
3056
+ });
3057
+
3058
+ /*
3059
+ * Where the issue actually lands for an operator: pressing "Run Rule"
3060
+ * twice must finish the 909, and the second press must report nothing
3061
+ * left. (The dashboard now chains these passes itself — see
3062
+ * AutoImportRunChain — so this is the server half of one button press.)
3063
+ */
3064
+ it("finishes the 909-device monitor backfill over two manual runs", async () => {
3065
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(909);
3066
+ const devices: Array<NetworkDevice> = makeInventoryFor(hosts);
3067
+ const scan: NetworkDeviceDiscoveryScan = makeScan({
3068
+ discoveredDevices: hosts,
3069
+ });
3070
+
3071
+ /*
3072
+ * Monitors accumulate across the two runs the way the database would:
3073
+ * the second run's project-wide snapshot sees what the first created,
3074
+ * and its provenance keys are what make the resume idempotent.
3075
+ */
3076
+ const provisioned: Array<Monitor> = [];
3077
+ monitorCreateMock.mockImplementation(
3078
+ ({ data }: { data: Monitor }): Promise<Monitor> => {
3079
+ data.id = data.id || ObjectID.generate();
3080
+ provisioned.push(data);
3081
+ return Promise.resolve(data);
3082
+ },
3083
+ );
3084
+ monitorFindByMock.mockImplementation((): Promise<Array<Monitor>> => {
3085
+ return Promise.resolve(provisioned);
3086
+ });
3087
+
3088
+ ruleFindOneByMock.mockResolvedValue(
3089
+ makeRule({ ipMatchTarget: "10.0.0.0/8", monitorTemplateId: TEMPLATE_ID }),
3090
+ );
3091
+ ruleFindByMock.mockResolvedValue([]);
3092
+ deviceFindByMock.mockResolvedValue(devices);
3093
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
3094
+
3095
+ mockRunNowScans([scan]);
3096
+ const firstRun: AutoImportRuleRunResult = await runRule(false);
3097
+
3098
+ expect(firstRun).toMatchObject({
3099
+ monitorsCreated: MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3100
+ monitorsPendingCreation: 909 - MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3101
+ isTruncated: true,
3102
+ });
3103
+
3104
+ mockRunNowScans([scan]);
3105
+ const secondRun: AutoImportRuleRunResult = await runRule(false);
3106
+
3107
+ expect(secondRun).toMatchObject({
3108
+ monitorsCreated: 909 - MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3109
+ monitorsSkippedAlreadyExisting: MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3110
+ monitorsPendingCreation: 0,
3111
+ isTruncated: false,
3112
+ });
3113
+
3114
+ // Every router ends up monitored exactly once.
3115
+ expect(provisioned).toHaveLength(909);
3116
+ expect(
3117
+ new Set(
3118
+ provisioned.map((monitor: Monitor): string => {
3119
+ return monitor.autoProvisionedNetworkDeviceId!.toString();
3120
+ }),
3121
+ ).size,
3122
+ ).toBe(909);
3123
+ });
3124
+
3125
+ /*
3126
+ * A capped manual run stops OPENING scans — each is a multi-megabyte jsonb
3127
+ * read it could not act on anyway — so the remainder it counted is a floor.
3128
+ * Reporting it as a total would have the operator run again and watch more
3129
+ * than the promised number appear.
3130
+ */
3131
+ it("says the remainder is a floor when a cap left scans unread", async () => {
3132
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(600);
3133
+
3134
+ ruleFindOneByMock.mockResolvedValue(
3135
+ makeRule({ ipMatchTarget: "10.0.0.0/8" }),
3136
+ );
3137
+ ruleFindByMock.mockResolvedValue([]);
3138
+ mockRunNowScans([
3139
+ makeScan({ discoveredDevices: hosts }),
3140
+ makeScan({ discoveredDevices: makeFleet(5) }),
3141
+ ]);
3142
+
3143
+ const result: AutoImportRuleRunResult = await runRule(false);
3144
+
3145
+ expect(result).toMatchObject({
3146
+ isTruncated: true,
3147
+ hostsPendingImport: 600 - MAX_DEVICES_PER_AUTO_IMPORT_RUN,
3148
+ hasUnevaluatedScans: true,
3149
+ });
3150
+ // The second scan was never opened.
3151
+ expect(scanFindOneByMock).toHaveBeenCalledTimes(1);
3152
+ });
3153
+
3154
+ it("reports an exact remainder when the capped scan was the last one", async () => {
3155
+ ruleFindOneByMock.mockResolvedValue(
3156
+ makeRule({ ipMatchTarget: "10.0.0.0/8" }),
3157
+ );
3158
+ ruleFindByMock.mockResolvedValue([]);
3159
+ mockRunNowScans([makeScan({ discoveredDevices: makeFleet(600) })]);
3160
+
3161
+ const result: AutoImportRuleRunResult = await runRule(false);
3162
+
3163
+ expect(result).toMatchObject({
3164
+ isTruncated: true,
3165
+ hostsPendingImport: 600 - MAX_DEVICES_PER_AUTO_IMPORT_RUN,
3166
+ hasUnevaluatedScans: false,
3167
+ });
3168
+ });
3169
+
3170
+ /*
3171
+ * The dry run predicts the real run, so it must predict the leftovers too
3172
+ * — otherwise "what would this rule import" answers with the cap instead
3173
+ * of with the estate.
3174
+ */
3175
+ it("lets a dry run predict what a real run would leave over", async () => {
3176
+ ruleFindOneByMock.mockResolvedValue(
3177
+ makeRule({ ipMatchTarget: "10.0.0.0/8" }),
3178
+ );
3179
+ ruleFindByMock.mockResolvedValue([]);
3180
+ mockRunNowScans([makeScan({ discoveredDevices: makeFleet(909) })]);
3181
+
3182
+ const result: AutoImportRuleRunResult = await runRule(true);
3183
+
3184
+ expect(result).toMatchObject({
3185
+ isDryRun: true,
3186
+ isTruncated: true,
3187
+ hostsEvaluated: 909,
3188
+ hostsMatched: 909,
3189
+ hostsPendingImport: 909 - MAX_DEVICES_PER_AUTO_IMPORT_RUN,
3190
+ });
3191
+ expect(createMock).not.toHaveBeenCalled();
3192
+ });
3193
+
3194
+ /*
3195
+ * Counting past the cap must not start issuing per-device queries again:
3196
+ * the unindexed monitor JSON search is exactly what the budget check
3197
+ * guards, and running it for every host past the cap would turn a capped
3198
+ * pass into the full-table walk the cap exists to avoid.
3199
+ */
3200
+ it("issues no per-device monitor searches for the hosts it only counted", async () => {
3201
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(600);
3202
+
3203
+ scanFindOneByMock.mockResolvedValue(makeScan({ discoveredDevices: hosts }));
3204
+ deviceFindByMock.mockResolvedValue(makeInventoryFor(hosts));
3205
+ ruleFindByMock.mockResolvedValue([
3206
+ makeRule({ ipMatchTarget: "10.0.0.0/8", monitorTemplateId: TEMPLATE_ID }),
3207
+ ]);
3208
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
3209
+
3210
+ await processScan();
3211
+
3212
+ /*
3213
+ * One project-wide snapshot plus one race-check per device the run
3214
+ * actually attempted — and none for the 100 it merely counted.
3215
+ */
3216
+ expect(monitorFindByMock).toHaveBeenCalledTimes(
3217
+ 1 + MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3218
+ );
3219
+ });
3220
+
3221
+ /*
3222
+ * A stored result lists duplicate rows often enough that the import path
3223
+ * has a bucket for them. Counting rows rather than distinct work would
3224
+ * inflate the very number this fix exists to make trustworthy.
3225
+ */
3226
+ it("counts a duplicated un-imported host once", async () => {
3227
+ const overflow: DiscoveredNetworkDevice = makeHost({
3228
+ ipAddress: "10.9.9.9",
3229
+ sysName: "WANRTR-overflow",
3230
+ });
3231
+ const hosts: Array<DiscoveredNetworkDevice> = [
3232
+ ...makeFleet(MAX_DEVICES_PER_AUTO_IMPORT_RUN),
3233
+ overflow,
3234
+ { ...overflow },
3235
+ { ...overflow },
3236
+ ];
3237
+
3238
+ scanFindOneByMock.mockResolvedValue(makeScan({ discoveredDevices: hosts }));
3239
+ ruleFindByMock.mockResolvedValue([
3240
+ makeRule({ ipMatchTarget: "10.0.0.0/8" }),
3241
+ ]);
3242
+
3243
+ const result: AutoImportRuleRunResult | null = await processScan();
3244
+
3245
+ expect(result).toMatchObject({
3246
+ isTruncated: true,
3247
+ // Three rows, one address, one host still to import.
3248
+ hostsPendingImport: 1,
3249
+ hostsEvaluated: MAX_DEVICES_PER_AUTO_IMPORT_RUN + 3,
3250
+ });
3251
+ });
3252
+
3253
+ it("counts a duplicated unmonitored device once", async () => {
3254
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(
3255
+ MAX_MONITORS_PER_AUTO_IMPORT_RUN,
3256
+ );
3257
+ const overflow: DiscoveredNetworkDevice = makeHost({
3258
+ ipAddress: "10.9.9.9",
3259
+ sysName: "WANRTR-overflow",
3260
+ });
3261
+ const withDuplicates: Array<DiscoveredNetworkDevice> = [
3262
+ ...hosts,
3263
+ overflow,
3264
+ { ...overflow },
3265
+ ];
3266
+
3267
+ scanFindOneByMock.mockResolvedValue(
3268
+ makeScan({ discoveredDevices: withDuplicates }),
3269
+ );
3270
+ deviceFindByMock.mockResolvedValue(makeInventoryFor(withDuplicates));
3271
+ ruleFindByMock.mockResolvedValue([
3272
+ makeRule({ ipMatchTarget: "10.0.0.0/8", monitorTemplateId: TEMPLATE_ID }),
3273
+ ]);
3274
+ monitorTemplateFindByMock.mockResolvedValue([makeTemplate()]);
3275
+
3276
+ const result: AutoImportRuleRunResult | null = await processScan();
3277
+
3278
+ expect(result).toMatchObject({
3279
+ isTruncated: true,
3280
+ monitorsPendingCreation: 1,
3281
+ });
3282
+ });
3283
+
3284
+ /*
3285
+ * The resume protocol is unchanged by the new accounting: a capped pass
3286
+ * that made progress still leaves the marker NULL so the next worker tick
3287
+ * continues, and the counting past the cap must not accidentally stamp it.
3288
+ */
3289
+ it("still leaves a capped pass's marker unstamped so the sweep resumes", async () => {
3290
+ const hosts: Array<DiscoveredNetworkDevice> = makeFleet(600);
3291
+
3292
+ scanFindOneByMock.mockResolvedValue(makeScan({ discoveredDevices: hosts }));
3293
+ ruleFindByMock.mockResolvedValue([
3294
+ makeRule({ ipMatchTarget: "10.0.0.0/8" }),
3295
+ ]);
3296
+
3297
+ await processScan();
3298
+
3299
+ expect(scanUpdateMock).toHaveBeenCalledTimes(1);
3300
+ const updateCall: { data: JSONObject } = scanUpdateMock.mock
3301
+ .calls[0]![0] as { data: JSONObject };
3302
+ expect(Object.keys(updateCall.data)).not.toContain("autoImportProcessedAt");
3303
+ });
3304
+ });