@oneuptime/common 12.0.9 → 12.0.11

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 (262) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Models/DatabaseModels/RunnerJob.ts +17 -2
  4. package/Server/API/LlmProviderAPI.ts +8 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
  8. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  9. package/Server/Services/NetworkDeviceService.ts +265 -12
  10. package/Server/Services/RunnerJobService.ts +24 -1
  11. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  12. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  13. package/Server/Utils/LLM/LLMService.ts +108 -19
  14. package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
  15. package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
  16. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
  17. package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
  18. package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
  19. package/Server/Utils/SSRFProtection.ts +5 -4
  20. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  21. package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
  22. package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
  23. package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
  24. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  25. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
  26. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  27. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  28. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  29. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  30. package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
  31. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
  32. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  33. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  34. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  35. package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
  36. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  37. package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
  38. package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
  39. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  40. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  41. package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
  42. package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
  43. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
  44. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
  45. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
  46. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
  47. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
  48. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  49. package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
  50. package/Tests/Types/API/URLTelScheme.test.ts +261 -0
  51. package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
  52. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  53. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  54. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  55. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
  56. package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
  57. package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
  58. package/Tests/UI/Components/Button.test.tsx +279 -0
  59. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  60. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  61. package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
  62. package/Tests/UI/Components/Icon.test.tsx +57 -0
  63. package/Tests/UI/Components/List.test.tsx +20 -3
  64. package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
  65. package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
  66. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  67. package/Tests/UI/Components/Pagination.test.tsx +721 -146
  68. package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
  69. package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
  70. package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
  71. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
  72. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
  73. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
  74. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
  75. package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
  76. package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
  77. package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
  78. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  79. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  80. package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
  81. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  82. package/Types/API/Protocol.ts +9 -0
  83. package/Types/API/URL.ts +286 -20
  84. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  85. package/Types/CustomField/CustomFieldType.ts +28 -0
  86. package/Types/Monitor/MonitorType.ts +11 -0
  87. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  88. package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
  89. package/Types/Runbook/RunbookStepType.ts +23 -0
  90. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
  91. package/UI/Components/Button/Button.tsx +14 -22
  92. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  93. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  94. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  95. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  96. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  97. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  98. package/UI/Components/Dictionary/Dictionary.tsx +4 -39
  99. package/UI/Components/Icon/Icon.tsx +1 -1
  100. package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
  101. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
  102. package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
  103. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
  104. package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
  105. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  106. package/UI/Components/Pagination/Pagination.tsx +373 -328
  107. package/UI/Components/Pagination/PaginationUtil.ts +263 -0
  108. package/UI/Components/Pagination/Screenshots/README.md +15 -0
  109. package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
  110. package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
  111. package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
  112. package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
  113. package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
  114. package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
  115. package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
  116. package/UI/Components/TextArea/TextArea.tsx +3 -0
  117. package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
  118. package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
  119. package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
  120. package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
  121. package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
  122. package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
  123. package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
  124. package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
  125. package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
  126. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
  127. package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
  128. package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
  129. package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
  130. package/Utils/API.ts +243 -36
  131. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  132. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  133. package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
  134. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  135. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  137. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
  139. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  140. package/build/dist/Server/API/LlmProviderAPI.js +9 -1
  141. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  146. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
  147. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  148. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  149. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  150. package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
  151. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  152. package/build/dist/Server/Services/RunnerJobService.js +20 -2
  153. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  154. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  155. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  156. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  157. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  158. package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
  159. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  160. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
  161. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  162. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
  163. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  164. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
  165. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  166. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
  167. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  168. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
  169. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
  170. package/build/dist/Server/Utils/SSRFProtection.js +5 -4
  171. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  172. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  173. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  174. package/build/dist/Types/API/Protocol.js +9 -0
  175. package/build/dist/Types/API/Protocol.js.map +1 -1
  176. package/build/dist/Types/API/URL.js +241 -18
  177. package/build/dist/Types/API/URL.js.map +1 -1
  178. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  179. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  180. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  181. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  182. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  183. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  184. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
  185. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
  186. package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
  187. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  188. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
  189. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  190. package/build/dist/UI/Components/Button/Button.js +10 -16
  191. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  192. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  193. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  194. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  195. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  196. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  197. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  198. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  199. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  200. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  201. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  202. package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
  203. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  204. package/build/dist/UI/Components/Icon/Icon.js +1 -1
  205. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  206. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
  207. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  208. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
  209. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  210. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
  211. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
  212. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  213. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  214. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
  215. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
  216. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  217. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  218. package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
  219. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  220. package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
  221. package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
  222. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
  223. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
  224. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
  225. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
  226. package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
  227. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  228. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
  229. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  230. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
  231. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
  232. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
  233. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
  234. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
  235. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
  236. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
  237. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
  238. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
  239. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
  240. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
  241. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
  242. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
  243. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
  244. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
  245. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
  246. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
  247. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
  248. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
  249. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
  250. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
  251. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
  252. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
  253. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
  254. package/build/dist/Utils/API.js +175 -34
  255. package/build/dist/Utils/API.js.map +1 -1
  256. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  257. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  258. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  259. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  260. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
  261. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
  262. package/package.json +1 -1
@@ -2,24 +2,140 @@ import DatabaseService from "./DatabaseService";
2
2
  import Model from "../../Models/DatabaseModels/NetworkDeviceLink";
3
3
  import NetworkDeviceService from "./NetworkDeviceService";
4
4
  import NetworkDevice from "../../Models/DatabaseModels/NetworkDevice";
5
- import { OnCreate } from "../Types/Database/Hooks";
5
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
6
6
  import CreateBy from "../Types/Database/CreateBy";
7
+ import UpdateBy from "../Types/Database/UpdateBy";
7
8
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
8
9
  import BadDataException from "../../Types/Exception/BadDataException";
9
10
  import ObjectID from "../../Types/ObjectID";
10
11
  import QueryHelper from "../Types/Database/QueryHelper";
11
12
  import RelationIdUtil from "../Utils/Database/RelationIdUtil";
12
13
  import LIMIT_MAX from "../../Types/Database/LimitMax";
14
+ import Query from "../Types/Database/Query";
15
+ import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
13
16
 
14
17
  // Both spellings of each end, for the same reason RelationIdUtil exists.
15
18
  const FROM_DEVICE_KEYS: Array<string> = ["fromDeviceId", "fromDevice"];
16
19
  const TO_DEVICE_KEYS: Array<string> = ["toDeviceId", "toDevice"];
20
+ const PARENT_DEVICE_KEYS: Array<string> = ["parentDeviceId", "parentDevice"];
21
+
22
+ /*
23
+ * A device id, compared the way Postgres compares it.
24
+ *
25
+ * These columns are `uuid`. Postgres normalises and matches them without
26
+ * regard to case, and ObjectID.isValidUUID accepts either case, so an API
27
+ * client may legitimately send `AAAAAAAA-...` for a row stored as
28
+ * `aaaaaaaa-...`. A raw JS !== between the two says "different device" and
29
+ * refuses a parent that is in fact an end of the link. Note that the
30
+ * device-existence check below cannot disagree: it runs in Postgres.
31
+ */
32
+ const sameDeviceId: (a: ObjectID, b: ObjectID) => boolean = (
33
+ a: ObjectID,
34
+ b: ObjectID,
35
+ ): boolean => {
36
+ return a.toString().toLowerCase() === b.toString().toLowerCase();
37
+ };
38
+
39
+ /*
40
+ * A declared parent has to be one of the two devices the link connects.
41
+ * Anything else is not a statement about this link at all, and the map
42
+ * would silently ignore it — the operator would have declared a hierarchy
43
+ * and watched nothing change.
44
+ */
45
+ const assertParentIsAnEnd: (
46
+ parentDeviceId: ObjectID | null,
47
+ fromDeviceId: ObjectID,
48
+ toDeviceId: ObjectID,
49
+ ) => void = (
50
+ parentDeviceId: ObjectID | null,
51
+ fromDeviceId: ObjectID,
52
+ toDeviceId: ObjectID,
53
+ ): void => {
54
+ if (!parentDeviceId) {
55
+ return;
56
+ }
57
+ if (
58
+ !sameDeviceId(parentDeviceId, fromDeviceId) &&
59
+ !sameDeviceId(parentDeviceId, toDeviceId)
60
+ ) {
61
+ throw new BadDataException(
62
+ "The parent device must be one of the two devices this link connects.",
63
+ );
64
+ }
65
+ };
66
+
67
+ /*
68
+ * Which of `keys` the payload actually WRITES.
69
+ *
70
+ * Not the same question as which keys are present. TypeORM drops
71
+ * undefined-valued keys before it builds the SET list — "it doesn't make
72
+ * sense to update undefined properties" — so a key holding undefined
73
+ * changes nothing in the database. Treating it as a write makes this hook
74
+ * validate a row that is never going to exist: model the parent as
75
+ * cleared, accept the write, and leave the stored parent stranded on a
76
+ * link whose end just moved. Null and "" are different and really do
77
+ * clear the column, which is why the test is against undefined alone.
78
+ */
79
+ const writtenKeys: (
80
+ data: Record<string, unknown>,
81
+ keys: Array<string>,
82
+ ) => Array<string> = (
83
+ data: Record<string, unknown>,
84
+ keys: Array<string>,
85
+ ): Array<string> => {
86
+ return keys.filter((key: string) => {
87
+ return key in data && data[key] !== undefined;
88
+ });
89
+ };
90
+
91
+ /*
92
+ * An update payload is a QueryDeepPartialEntity, so a column may hold a
93
+ * raw SQL-expression function instead of a value. RelationIdUtil reads one
94
+ * as null — indistinguishable from "cleared" — and the whole invariant is
95
+ * then checked against a row that bears no relation to what will be
96
+ * written. Nothing in the tree writes this model that way today; refusing
97
+ * is what keeps that true, and a caller who needs it can resolve the id
98
+ * before calling rather than have it silently mis-validated.
99
+ */
100
+ const assertNoSqlExpression: (
101
+ data: Record<string, unknown>,
102
+ keys: Array<string>,
103
+ ) => void = (data: Record<string, unknown>, keys: Array<string>): void => {
104
+ for (const key of keys) {
105
+ if (typeof data[key] === "function") {
106
+ throw new BadDataException(
107
+ `${key} cannot be set to a raw SQL expression on a network device link — the link's ends and its declared parent have to be checked against each other, which needs actual ids.`,
108
+ );
109
+ }
110
+ }
111
+ };
17
112
 
18
113
  export class Service extends DatabaseService<Model> {
19
114
  public constructor() {
20
115
  super(Model);
21
116
  }
22
117
 
118
+ /*
119
+ * onBeforeUpdate runs before DatabaseService permission-checks the query,
120
+ * so reading the raw client query as root would hand the hook rows from
121
+ * other projects. Re-apply the caller's tenant here. (Same helper as
122
+ * NetworkDeviceService and NetworkSiteService — private per service,
123
+ * which is the shape the codebase already settled on.)
124
+ */
125
+ private scopeQueryToCallerTenant(
126
+ query: Query<Model>,
127
+ props: DatabaseCommonInteractionProps,
128
+ ): Query<Model> {
129
+ if (props.isRoot || !props.tenantId) {
130
+ return query;
131
+ }
132
+
133
+ return {
134
+ ...query,
135
+ projectId: props.tenantId,
136
+ };
137
+ }
138
+
23
139
  /*
24
140
  * Two guards, both about links that would be nonsense on the map:
25
141
  *
@@ -39,6 +155,12 @@ export class Service extends DatabaseService<Model> {
39
155
  unknown
40
156
  >;
41
157
 
158
+ assertNoSqlExpression(data, [
159
+ ...FROM_DEVICE_KEYS,
160
+ ...TO_DEVICE_KEYS,
161
+ ...PARENT_DEVICE_KEYS,
162
+ ]);
163
+
42
164
  const fromDeviceId: ObjectID | null = RelationIdUtil.read(
43
165
  data,
44
166
  FROM_DEVICE_KEYS,
@@ -52,7 +174,7 @@ export class Service extends DatabaseService<Model> {
52
174
  throw new BadDataException("A link needs a device at each end.");
53
175
  }
54
176
 
55
- if (fromDeviceId.toString() === toDeviceId.toString()) {
177
+ if (sameDeviceId(fromDeviceId, toDeviceId)) {
56
178
  throw new BadDataException("A device cannot be linked to itself.");
57
179
  }
58
180
 
@@ -77,8 +199,222 @@ export class Service extends DatabaseService<Model> {
77
199
  );
78
200
  }
79
201
 
202
+ assertParentIsAnEnd(
203
+ RelationIdUtil.read(data, PARENT_DEVICE_KEYS),
204
+ fromDeviceId,
205
+ toDeviceId,
206
+ );
207
+
80
208
  return { createBy, carryForward: null };
81
209
  }
210
+
211
+ /*
212
+ * The same invariant, on the other write path.
213
+ *
214
+ * An update can break it two ways, and only one of them is obvious. The
215
+ * obvious one is setting a parent that is not on the link. The other is
216
+ * moving an END: re-point `toDevice` at a different switch and a parent
217
+ * that was valid a moment ago now names a device the link no longer
218
+ * touches. So the post-update state is what gets checked, assembled from
219
+ * the payload where it says something and from the stored row where it
220
+ * does not.
221
+ *
222
+ * An update matches a QUERY, not one row, and each matched row may have
223
+ * different ends — so every one of them is validated, and one bad row
224
+ * rejects the whole write. That is the conservative direction: a partial
225
+ * update that fixed some rows and left others contradicting themselves
226
+ * would be far harder to notice than a refusal.
227
+ *
228
+ * It also re-runs onBeforeCreate's TENANCY check on any end that moves.
229
+ * That check used to exist only on create, which left the update path
230
+ * able to re-point an end at another project's device — the exact threat
231
+ * onBeforeCreate's own comment describes, since the FK is global and the
232
+ * column is member-writable.
233
+ *
234
+ * Only costs a read when the payload touches an end or the parent at all;
235
+ * an update of ports or the bound monitor takes the early return.
236
+ */
237
+ @CaptureSpan()
238
+ protected override async onBeforeUpdate(
239
+ updateBy: UpdateBy<Model>,
240
+ ): Promise<OnUpdate<Model>> {
241
+ const data: Record<string, unknown> = (updateBy.data ||
242
+ {}) as unknown as Record<string, unknown>;
243
+
244
+ const hierarchyKeys: Array<string> = [
245
+ ...FROM_DEVICE_KEYS,
246
+ ...TO_DEVICE_KEYS,
247
+ ...PARENT_DEVICE_KEYS,
248
+ ];
249
+
250
+ if (writtenKeys(data, hierarchyKeys).length === 0) {
251
+ return { updateBy, carryForward: null };
252
+ }
253
+
254
+ assertNoSqlExpression(data, hierarchyKeys);
255
+
256
+ const writtenFromDeviceId: ObjectID | null = RelationIdUtil.read(
257
+ data,
258
+ FROM_DEVICE_KEYS,
259
+ );
260
+ const writtenToDeviceId: ObjectID | null = RelationIdUtil.read(
261
+ data,
262
+ TO_DEVICE_KEYS,
263
+ );
264
+ const writtenParentDeviceId: ObjectID | null = RelationIdUtil.read(
265
+ data,
266
+ PARENT_DEVICE_KEYS,
267
+ );
268
+
269
+ /*
270
+ * Writing the parent key with an empty value is how a hierarchy is
271
+ * cleared back to a peer link. RelationIdUtil reads that as null, which
272
+ * is indistinguishable from "not written" — so the presence of the key
273
+ * (with a value that will actually be written; see writtenKeys), not
274
+ * the truthiness of its value, is what decides whether the stored
275
+ * parent still applies.
276
+ */
277
+ const isParentWritten: boolean =
278
+ writtenKeys(data, PARENT_DEVICE_KEYS).length > 0;
279
+
280
+ const existingLinks: Array<Model> = await this.findBy({
281
+ query: this.scopeQueryToCallerTenant(updateBy.query, updateBy.props),
282
+ select: {
283
+ _id: true,
284
+ projectId: true,
285
+ fromDeviceId: true,
286
+ toDeviceId: true,
287
+ parentDeviceId: true,
288
+ },
289
+ limit: LIMIT_MAX,
290
+ skip: 0,
291
+ props: {
292
+ isRoot: true,
293
+ },
294
+ });
295
+
296
+ /*
297
+ * An end that MOVED has to be re-checked for tenancy, exactly as
298
+ * onBeforeCreate checks it. The FK only requires the device row to
299
+ * exist — it is global — so without this a project member could
300
+ * re-point an end of their own link at another project's device and
301
+ * read its name straight off the map, which is the threat
302
+ * onBeforeCreate's own comment describes. The declared parent inherits
303
+ * the check for free: it has to be one of the two ends.
304
+ *
305
+ * Grouped by project because an update may in principle match links in
306
+ * more than one, and a device is only legitimate within its own.
307
+ */
308
+ /*
309
+ * Deduped: writing the same id to both ends is a self-link, and that
310
+ * has its own error below — it must not surface as "device not found".
311
+ */
312
+ const movedEndIds: Array<string> = [
313
+ ...new Set<string>(
314
+ [writtenFromDeviceId, writtenToDeviceId]
315
+ .filter((id: ObjectID | null): id is ObjectID => {
316
+ return Boolean(id);
317
+ })
318
+ .map((id: ObjectID) => {
319
+ return id.toString();
320
+ }),
321
+ ),
322
+ ];
323
+
324
+ if (movedEndIds.length > 0 && existingLinks.length > 0) {
325
+ /*
326
+ * Every project this write touches: the caller's own tenant, plus
327
+ * the project of each row the query matched (a root caller has no
328
+ * tenant, and only root can match rows across more than one). The
329
+ * new end has to be legitimate in all of them.
330
+ *
331
+ * Guarded on there being matched rows at all: a query that matched
332
+ * nothing writes nothing, and there is no link for a foreign device
333
+ * to end up attached to.
334
+ */
335
+ const projectIds: Set<string> = new Set<string>();
336
+ if (updateBy.props.tenantId) {
337
+ projectIds.add(updateBy.props.tenantId.toString());
338
+ }
339
+ for (const link of existingLinks) {
340
+ if (link.projectId) {
341
+ projectIds.add(link.projectId.toString());
342
+ }
343
+ }
344
+
345
+ /*
346
+ * Fail closed. No project to check against and an end on the move
347
+ * is not a safe update to wave through — it is the one shape where
348
+ * a device from anywhere could be attached to this link.
349
+ */
350
+ if (projectIds.size === 0) {
351
+ throw new BadDataException(
352
+ "Both devices must exist and belong to this project.",
353
+ );
354
+ }
355
+
356
+ for (const projectId of projectIds) {
357
+ const devices: Array<NetworkDevice> = await NetworkDeviceService.findBy(
358
+ {
359
+ query: {
360
+ _id: QueryHelper.any(movedEndIds),
361
+ projectId: new ObjectID(projectId),
362
+ },
363
+ select: { _id: true },
364
+ limit: LIMIT_MAX,
365
+ skip: 0,
366
+ props: { isRoot: true },
367
+ },
368
+ );
369
+
370
+ /*
371
+ * Compared as a SET of ids rather than as a count. A count says
372
+ * "as many rows came back as I asked about", which a duplicate row
373
+ * satisfies just as well as the right rows do — and this is the
374
+ * check standing between one project's map and another project's
375
+ * device names, so it should be answering the actual question.
376
+ */
377
+ const foundIds: Set<string> = new Set<string>(
378
+ devices.map((device: NetworkDevice) => {
379
+ return (device.id || device._id || "").toString().toLowerCase();
380
+ }),
381
+ );
382
+
383
+ const isMissingAnEnd: boolean = movedEndIds.some((id: string) => {
384
+ return !foundIds.has(id.toLowerCase());
385
+ });
386
+
387
+ if (isMissingAnEnd) {
388
+ throw new BadDataException(
389
+ "Both devices must exist and belong to this project.",
390
+ );
391
+ }
392
+ }
393
+ }
394
+
395
+ for (const link of existingLinks) {
396
+ const fromDeviceId: ObjectID | undefined =
397
+ writtenFromDeviceId || link.fromDeviceId;
398
+ const toDeviceId: ObjectID | undefined =
399
+ writtenToDeviceId || link.toDeviceId;
400
+ const parentDeviceId: ObjectID | null = isParentWritten
401
+ ? writtenParentDeviceId
402
+ : link.parentDeviceId || null;
403
+
404
+ // A row mid-migration with a missing end has no invariant to check.
405
+ if (!fromDeviceId || !toDeviceId) {
406
+ continue;
407
+ }
408
+
409
+ if (sameDeviceId(fromDeviceId, toDeviceId)) {
410
+ throw new BadDataException("A device cannot be linked to itself.");
411
+ }
412
+
413
+ assertParentIsAnEnd(parentDeviceId, fromDeviceId, toDeviceId);
414
+ }
415
+
416
+ return { updateBy, carryForward: null };
417
+ }
82
418
  }
83
419
 
84
420
  export default new Service();
@@ -14,12 +14,14 @@ import UpdateBy from "../Types/Database/UpdateBy";
14
14
  import Query from "../Types/Database/Query";
15
15
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
16
16
  import logger, { LogAttributes } from "../Utils/Logger";
17
- import LIMIT_MAX from "../../Types/Database/LimitMax";
17
+ import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
18
+ import SortOrder from "../../Types/BaseDatabase/SortOrder";
18
19
  import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
19
20
  import BadDataException from "../../Types/Exception/BadDataException";
20
21
  import ObjectID from "../../Types/ObjectID";
21
22
  import OneUptimeDate from "../../Types/Date";
22
23
  import CidrMatchUtil from "../../Utils/NetworkSite/CidrMatchUtil";
24
+ import { SiteAssignmentRuleRunResult } from "../../Types/NetworkAutomation/RuleRunResult";
23
25
  import { NetworkDeviceMonitoringMethodUtil } from "../../Types/NetworkDevice/NetworkDeviceMonitoringMethod";
24
26
  import RelationIdUtil from "../Utils/Database/RelationIdUtil";
25
27
  import { EntityManager } from "typeorm";
@@ -78,6 +80,39 @@ function readMonitorIdFromData(data: Record<string, unknown>): ObjectID | null {
78
80
  return RelationIdUtil.read(data, MONITOR_KEYS);
79
81
  }
80
82
 
83
+ /*
84
+ * How many devices a single manual "Run now" of an assignment rule walks.
85
+ * The automatic path only ever sees one device at a time, so this cap is the
86
+ * only thing standing between a rule run and a full-fleet table scan inside
87
+ * one HTTP request. Runs that hit it report isTruncated so the caller can say
88
+ * "run it again" rather than quietly leave half the estate unassigned.
89
+ */
90
+ export const MAX_DEVICES_PER_RULE_RUN: number = LIMIT_PER_PROJECT;
91
+
92
+ // Devices are read in pages so a large estate never lands in memory at once.
93
+ const RULE_RUN_PAGE_SIZE: number = 1000;
94
+
95
+ /*
96
+ * The identity a rule is matched against. The hostname column holds an IP
97
+ * address or a DNS name depending on how the device got here, so it is passed
98
+ * as both — ipInCidr rejects non-IP strings safely. Mirrors the single-device
99
+ * path in applySiteAssignmentRulesToDevice; both must stay in step or a
100
+ * manual run would disagree with what discovery does.
101
+ */
102
+ function toRuleMatchTarget(device: Model): {
103
+ ip: string | undefined;
104
+ hostname: string | undefined;
105
+ sysName: string | undefined;
106
+ name: string | undefined;
107
+ } {
108
+ return {
109
+ ip: device.hostname,
110
+ hostname: device.hostname,
111
+ sysName: device.sysName,
112
+ name: device.name,
113
+ };
114
+ }
115
+
81
116
  export class Service extends DatabaseService<Model> {
82
117
  public constructor() {
83
118
  super(Model);
@@ -616,20 +651,14 @@ export class Service extends DatabaseService<Model> {
616
651
  }
617
652
 
618
653
  /*
619
- * The device's hostname column stores an IP address or a DNS name; pass
620
- * it as both ipInCidr safely rejects non-IP strings. `name` is passed
621
- * too because a discovery import puts the responding IP in `hostname`
622
- * and the device's real identity in `name`, so a hostname pattern that
623
- * only ever saw `hostname` could not match anything a user recognises.
654
+ * `name` is matched on as well as `hostname` because a discovery import
655
+ * puts the responding IP in `hostname` and the device's real identity in
656
+ * `name`, so a hostname pattern that only ever saw `hostname` could not
657
+ * match anything a user recognises. See toRuleMatchTarget.
624
658
  */
625
659
  const winner: NetworkSiteAssignmentRule | null = CidrMatchUtil.pickRule(
626
660
  rules,
627
- {
628
- ip: device.hostname,
629
- hostname: device.hostname,
630
- sysName: device.sysName,
631
- name: device.name,
632
- },
661
+ toRuleMatchTarget(device),
633
662
  );
634
663
 
635
664
  if (!winner || !winner.siteId) {
@@ -654,6 +683,230 @@ export class Service extends DatabaseService<Model> {
654
683
  });
655
684
  }
656
685
 
686
+ /*
687
+ * Runs ONE assignment rule against the devices that already exist, which is
688
+ * the half the automatic path cannot do: rules only ever fire on create, on
689
+ * an identity change, or on the next poll of a device that has no site, so
690
+ * a rule written after the estate was imported would never reach it
691
+ * (OneUptime/oneuptime#3191).
692
+ *
693
+ * Two rules of the automatic path are kept deliberately:
694
+ *
695
+ * - priority still decides. A device this rule matches but a
696
+ * higher-priority rule also matches is REPORTED, not moved: running one
697
+ * rule must never quietly do another rule's work, or the button would
698
+ * produce a placement no rule alone explains.
699
+ * - a device already in a site is left alone unless the caller explicitly
700
+ * asks otherwise. Nothing records whether a site was chosen by a human
701
+ * or by a rule, so overwriting is the caller's decision to make, with
702
+ * the warning that goes with it — and it is not needed for the case the
703
+ * button exists for, since a device imported before the rule existed
704
+ * has no site at all.
705
+ */
706
+ @CaptureSpan()
707
+ public async applySiteAssignmentRuleToExistingDevices(data: {
708
+ ruleId: ObjectID;
709
+ projectId: ObjectID;
710
+ reassignDevicesAlreadyInASite: boolean;
711
+ }): Promise<SiteAssignmentRuleRunResult> {
712
+ /*
713
+ * Every rule in the project, not just the one being run — pickRule needs
714
+ * the whole set to answer "does something outrank this rule here?".
715
+ * Loading them by project is also what scopes the run to the tenant: a
716
+ * rule id from another project simply is not in this list.
717
+ */
718
+ const rules: Array<NetworkSiteAssignmentRule> =
719
+ await NetworkSiteAssignmentRuleService.findBy({
720
+ query: {
721
+ projectId: data.projectId,
722
+ },
723
+ select: {
724
+ _id: true,
725
+ siteId: true,
726
+ subnetCidr: true,
727
+ hostnamePattern: true,
728
+ priority: true,
729
+ createdAt: true,
730
+ },
731
+ limit: LIMIT_MAX,
732
+ skip: 0,
733
+ props: {
734
+ isRoot: true,
735
+ },
736
+ });
737
+
738
+ const rule: NetworkSiteAssignmentRule | undefined = rules.find(
739
+ (candidate: NetworkSiteAssignmentRule) => {
740
+ return candidate._id?.toString() === data.ruleId.toString();
741
+ },
742
+ );
743
+
744
+ if (!rule) {
745
+ throw new BadDataException("Assignment rule not found.");
746
+ }
747
+
748
+ if (!rule.siteId) {
749
+ throw new BadDataException(
750
+ "This assignment rule has no site to assign devices to.",
751
+ );
752
+ }
753
+
754
+ const ruleSiteId: ObjectID = rule.siteId;
755
+
756
+ const result: SiteAssignmentRuleRunResult = {
757
+ devicesEvaluated: 0,
758
+ devicesMatched: 0,
759
+ devicesAssigned: 0,
760
+ devicesAlreadyInRuleSite: 0,
761
+ devicesSkippedAlreadyInAnotherSite: 0,
762
+ devicesClaimedByHigherPriorityRule: 0,
763
+ devicesFailed: 0,
764
+ isTruncated: false,
765
+ };
766
+
767
+ let skip: number = 0;
768
+
769
+ for (;;) {
770
+ const devices: Array<Model> = await this.findBy({
771
+ query: {
772
+ projectId: data.projectId,
773
+ },
774
+ select: {
775
+ _id: true,
776
+ siteId: true,
777
+ hostname: true,
778
+ sysName: true,
779
+ name: true,
780
+ },
781
+ /*
782
+ * Sorted by id so paging stays stable while the run writes to the
783
+ * very rows it is paging over. Assigning a site never changes an id,
784
+ * so no device can be skipped or seen twice.
785
+ */
786
+ sort: {
787
+ _id: SortOrder.Ascending,
788
+ },
789
+ limit: RULE_RUN_PAGE_SIZE,
790
+ skip: skip,
791
+ props: {
792
+ isRoot: true,
793
+ },
794
+ });
795
+
796
+ if (devices.length === 0) {
797
+ break;
798
+ }
799
+
800
+ for (const device of devices) {
801
+ result.devicesEvaluated++;
802
+
803
+ const target: ReturnType<typeof toRuleMatchTarget> =
804
+ toRuleMatchTarget(device);
805
+
806
+ if (!CidrMatchUtil.ruleMatches(rule, target)) {
807
+ continue;
808
+ }
809
+
810
+ result.devicesMatched++;
811
+
812
+ if (device.siteId?.toString() === ruleSiteId.toString()) {
813
+ result.devicesAlreadyInRuleSite++;
814
+ continue;
815
+ }
816
+
817
+ if (device.siteId && !data.reassignDevicesAlreadyInASite) {
818
+ result.devicesSkippedAlreadyInAnotherSite++;
819
+ continue;
820
+ }
821
+
822
+ const winner: NetworkSiteAssignmentRule | null = CidrMatchUtil.pickRule(
823
+ rules,
824
+ target,
825
+ );
826
+
827
+ if (winner?._id?.toString() !== rule._id?.toString()) {
828
+ result.devicesClaimedByHigherPriorityRule++;
829
+ continue;
830
+ }
831
+
832
+ /*
833
+ * Counted as a failure rather than skipped silently: every matched
834
+ * device has to land in exactly one bucket, or the summary the
835
+ * operator reads would not add up.
836
+ */
837
+ if (!device.id) {
838
+ result.devicesFailed++;
839
+ continue;
840
+ }
841
+
842
+ /*
843
+ * Through updateOneById, not a bulk UPDATE: onUpdateSuccess is what
844
+ * refreshes the rollups of the site the device left and the site it
845
+ * joined, and a raw write would leave both stale.
846
+ */
847
+ try {
848
+ await this.updateOneById({
849
+ id: device.id,
850
+ data: {
851
+ siteId: ruleSiteId,
852
+ },
853
+ props: {
854
+ isRoot: true,
855
+ },
856
+ });
857
+
858
+ result.devicesAssigned++;
859
+ } catch (error) {
860
+ // One unhappy device must not abandon the rest of the estate.
861
+ result.devicesFailed++;
862
+ logger.error(
863
+ `Error assigning site from assignment rule ${data.ruleId.toString()} to device ${device.id.toString()}: ${error}`,
864
+ {
865
+ projectId: data.projectId.toString(),
866
+ networkDeviceId: device.id.toString(),
867
+ } as LogAttributes,
868
+ );
869
+ }
870
+ }
871
+
872
+ skip += devices.length;
873
+
874
+ if (devices.length < RULE_RUN_PAGE_SIZE) {
875
+ break;
876
+ }
877
+
878
+ if (skip >= MAX_DEVICES_PER_RULE_RUN) {
879
+ /*
880
+ * A full last page does not prove there is an eleventh thousand of
881
+ * devices — an estate of exactly the cap would report a truncation
882
+ * that never happened, and the UI would tell the user to run it
883
+ * again forever. One row is enough to settle it.
884
+ */
885
+ const nextDevice: Array<Model> = await this.findBy({
886
+ query: {
887
+ projectId: data.projectId,
888
+ },
889
+ select: {
890
+ _id: true,
891
+ },
892
+ sort: {
893
+ _id: SortOrder.Ascending,
894
+ },
895
+ limit: 1,
896
+ skip: skip,
897
+ props: {
898
+ isRoot: true,
899
+ },
900
+ });
901
+
902
+ result.isTruncated = nextDevice.length > 0;
903
+ break;
904
+ }
905
+ }
906
+
907
+ return result;
908
+ }
909
+
657
910
  /*
658
911
  * Atomically claims the devices this probe should poll now and advances
659
912
  * each device's nextPollAt by its own polling interval — the device-owned