@oneuptime/common 12.0.7 → 12.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/Models/DatabaseModels/TelemetryEntity.ts +45 -0
  2. package/Models/DatabaseModels/TelemetryEntityRelationship.ts +26 -0
  3. package/Models/DatabaseModels/WorkflowLog.ts +38 -0
  4. package/Server/API/UserAPI.ts +16 -1
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  8. package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
  9. package/Server/Services/TelemetryEntityRelationshipService.ts +3 -0
  10. package/Server/Services/TelemetryEntityService.ts +114 -1
  11. package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
  12. package/Server/Types/Workflow/TriggerCode.ts +12 -0
  13. package/Server/Types/Workflow/Workflow.ts +5 -0
  14. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
  15. package/Server/Utils/VM/VMAPI.ts +56 -8
  16. package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
  17. package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
  18. package/Tests/Server/Services/TelemetryEntityManualCreate.test.ts +248 -0
  19. package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
  20. package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
  21. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
  22. package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
  23. package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
  24. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
  25. package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
  26. package/Tests/Types/Workflow/TemplateSyntax.test.ts +379 -0
  27. package/Tests/Types/Workflow/Templates.test.ts +403 -0
  28. package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
  29. package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
  30. package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
  31. package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
  32. package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
  33. package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
  34. package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
  35. package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
  36. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
  37. package/Tests/UI/Components/Workflow/GraphLint.test.ts +821 -0
  38. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +287 -0
  39. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +185 -0
  40. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +174 -0
  41. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +152 -0
  42. package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
  43. package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
  44. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
  45. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +240 -0
  46. package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
  47. package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
  48. package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
  49. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -0
  50. package/Types/SerializableObjectDictionary.ts +133 -39
  51. package/Types/Telemetry/EntitySource.ts +40 -0
  52. package/Types/Telemetry/EntityType.ts +27 -0
  53. package/Types/Telemetry/EntityTypeGroups.ts +65 -0
  54. package/Types/Workflow/Component.ts +29 -0
  55. package/Types/Workflow/Components/API.ts +35 -0
  56. package/Types/Workflow/Components/BaseModel.ts +7 -3
  57. package/Types/Workflow/StepTrace.ts +181 -0
  58. package/Types/Workflow/TemplateSyntax.ts +499 -0
  59. package/Types/Workflow/Templates.ts +316 -0
  60. package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
  61. package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
  62. package/UI/Components/Dictionary/Dictionary.tsx +23 -6
  63. package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
  64. package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
  65. package/UI/Components/Forms/Types/Field.ts +7 -0
  66. package/UI/Components/Forms/Validation.ts +63 -1
  67. package/UI/Components/Input/Input.tsx +31 -3
  68. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
  69. package/UI/Components/Modal/Modal.tsx +37 -4
  70. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
  71. package/UI/Components/Workflow/ArgumentsForm.tsx +194 -7
  72. package/UI/Components/Workflow/Component.tsx +28 -26
  73. package/UI/Components/Workflow/ComponentSettingsModal.tsx +69 -8
  74. package/UI/Components/Workflow/GraphLint.ts +591 -0
  75. package/UI/Components/Workflow/ModelColumnEditor.tsx +396 -0
  76. package/UI/Components/Workflow/ModelSchema.ts +196 -0
  77. package/UI/Components/Workflow/RunForm.tsx +41 -7
  78. package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
  79. package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
  80. package/UI/Components/Workflow/Utils.ts +93 -1
  81. package/UI/Components/Workflow/Workflow.tsx +77 -4
  82. package/UI/Types/LayeredDismissal.ts +31 -0
  83. package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +45 -7
  86. package/Utils/Telemetry/EntityKey.ts +66 -0
  87. package/build/dist/Models/DatabaseModels/TelemetryEntity.js +47 -0
  88. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +1 -1
  89. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js +27 -0
  90. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
  92. package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
  93. package/build/dist/Server/API/UserAPI.js +16 -1
  94. package/build/dist/Server/API/UserAPI.js.map +1 -1
  95. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
  96. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
  97. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
  98. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
  99. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  100. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  101. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
  102. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  103. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +3 -0
  104. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  105. package/build/dist/Server/Services/TelemetryEntityService.js +91 -1
  106. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  107. package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
  108. package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
  109. package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
  110. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
  111. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
  112. package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
  113. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  114. package/build/dist/Types/SerializableObjectDictionary.js +133 -39
  115. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  116. package/build/dist/Types/Telemetry/EntitySource.js +39 -0
  117. package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
  118. package/build/dist/Types/Telemetry/EntityType.js +27 -0
  119. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  120. package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
  121. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
  122. package/build/dist/Types/Workflow/Component.js +21 -0
  123. package/build/dist/Types/Workflow/Component.js.map +1 -1
  124. package/build/dist/Types/Workflow/Components/API.js +35 -0
  125. package/build/dist/Types/Workflow/Components/API.js.map +1 -1
  126. package/build/dist/Types/Workflow/Components/BaseModel.js +7 -3
  127. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  128. package/build/dist/Types/Workflow/StepTrace.js +104 -0
  129. package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
  130. package/build/dist/Types/Workflow/TemplateSyntax.js +326 -0
  131. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
  132. package/build/dist/Types/Workflow/Templates.js +218 -0
  133. package/build/dist/Types/Workflow/Templates.js.map +1 -0
  134. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
  135. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
  136. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
  137. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
  138. package/build/dist/UI/Components/Dictionary/Dictionary.js +22 -6
  139. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  140. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
  141. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  142. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
  143. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  144. package/build/dist/UI/Components/Forms/Validation.js +44 -0
  145. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  146. package/build/dist/UI/Components/Input/Input.js +12 -4
  147. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  148. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
  149. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  150. package/build/dist/UI/Components/Modal/Modal.js +31 -5
  151. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  152. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
  153. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  154. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +140 -11
  155. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  156. package/build/dist/UI/Components/Workflow/Component.js +20 -19
  157. package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
  158. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +31 -3
  159. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  160. package/build/dist/UI/Components/Workflow/GraphLint.js +396 -0
  161. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
  162. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +225 -0
  163. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
  164. package/build/dist/UI/Components/Workflow/ModelSchema.js +118 -0
  165. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
  166. package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
  167. package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
  168. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
  169. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
  170. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
  171. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
  172. package/build/dist/UI/Components/Workflow/Utils.js +73 -1
  173. package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
  174. package/build/dist/UI/Components/Workflow/Workflow.js +51 -6
  175. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  176. package/build/dist/UI/Types/LayeredDismissal.js +21 -0
  177. package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
  178. package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
  179. package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
  181. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +35 -6
  183. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  184. package/build/dist/Utils/Telemetry/EntityKey.js +52 -0
  185. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  186. package/package.json +1 -1
@@ -0,0 +1,689 @@
1
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
2
+ import TelemetryEntity from "../../../Models/DatabaseModels/TelemetryEntity";
3
+ import CloudResource from "../../../Models/DatabaseModels/CloudResource";
4
+ import DockerHost from "../../../Models/DatabaseModels/DockerHost";
5
+ import IoTDevice from "../../../Models/DatabaseModels/IoTDevice";
6
+ import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
7
+ import PodmanHost from "../../../Models/DatabaseModels/PodmanHost";
8
+ import RumApplication from "../../../Models/DatabaseModels/RumApplication";
9
+ import ServerlessFunction from "../../../Models/DatabaseModels/ServerlessFunction";
10
+ import DatabaseService from "../../Services/DatabaseService";
11
+ import CloudResourceService from "../../Services/CloudResourceService";
12
+ import DockerHostService from "../../Services/DockerHostService";
13
+ import IoTDeviceService from "../../Services/IoTDeviceService";
14
+ import NetworkDeviceService from "../../Services/NetworkDeviceService";
15
+ import PodmanHostService from "../../Services/PodmanHostService";
16
+ import RumApplicationService from "../../Services/RumApplicationService";
17
+ import ServerlessFunctionService from "../../Services/ServerlessFunctionService";
18
+ import TelemetryEntityService from "../../Services/TelemetryEntityService";
19
+ import Query from "../../Types/Database/Query";
20
+ import QueryHelper from "../../Types/Database/QueryHelper";
21
+ import Select from "../../Types/Database/Select";
22
+ import QueryDeepPartialEntity from "../../../Types/Database/PartialEntity";
23
+ import ColumnLength from "../../../Types/Database/ColumnLength";
24
+ import Dictionary from "../../../Types/Dictionary";
25
+ import ObjectID from "../../../Types/ObjectID";
26
+ import OneUptimeDate from "../../../Types/Date";
27
+ import { JSONObject } from "../../../Types/JSON";
28
+ import EntitySource from "../../../Types/Telemetry/EntitySource";
29
+ import EntityType from "../../../Types/Telemetry/EntityType";
30
+ import {
31
+ INVENTORY_ENTITY_IDENTITY_ATTRIBUTE,
32
+ keyForInventoryEntity,
33
+ } from "../../../Utils/Telemetry/EntityKey";
34
+ import logger from "../Logger";
35
+
36
+ /*
37
+ * Mirrors OneUptime's inventory tables into the TelemetryEntity registry.
38
+ *
39
+ * The registry began as a projection of OTLP resource attributes, which is
40
+ * why the entity explorer shows a Kubernetes pod but not the switch next to
41
+ * it: half of what OneUptime inventories is collected by pollers (SNMP,
42
+ * cloud APIs, MQTT) that never produce a resource for ingest to extract
43
+ * entities from. Those rows already exist in rich typed tables — they were
44
+ * simply never projected. This module projects them, so "everything we know
45
+ * about the estate" is answerable in one query instead of eight.
46
+ *
47
+ * WHY A RECONCILE RATHER THAN CREATE/DELETE HOOKS ON EACH TABLE
48
+ *
49
+ * Hooks were the obvious approach and are the wrong one here, for three
50
+ * reasons that all bite the same way — a registry that silently disagrees
51
+ * with the inventory it mirrors:
52
+ *
53
+ * 1. Backfill. Hooks only fire on future writes, so every device
54
+ * registered before this shipped would stay invisible forever.
55
+ * 2. Cascade deletes. Deleting a project (or an IoT fleet) removes its
56
+ * devices by FK cascade, which fires no per-row service hook — the
57
+ * mirrored rows would outlive their owners.
58
+ * 3. Seven tables x two hooks is seven chances for a future edit to drop
59
+ * one, and the resulting drift is invisible until someone notices a
60
+ * device missing from the explorer.
61
+ *
62
+ * A periodic full reconcile is level-triggered: whatever the inventory
63
+ * says now is what the registry converges to, no matter how it got there.
64
+ * The cost is latency — a newly registered device appears within one cron
65
+ * period rather than instantly — which is an easy trade for inventory that
66
+ * changes on a human timescale.
67
+ */
68
+
69
+ /** Registry rows are read and written in pages of this size. */
70
+ export const INVENTORY_SYNC_PAGE_SIZE: number = 1000;
71
+
72
+ /**
73
+ * The projection of one inventory row, as the registry stores it. Pure data
74
+ * so the mapping can be tested without a database.
75
+ */
76
+ export interface InventoryRowProjection {
77
+ id: ObjectID;
78
+ projectId: ObjectID;
79
+ displayName: string;
80
+ descriptiveAttributes: Dictionary<string>;
81
+ }
82
+
83
+ /**
84
+ * One inventory table, type-erased at the boundary so the seven specs can
85
+ * live in a single array. `defineInventorySource` below preserves full type
86
+ * safety at each definition site; only the resulting closures are untyped.
87
+ */
88
+ export interface ErasedInventorySource {
89
+ entityType: EntityType;
90
+ /** Model name stored in TelemetryEntity.resourceType. */
91
+ resourceType: string;
92
+ fetchPage(data: {
93
+ skip: number;
94
+ limit: number;
95
+ }): Promise<Array<InventoryRowProjection>>;
96
+ /** Which of these ids still exist in the owning table. */
97
+ findLiveIds(ids: Array<ObjectID>): Promise<Set<string>>;
98
+ }
99
+
100
+ export interface InventorySourceSpec<TModel extends BaseModel> {
101
+ entityType: EntityType;
102
+ resourceType: string;
103
+ service: DatabaseService<TModel>;
104
+ /**
105
+ * Columns needed by `describe`, beyond `_id` / `projectId` / `name` which
106
+ * are always selected.
107
+ */
108
+ select: Select<TModel>;
109
+ /**
110
+ * Restricts what is mirrored — archived rows are excluded so the explorer
111
+ * reflects the live estate rather than its history.
112
+ */
113
+ query: Query<TModel>;
114
+ /** Non-identifying metadata worth showing on the entity. */
115
+ describe(row: TModel): Dictionary<string>;
116
+ }
117
+
118
+ /**
119
+ * Drops empty values so the stored attribute bag contains only things worth
120
+ * displaying — an inventory row with no OS recorded should have no `os.type`
121
+ * key rather than an empty one.
122
+ */
123
+ export function compactAttributes(
124
+ attributes: Dictionary<string | undefined | null>,
125
+ ): Dictionary<string> {
126
+ const out: Dictionary<string> = {};
127
+
128
+ for (const key of Object.keys(attributes)) {
129
+ const value: string | undefined | null = attributes[key];
130
+ if (value === undefined || value === null) {
131
+ continue;
132
+ }
133
+ const text: string = String(value).trim();
134
+ if (text.length === 0) {
135
+ continue;
136
+ }
137
+ out[key] = text;
138
+ }
139
+
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * Builds the registry row for one inventory row. Pure: same inputs, same
145
+ * output, no database — this is the part worth testing exhaustively, since
146
+ * an identity mistake here is what would mint duplicate entities.
147
+ */
148
+ export function buildInventoryEntityModel(data: {
149
+ source: Pick<ErasedInventorySource, "entityType" | "resourceType">;
150
+ row: InventoryRowProjection;
151
+ now: Date;
152
+ }): TelemetryEntity {
153
+ const { source, row, now } = data;
154
+
155
+ const model: TelemetryEntity = new TelemetryEntity();
156
+ model.projectId = row.projectId;
157
+ model.entityType = source.entityType;
158
+ model.entityKey = keyForInventoryEntity(
159
+ row.projectId.toString(),
160
+ source.entityType,
161
+ row.id.toString(),
162
+ );
163
+ model.source = EntitySource.Inventory;
164
+ /*
165
+ * Identity is the owning row's id, not its name: renaming a device in the
166
+ * UI must not orphan its registry row and mint a second one beside it.
167
+ */
168
+ model.identifyingAttributes = {
169
+ [INVENTORY_ENTITY_IDENTITY_ATTRIBUTE]: row.id.toString(),
170
+ } as JSONObject;
171
+ model.displayName = row.displayName.substring(0, ColumnLength.ShortText);
172
+ model.descriptiveAttributes = row.descriptiveAttributes as JSONObject;
173
+ /*
174
+ * The polymorphic pointer back to the rich typed row. Populated here for
175
+ * every mirrored type — previously only `service` entities carried one —
176
+ * so a registry row can be resolved to the page that owns it.
177
+ */
178
+ model.resourceType = source.resourceType;
179
+ model.resourceId = row.id;
180
+ /*
181
+ * Both stamped at mirror time. `lastSeenAt` never advances for these rows
182
+ * (no telemetry heartbeat exists to advance it), which is precisely why
183
+ * PruneStaleEntities excludes EntitySource.Inventory.
184
+ */
185
+ model.firstSeenAt = now;
186
+ model.lastSeenAt = now;
187
+
188
+ return model;
189
+ }
190
+
191
+ /**
192
+ * True when a mirrored row has drifted from its owning inventory row and
193
+ * needs rewriting. Compared field by field rather than blindly updating so a
194
+ * steady-state reconcile issues no writes at all.
195
+ */
196
+ export function inventoryEntityNeedsUpdate(data: {
197
+ existing: TelemetryEntity;
198
+ desired: TelemetryEntity;
199
+ }): boolean {
200
+ const { existing, desired } = data;
201
+
202
+ if ((existing.displayName || "") !== (desired.displayName || "")) {
203
+ return true;
204
+ }
205
+
206
+ if (existing.resourceType !== desired.resourceType) {
207
+ return true;
208
+ }
209
+
210
+ if (existing.resourceId?.toString() !== desired.resourceId?.toString()) {
211
+ return true;
212
+ }
213
+
214
+ return (
215
+ JSON.stringify(existing.descriptiveAttributes || {}) !==
216
+ JSON.stringify(desired.descriptiveAttributes || {})
217
+ );
218
+ }
219
+
220
+ /**
221
+ * The columns a drifted mirror rewrites.
222
+ *
223
+ * Built as a plain shape and cast once, for the same two reasons as
224
+ * TelemetryEntityService.buildDescriptiveUpdate: assigning into
225
+ * `QueryDeepPartialEntity<TelemetryEntity>` directly makes TS instantiate the
226
+ * recursive JSONObject mapping (TS2589), and under this project's
227
+ * `exactOptionalPropertyTypes` an optional model field typed `string |
228
+ * undefined` is not assignable to the update type at all. Keys are therefore
229
+ * assigned only when they have a value rather than being set to undefined —
230
+ * which is also the correct write semantics: absent means "leave it alone",
231
+ * not "null it out".
232
+ */
233
+ function buildMirrorUpdate(
234
+ desired: TelemetryEntity,
235
+ ): QueryDeepPartialEntity<TelemetryEntity> {
236
+ const update: {
237
+ displayName?: string;
238
+ descriptiveAttributes?: JSONObject;
239
+ resourceType?: string;
240
+ resourceId?: ObjectID;
241
+ } = {};
242
+
243
+ if (desired.displayName !== undefined) {
244
+ update.displayName = desired.displayName;
245
+ }
246
+
247
+ if (desired.descriptiveAttributes !== undefined) {
248
+ update.descriptiveAttributes = desired.descriptiveAttributes;
249
+ }
250
+
251
+ if (desired.resourceType !== undefined) {
252
+ update.resourceType = desired.resourceType;
253
+ }
254
+
255
+ if (desired.resourceId !== undefined) {
256
+ update.resourceId = desired.resourceId;
257
+ }
258
+
259
+ return update as unknown as QueryDeepPartialEntity<TelemetryEntity>;
260
+ }
261
+
262
+ function defineInventorySource<TModel extends BaseModel>(
263
+ spec: InventorySourceSpec<TModel>,
264
+ ): ErasedInventorySource {
265
+ return {
266
+ entityType: spec.entityType,
267
+ resourceType: spec.resourceType,
268
+
269
+ fetchPage: async (data: {
270
+ skip: number;
271
+ limit: number;
272
+ }): Promise<Array<InventoryRowProjection>> => {
273
+ const rows: Array<TModel> = await spec.service.findBy({
274
+ query: spec.query,
275
+ select: {
276
+ _id: true,
277
+ projectId: true,
278
+ name: true,
279
+ ...spec.select,
280
+ } as Select<TModel>,
281
+ skip: data.skip,
282
+ limit: data.limit,
283
+ props: { isRoot: true },
284
+ });
285
+
286
+ const projections: Array<InventoryRowProjection> = [];
287
+
288
+ for (const row of rows) {
289
+ const rowRecord: Record<string, unknown> = row as unknown as Record<
290
+ string,
291
+ unknown
292
+ >;
293
+ const projectId: unknown = rowRecord["projectId"];
294
+
295
+ /*
296
+ * A row with no project cannot be mirrored — the entity key folds
297
+ * projectId in, and the registry is tenant-scoped. Should not occur;
298
+ * skipped rather than throwing so one bad row cannot stall the sweep.
299
+ */
300
+ if (!row._id || !projectId) {
301
+ continue;
302
+ }
303
+
304
+ const name: unknown = rowRecord["name"];
305
+ const displayName: string = String(name || "").trim();
306
+
307
+ projections.push({
308
+ id: new ObjectID(row._id.toString()),
309
+ projectId: new ObjectID(String(projectId)),
310
+ displayName:
311
+ displayName.length > 0 ? displayName : row._id.toString(),
312
+ descriptiveAttributes: spec.describe(row),
313
+ });
314
+ }
315
+
316
+ return projections;
317
+ },
318
+
319
+ findLiveIds: async (ids: Array<ObjectID>): Promise<Set<string>> => {
320
+ if (ids.length === 0) {
321
+ return new Set<string>();
322
+ }
323
+
324
+ /*
325
+ * Deliberately unfiltered by `spec.query`: this answers "does the row
326
+ * still exist", and an archived row still exists. Reusing the mirror
327
+ * query here would make archiving delete the registry row rather than
328
+ * merely stop refreshing it, which is a different (and destructive)
329
+ * behaviour from the one intended.
330
+ */
331
+ const rows: Array<TModel> = await spec.service.findBy({
332
+ query: {
333
+ _id: QueryHelper.any(
334
+ ids.map((id: ObjectID) => {
335
+ return id.toString();
336
+ }),
337
+ ),
338
+ } as Query<TModel>,
339
+ select: { _id: true } as Select<TModel>,
340
+ skip: 0,
341
+ limit: ids.length,
342
+ props: { isRoot: true },
343
+ });
344
+
345
+ return new Set<string>(
346
+ rows.map((row: TModel) => {
347
+ return row._id!.toString();
348
+ }),
349
+ );
350
+ },
351
+ };
352
+ }
353
+
354
+ /*
355
+ * Archived rows are excluded from every source that has the concept: the
356
+ * explorer is a view of the live estate. IoTDevice and NetworkDevice have no
357
+ * archive flag, so their queries are unrestricted.
358
+ */
359
+ export const INVENTORY_SOURCES: ReadonlyArray<ErasedInventorySource> = [
360
+ defineInventorySource<NetworkDevice>({
361
+ entityType: EntityType.NetworkDevice,
362
+ resourceType: "NetworkDevice",
363
+ service: NetworkDeviceService,
364
+ select: { hostname: true },
365
+ query: {},
366
+ describe: (row: NetworkDevice): Dictionary<string> => {
367
+ return compactAttributes({ "net.device.hostname": row.hostname });
368
+ },
369
+ }),
370
+ defineInventorySource<CloudResource>({
371
+ entityType: EntityType.CloudResource,
372
+ resourceType: "CloudResource",
373
+ service: CloudResourceService,
374
+ select: {
375
+ resourceIdentifier: true,
376
+ cloudProvider: true,
377
+ cloudRegion: true,
378
+ cloudAccountId: true,
379
+ },
380
+ query: { isArchived: false },
381
+ describe: (row: CloudResource): Dictionary<string> => {
382
+ return compactAttributes({
383
+ "cloud.resource.id": row.resourceIdentifier,
384
+ "cloud.provider": row.cloudProvider,
385
+ "cloud.region": row.cloudRegion,
386
+ "cloud.account.id": row.cloudAccountId,
387
+ });
388
+ },
389
+ }),
390
+ defineInventorySource<ServerlessFunction>({
391
+ entityType: EntityType.ServerlessFunction,
392
+ resourceType: "ServerlessFunction",
393
+ service: ServerlessFunctionService,
394
+ select: {
395
+ functionIdentifier: true,
396
+ cloudProvider: true,
397
+ cloudRegion: true,
398
+ runtimeName: true,
399
+ },
400
+ query: { isArchived: false },
401
+ describe: (row: ServerlessFunction): Dictionary<string> => {
402
+ return compactAttributes({
403
+ "faas.name": row.functionIdentifier,
404
+ "cloud.provider": row.cloudProvider,
405
+ "cloud.region": row.cloudRegion,
406
+ "process.runtime.name": row.runtimeName,
407
+ });
408
+ },
409
+ }),
410
+ defineInventorySource<RumApplication>({
411
+ entityType: EntityType.RumApplication,
412
+ resourceType: "RumApplication",
413
+ service: RumApplicationService,
414
+ select: { appIdentifier: true, clientType: true, sdkLanguage: true },
415
+ query: { isArchived: false },
416
+ describe: (row: RumApplication): Dictionary<string> => {
417
+ return compactAttributes({
418
+ "rum.app.id": row.appIdentifier,
419
+ "rum.client.type": row.clientType,
420
+ "telemetry.sdk.language": row.sdkLanguage,
421
+ });
422
+ },
423
+ }),
424
+ defineInventorySource<IoTDevice>({
425
+ entityType: EntityType.IoTDevice,
426
+ resourceType: "IoTDevice",
427
+ service: IoTDeviceService,
428
+ select: { externalId: true, deviceType: true, firmwareVersion: true },
429
+ query: {},
430
+ describe: (row: IoTDevice): Dictionary<string> => {
431
+ return compactAttributes({
432
+ "device.id": row.externalId,
433
+ "device.type": row.deviceType,
434
+ "device.firmware.version": row.firmwareVersion,
435
+ });
436
+ },
437
+ }),
438
+ defineInventorySource<DockerHost>({
439
+ entityType: EntityType.DockerHost,
440
+ resourceType: "DockerHost",
441
+ service: DockerHostService,
442
+ select: { hostIdentifier: true, osType: true, osVersion: true },
443
+ query: { isArchived: false },
444
+ describe: (row: DockerHost): Dictionary<string> => {
445
+ return compactAttributes({
446
+ "host.name": row.hostIdentifier,
447
+ "os.type": row.osType,
448
+ "os.version": row.osVersion,
449
+ });
450
+ },
451
+ }),
452
+ defineInventorySource<PodmanHost>({
453
+ entityType: EntityType.PodmanHost,
454
+ resourceType: "PodmanHost",
455
+ service: PodmanHostService,
456
+ select: { hostIdentifier: true, osType: true, osVersion: true },
457
+ query: { isArchived: false },
458
+ describe: (row: PodmanHost): Dictionary<string> => {
459
+ return compactAttributes({
460
+ "host.name": row.hostIdentifier,
461
+ "os.type": row.osType,
462
+ "os.version": row.osVersion,
463
+ });
464
+ },
465
+ }),
466
+ ];
467
+
468
+ export interface InventorySyncResult {
469
+ created: number;
470
+ updated: number;
471
+ deleted: number;
472
+ }
473
+
474
+ /**
475
+ * Forward pass: every live inventory row gets a registry row, and one that
476
+ * has drifted (renamed, re-tagged) is rewritten.
477
+ */
478
+ async function mirrorRows(
479
+ source: ErasedInventorySource,
480
+ ): Promise<{ created: number; updated: number }> {
481
+ let created: number = 0;
482
+ let updated: number = 0;
483
+ let skip: number = 0;
484
+
485
+ for (;;) {
486
+ const rows: Array<InventoryRowProjection> = await source.fetchPage({
487
+ skip,
488
+ limit: INVENTORY_SYNC_PAGE_SIZE,
489
+ });
490
+
491
+ if (rows.length === 0) {
492
+ break;
493
+ }
494
+
495
+ const now: Date = OneUptimeDate.getCurrentDate();
496
+
497
+ const desiredByKey: Map<string, TelemetryEntity> = new Map<
498
+ string,
499
+ TelemetryEntity
500
+ >();
501
+
502
+ for (const row of rows) {
503
+ const model: TelemetryEntity = buildInventoryEntityModel({
504
+ source,
505
+ row,
506
+ now,
507
+ });
508
+ desiredByKey.set(model.entityKey!, model);
509
+ }
510
+
511
+ const existingRows: Array<TelemetryEntity> =
512
+ await TelemetryEntityService.findBy({
513
+ query: {
514
+ entityType: source.entityType,
515
+ entityKey: QueryHelper.any(Array.from(desiredByKey.keys())),
516
+ },
517
+ select: {
518
+ _id: true,
519
+ entityKey: true,
520
+ displayName: true,
521
+ descriptiveAttributes: true,
522
+ resourceType: true,
523
+ resourceId: true,
524
+ },
525
+ skip: 0,
526
+ limit: desiredByKey.size,
527
+ props: { isRoot: true },
528
+ });
529
+
530
+ const existingByKey: Map<string, TelemetryEntity> = new Map<
531
+ string,
532
+ TelemetryEntity
533
+ >();
534
+ for (const existing of existingRows) {
535
+ existingByKey.set(existing.entityKey!, existing);
536
+ }
537
+
538
+ for (const [key, desired] of desiredByKey.entries()) {
539
+ const existing: TelemetryEntity | undefined = existingByKey.get(key);
540
+
541
+ try {
542
+ if (!existing) {
543
+ await TelemetryEntityService.create({
544
+ data: desired,
545
+ props: { isRoot: true },
546
+ });
547
+ created++;
548
+ continue;
549
+ }
550
+
551
+ if (inventoryEntityNeedsUpdate({ existing, desired })) {
552
+ await TelemetryEntityService.updateOneById({
553
+ id: existing.id!,
554
+ data: buildMirrorUpdate(desired),
555
+ props: { isRoot: true },
556
+ });
557
+ updated++;
558
+ }
559
+ } catch (err) {
560
+ /*
561
+ * One unmirrorable row must not stall the rest of the estate. The
562
+ * next run retries it, so a transient failure self-heals.
563
+ */
564
+ logger.error(
565
+ `InventoryEntityRegistry: failed to mirror ${source.resourceType} row for entity key ${key}:`,
566
+ );
567
+ logger.error(err as Error);
568
+ }
569
+ }
570
+
571
+ skip += rows.length;
572
+
573
+ if (rows.length < INVENTORY_SYNC_PAGE_SIZE) {
574
+ break;
575
+ }
576
+ }
577
+
578
+ return { created, updated };
579
+ }
580
+
581
+ /**
582
+ * Reverse pass: a mirrored row whose owning inventory row is gone is
583
+ * deleted. This is what catches cascade deletes, which fire no service hook.
584
+ */
585
+ async function removeOrphans(source: ErasedInventorySource): Promise<number> {
586
+ const orphanIds: Array<ObjectID> = [];
587
+ let skip: number = 0;
588
+
589
+ /*
590
+ * Collected across a read-only pass and deleted afterwards. Deleting while
591
+ * paging would shift subsequent offsets and skip rows.
592
+ */
593
+ for (;;) {
594
+ const rows: Array<TelemetryEntity> = await TelemetryEntityService.findBy({
595
+ query: {
596
+ entityType: source.entityType,
597
+ source: EntitySource.Inventory,
598
+ },
599
+ select: { _id: true, resourceId: true },
600
+ skip,
601
+ limit: INVENTORY_SYNC_PAGE_SIZE,
602
+ props: { isRoot: true },
603
+ });
604
+
605
+ if (rows.length === 0) {
606
+ break;
607
+ }
608
+
609
+ const resourceIds: Array<ObjectID> = [];
610
+ for (const row of rows) {
611
+ if (row.resourceId) {
612
+ resourceIds.push(row.resourceId);
613
+ }
614
+ }
615
+
616
+ const liveIds: Set<string> = await source.findLiveIds(resourceIds);
617
+
618
+ for (const row of rows) {
619
+ /*
620
+ * A mirrored row with no pointer cannot be matched back to anything,
621
+ * so it is orphaned by definition.
622
+ */
623
+ if (!row.resourceId || !liveIds.has(row.resourceId.toString())) {
624
+ orphanIds.push(row.id!);
625
+ }
626
+ }
627
+
628
+ skip += rows.length;
629
+
630
+ if (rows.length < INVENTORY_SYNC_PAGE_SIZE) {
631
+ break;
632
+ }
633
+ }
634
+
635
+ if (orphanIds.length === 0) {
636
+ return 0;
637
+ }
638
+
639
+ await TelemetryEntityService.hardDeleteBy({
640
+ query: {
641
+ _id: QueryHelper.any(
642
+ orphanIds.map((id: ObjectID) => {
643
+ return id.toString();
644
+ }),
645
+ ),
646
+ // Re-asserted so a bug upstream can never widen this into a broad delete.
647
+ source: EntitySource.Inventory,
648
+ },
649
+ limit: orphanIds.length,
650
+ skip: 0,
651
+ props: { isRoot: true },
652
+ });
653
+
654
+ return orphanIds.length;
655
+ }
656
+
657
+ /** Reconciles one inventory table into the registry. */
658
+ export async function syncInventorySource(
659
+ source: ErasedInventorySource,
660
+ ): Promise<InventorySyncResult> {
661
+ const { created, updated } = await mirrorRows(source);
662
+ const deleted: number = await removeOrphans(source);
663
+ return { created, updated, deleted };
664
+ }
665
+
666
+ /**
667
+ * Reconciles every inventory table. Each source is isolated: a failure in
668
+ * one leaves the others to complete, because a broken cloud integration
669
+ * should not stop network devices being mirrored.
670
+ */
671
+ export async function syncAllInventorySources(): Promise<InventorySyncResult> {
672
+ const total: InventorySyncResult = { created: 0, updated: 0, deleted: 0 };
673
+
674
+ for (const source of INVENTORY_SOURCES) {
675
+ try {
676
+ const result: InventorySyncResult = await syncInventorySource(source);
677
+ total.created += result.created;
678
+ total.updated += result.updated;
679
+ total.deleted += result.deleted;
680
+ } catch (err) {
681
+ logger.error(
682
+ `InventoryEntityRegistry: sync failed for ${source.resourceType}:`,
683
+ );
684
+ logger.error(err as Error);
685
+ }
686
+ }
687
+
688
+ return total;
689
+ }