@oneuptime/common 14.0.5 → 14.0.7

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 (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -0,0 +1,345 @@
1
+ import InventoryItemService from "../../../Server/Services/InventoryItemService";
2
+ import {
3
+ INVENTORY_OVERVIEW_GROUP_BY,
4
+ INVENTORY_OVERVIEW_SELECT,
5
+ InventoryOverviewCounts,
6
+ readInventoryOverviewGroups,
7
+ } from "../../../Server/Utils/Inventory/InventoryOverviewAggregation";
8
+ import AggregateBy, {
9
+ AggregateColumn,
10
+ AggregateRow,
11
+ } from "../../../Server/Types/Database/AggregateBy";
12
+ import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
13
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
14
+ import ObjectID from "../../../Types/ObjectID";
15
+ import EntitySource from "../../../Types/Telemetry/EntitySource";
16
+ import EntityType from "../../../Types/Telemetry/EntityType";
17
+ import {
18
+ InventoryLiveness,
19
+ getInventoryLivenessSql,
20
+ } from "../../../Types/Telemetry/InventoryLiveness";
21
+ import { afterEach, describe, expect, jest, test } from "@jest/globals";
22
+ import type { SpyInstance } from "jest-mock";
23
+ import { getMetadataArgsStorage } from "typeorm";
24
+ import { ColumnMetadataArgs } from "typeorm/metadata-args/ColumnMetadataArgs";
25
+
26
+ /*
27
+ * The Inventory Overview's tiles and breakdown used to be a fold in the
28
+ * browser over one list read capped at ten thousand rows, so a bigger project
29
+ * saw numbers for only part of its estate and nothing said so. They are
30
+ * counted in Postgres now. These pin the read itself — what it scopes to, and
31
+ * that nothing caps it — and the fold of its groups back into the numbers the
32
+ * page renders. InventoryOverviewPostgres.test.ts runs the same columns
33
+ * against a real database.
34
+ */
35
+
36
+ const projectId: ObjectID = ObjectID.generate();
37
+ const props: DatabaseCommonInteractionProps = {
38
+ tenantId: projectId,
39
+ userId: ObjectID.generate(),
40
+ };
41
+
42
+ function columnFor(alias: string): AggregateColumn {
43
+ const column: AggregateColumn | undefined = INVENTORY_OVERVIEW_SELECT.find(
44
+ (candidate: AggregateColumn): boolean => {
45
+ return candidate.alias === alias;
46
+ },
47
+ );
48
+
49
+ if (!column) {
50
+ throw new Error(`No overview column is aliased "${alias}".`);
51
+ }
52
+
53
+ return column;
54
+ }
55
+
56
+ describe("getOverviewCounts", () => {
57
+ afterEach(() => {
58
+ jest.restoreAllMocks();
59
+ });
60
+
61
+ type AggregateBySpy = SpyInstance<typeof InventoryItemService.aggregateBy>;
62
+
63
+ function spyOnAggregate(rows: Array<AggregateRow>): AggregateBySpy {
64
+ return jest
65
+ .spyOn(InventoryItemService, "aggregateBy")
66
+ .mockResolvedValue(rows);
67
+ }
68
+
69
+ function aggregateCall(spy: AggregateBySpy): AggregateBy<InventoryItem> {
70
+ expect(spy).toHaveBeenCalledTimes(1);
71
+ return spy.mock.calls[0]![0];
72
+ }
73
+
74
+ test("counts the project's unarchived estate, as the caller", async () => {
75
+ /*
76
+ * Archived rows are left out because every tile drills into the live
77
+ * Items list, which leaves them out too — counting them would put 100 on
78
+ * a tile that opens a list of 95. The caller's props go through so the
79
+ * count runs the same permission pipeline the list does.
80
+ */
81
+ const spy: AggregateBySpy = spyOnAggregate([]);
82
+
83
+ await InventoryItemService.getOverviewCounts({ projectId, props });
84
+
85
+ const call: AggregateBy<InventoryItem> = aggregateCall(spy);
86
+
87
+ expect(call.query).toEqual({ projectId: projectId, isArchived: false });
88
+ expect(call.props).toBe(props);
89
+ });
90
+
91
+ test("is one statement grouped by type, so the tiles and the breakdown are one snapshot", async () => {
92
+ const spy: AggregateBySpy = spyOnAggregate([]);
93
+
94
+ await InventoryItemService.getOverviewCounts({ projectId, props });
95
+
96
+ const call: AggregateBy<InventoryItem> = aggregateCall(spy);
97
+
98
+ expect(call.groupBy).toEqual([
99
+ { expression: `"InventoryItem"."entityType"`, alias: "entityType" },
100
+ ]);
101
+ expect(call.select).toBe(INVENTORY_OVERVIEW_SELECT);
102
+ });
103
+
104
+ test("is not capped — a limit on the groups would cap the totals summed from them", async () => {
105
+ const spy: AggregateBySpy = spyOnAggregate([]);
106
+
107
+ await InventoryItemService.getOverviewCounts({ projectId, props });
108
+
109
+ expect(aggregateCall(spy).limit).toBeUndefined();
110
+ });
111
+
112
+ test("returns the fold of whatever the database grouped", async () => {
113
+ const rows: Array<AggregateRow> = [
114
+ {
115
+ entityType: EntityType.Service,
116
+ itemCount: "25000",
117
+ discoveredCount: "24000",
118
+ mirroredCount: "0",
119
+ manualCount: "1000",
120
+ staleCount: "300",
121
+ },
122
+ ];
123
+ spyOnAggregate(rows);
124
+
125
+ const counts: InventoryOverviewCounts =
126
+ await InventoryItemService.getOverviewCounts({ projectId, props });
127
+
128
+ expect(counts).toEqual(readInventoryOverviewGroups(rows));
129
+ // Past the old ten-thousand-row cap, which is the point.
130
+ expect(counts.total).toBe(25000);
131
+ });
132
+
133
+ test("a database failure reaches the caller rather than reading as an empty estate", async () => {
134
+ jest
135
+ .spyOn(InventoryItemService, "aggregateBy")
136
+ .mockRejectedValue(new Error("connection reset"));
137
+
138
+ await expect(
139
+ InventoryItemService.getOverviewCounts({ projectId, props }),
140
+ ).rejects.toThrow("connection reset");
141
+ });
142
+ });
143
+
144
+ describe("the overview columns", () => {
145
+ test("group by the entity type and nothing else", () => {
146
+ expect(INVENTORY_OVERVIEW_GROUP_BY).toEqual([
147
+ { expression: `"InventoryItem"."entityType"`, alias: "entityType" },
148
+ ]);
149
+ });
150
+
151
+ test("count every row for the total", () => {
152
+ expect(columnFor("itemCount").expression).toBe("COUNT(*)");
153
+ });
154
+
155
+ test.each([
156
+ ["discoveredCount", EntitySource.Discovered],
157
+ ["mirroredCount", EntitySource.Inventory],
158
+ ["manualCount", EntitySource.Manual],
159
+ ])("%s filters on source %s", (alias: string, source: EntitySource) => {
160
+ expect(columnFor(alias).expression).toBe(
161
+ `COUNT(*) FILTER (WHERE "InventoryItem"."source" = '${source}')`,
162
+ );
163
+ });
164
+
165
+ test("the stale count applies the Stale status facet's own SQL", () => {
166
+ /*
167
+ * "Gone Quiet" drills into the list filtered on `inventoryStatus =
168
+ * stale`. Writing a second staleness predicate here — say `lastSeenAt <
169
+ * now() - 1 day` — would disagree with that list at the boundary minute
170
+ * and on never-seen and non-discovered rows. So the count uses the very
171
+ * expression the virtual column is built from.
172
+ */
173
+ expect(columnFor("staleCount").expression).toBe(
174
+ `COUNT(*) FILTER (WHERE (${getInventoryLivenessSql(`"InventoryItem"`)}) = '${InventoryLiveness.Stale}')`,
175
+ );
176
+ });
177
+
178
+ test("the inventoryStatus virtual column is built from the same SQL", () => {
179
+ const column: ColumnMetadataArgs | undefined =
180
+ getMetadataArgsStorage().columns.find(
181
+ (candidate: ColumnMetadataArgs): boolean => {
182
+ return (
183
+ candidate.target === InventoryItem &&
184
+ candidate.propertyName === "inventoryStatus"
185
+ );
186
+ },
187
+ );
188
+
189
+ expect(column?.mode).toBe("virtual-property");
190
+
191
+ const query: ((alias: string) => string) | undefined = (
192
+ column?.options as { query?: (alias: string) => string } | undefined
193
+ )?.query;
194
+
195
+ expect(query).toBeDefined();
196
+ expect(query!(`"InventoryItem"`)).toBe(
197
+ getInventoryLivenessSql(`"InventoryItem"`),
198
+ );
199
+ });
200
+
201
+ test("aliases are unique, plain identifiers", () => {
202
+ const aliases: Array<string> = [
203
+ ...INVENTORY_OVERVIEW_GROUP_BY,
204
+ ...INVENTORY_OVERVIEW_SELECT,
205
+ ].map((column: AggregateColumn): string => {
206
+ return column.alias;
207
+ });
208
+
209
+ expect(new Set(aliases).size).toBe(aliases.length);
210
+
211
+ for (const alias of aliases) {
212
+ expect(alias).toMatch(/^[a-zA-Z][a-zA-Z0-9_]*$/);
213
+ }
214
+ });
215
+
216
+ test("no expression carries a statement separator", () => {
217
+ // aggregateBy rejects these; this fails at the definition instead of at runtime.
218
+ for (const column of INVENTORY_OVERVIEW_SELECT) {
219
+ expect(column.expression).not.toContain(";");
220
+ }
221
+ });
222
+ });
223
+
224
+ describe("readInventoryOverviewGroups", () => {
225
+ test("no groups is an empty estate", () => {
226
+ expect(readInventoryOverviewGroups([])).toEqual({
227
+ total: 0,
228
+ discovered: 0,
229
+ mirrored: 0,
230
+ manual: 0,
231
+ stale: 0,
232
+ countsByType: {},
233
+ });
234
+ });
235
+
236
+ test("sums every group into the tiles and keys the groups by type", () => {
237
+ const counts: InventoryOverviewCounts = readInventoryOverviewGroups([
238
+ {
239
+ entityType: EntityType.Service,
240
+ itemCount: "7",
241
+ discoveredCount: "5",
242
+ mirroredCount: "0",
243
+ manualCount: "2",
244
+ staleCount: "3",
245
+ },
246
+ {
247
+ entityType: EntityType.Host,
248
+ itemCount: "4",
249
+ discoveredCount: "1",
250
+ mirroredCount: "3",
251
+ manualCount: "0",
252
+ staleCount: "1",
253
+ },
254
+ ]);
255
+
256
+ expect(counts).toEqual({
257
+ total: 11,
258
+ discovered: 6,
259
+ mirrored: 3,
260
+ manual: 2,
261
+ stale: 4,
262
+ countsByType: {
263
+ [EntityType.Service]: 7,
264
+ [EntityType.Host]: 4,
265
+ },
266
+ });
267
+ });
268
+
269
+ test("reads Postgres' bigint strings as numbers, not by concatenating them", () => {
270
+ const counts: InventoryOverviewCounts = readInventoryOverviewGroups([
271
+ { entityType: EntityType.Service, itemCount: "10" },
272
+ { entityType: EntityType.Host, itemCount: "9" },
273
+ ]);
274
+
275
+ expect(counts.total).toBe(19);
276
+ });
277
+
278
+ test("the breakdown adds up to the total whenever every group has a type", () => {
279
+ const counts: InventoryOverviewCounts = readInventoryOverviewGroups([
280
+ { entityType: EntityType.Service, itemCount: "12" },
281
+ { entityType: EntityType.Host, itemCount: "40" },
282
+ { entityType: "some.future.type", itemCount: "3" },
283
+ ]);
284
+
285
+ const breakdownTotal: number = Object.values(counts.countsByType).reduce(
286
+ (sum: number, count: number): number => {
287
+ return sum + count;
288
+ },
289
+ 0,
290
+ );
291
+
292
+ expect(breakdownTotal).toBe(counts.total);
293
+ // A type this build has no descriptor for is kept, for the "Other" group.
294
+ expect(counts.countsByType["some.future.type"]).toBe(3);
295
+ });
296
+
297
+ test("a group with no type counts towards every tile but has no breakdown row", () => {
298
+ const counts: InventoryOverviewCounts = readInventoryOverviewGroups([
299
+ {
300
+ entityType: null,
301
+ itemCount: "2",
302
+ discoveredCount: "2",
303
+ staleCount: "1",
304
+ },
305
+ { entityType: "", itemCount: "1", manualCount: "1" },
306
+ ]);
307
+
308
+ expect(counts.total).toBe(3);
309
+ expect(counts.discovered).toBe(2);
310
+ expect(counts.manual).toBe(1);
311
+ expect(counts.stale).toBe(1);
312
+ expect(counts.countsByType).toEqual({});
313
+ });
314
+
315
+ test("rows of an unrecognised source count in the total and in no source tile", () => {
316
+ // The database only puts a row in a source bucket its FILTER names.
317
+ const counts: InventoryOverviewCounts = readInventoryOverviewGroups([
318
+ {
319
+ entityType: EntityType.Service,
320
+ itemCount: "5",
321
+ discoveredCount: "3",
322
+ mirroredCount: "0",
323
+ manualCount: "0",
324
+ staleCount: "0",
325
+ },
326
+ ]);
327
+
328
+ expect(counts.total).toBe(5);
329
+ expect(counts.discovered + counts.mirrored + counts.manual).toBe(3);
330
+ });
331
+
332
+ test("missing and unparseable columns read as zero", () => {
333
+ const counts: InventoryOverviewCounts = readInventoryOverviewGroups([
334
+ {
335
+ entityType: EntityType.Service,
336
+ itemCount: "not a number",
337
+ discoveredCount: null,
338
+ },
339
+ ]);
340
+
341
+ expect(counts.total).toBe(0);
342
+ expect(counts.discovered).toBe(0);
343
+ expect(counts.countsByType).toEqual({});
344
+ });
345
+ });
@@ -0,0 +1,254 @@
1
+ import Entities from "../../../Models/DatabaseModels/Index";
2
+ import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
3
+ import {
4
+ INVENTORY_OVERVIEW_GROUP_BY,
5
+ INVENTORY_OVERVIEW_SELECT,
6
+ InventoryOverviewCounts,
7
+ readInventoryOverviewGroups,
8
+ } from "../../../Server/Utils/Inventory/InventoryOverviewAggregation";
9
+ import {
10
+ AggregateColumn,
11
+ AggregateRow,
12
+ } from "../../../Server/Types/Database/AggregateBy";
13
+ import QueryUtil from "../../../Server/Types/Database/QueryUtil";
14
+ import Includes from "../../../Types/BaseDatabase/Includes";
15
+ import Query from "../../../Types/BaseDatabase/Query";
16
+ import ObjectID from "../../../Types/ObjectID";
17
+ import EntitySource from "../../../Types/Telemetry/EntitySource";
18
+ import EntityType from "../../../Types/Telemetry/EntityType";
19
+ import { InventoryLiveness } from "../../../Types/Telemetry/InventoryLiveness";
20
+ import {
21
+ INVENTORY_SUMMARY_TILES,
22
+ InventorySummaryTile,
23
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Inventory/InventorySummaryTiles";
24
+ import {
25
+ DataSource,
26
+ FindOptionsWhere,
27
+ QueryRunner,
28
+ SelectQueryBuilder,
29
+ } from "typeorm";
30
+
31
+ /*
32
+ * The Inventory Overview's counts, run against Postgres and checked against
33
+ * the lists they drill into.
34
+ *
35
+ * Every tile opens the Items list narrowed by its scope — a source facet, or
36
+ * the source facet plus the Stale status facet — and the number on the tile
37
+ * has to be the number of rows that list shows. The unit tests pin the SQL
38
+ * text; only a real database can say the two queries select the same rows,
39
+ * so each tile's count is compared here with the list query its scope
40
+ * produces, over an estate built to sit on every boundary.
41
+ *
42
+ * Opt in with RUN_POSTGRES_INVENTORY_FACET_TESTS=true and config.env loaded,
43
+ * like InventoryStatusPostgres.test.ts. Tables live in a unique temporary
44
+ * schema; production rows are never read or changed. A transaction freezes
45
+ * CURRENT_TIMESTAMP so the boundary rows stay on their boundary.
46
+ */
47
+ const describePostgres: typeof describe =
48
+ process.env["RUN_POSTGRES_INVENTORY_FACET_TESTS"] === "true"
49
+ ? describe
50
+ : describe.skip;
51
+
52
+ describePostgres("inventory overview counts against Postgres", () => {
53
+ const schema: string = `inventory_overview_${ObjectID.generate().toString().replace(/-/g, "")}`;
54
+ const projectId: ObjectID = ObjectID.generate();
55
+ const otherProjectId: ObjectID = ObjectID.generate();
56
+ let database: DataSource;
57
+ let runner: QueryRunner;
58
+ let now: Date;
59
+ let counts: InventoryOverviewCounts;
60
+
61
+ const MINUTE: number = 60_000;
62
+
63
+ beforeAll(async () => {
64
+ database = new DataSource({
65
+ type: "postgres",
66
+ host: process.env["INVENTORY_FACET_TEST_DATABASE_HOST"] || "localhost",
67
+ port: Number(process.env["INVENTORY_FACET_TEST_DATABASE_PORT"] || "5400"),
68
+ username: process.env["DATABASE_USERNAME"] || "postgres",
69
+ password: process.env["DATABASE_PASSWORD"] || "password",
70
+ database: process.env["DATABASE_NAME"] || "oneuptimedb",
71
+ entities: Entities,
72
+ schema,
73
+ synchronize: false,
74
+ extra: { options: `-c search_path=${schema},public` },
75
+ });
76
+ await database.initialize();
77
+ await database.query(`CREATE SCHEMA "${schema}"`);
78
+ await database.query(`CREATE TABLE "${schema}"."InventoryItem" (
79
+ "_id" uuid PRIMARY KEY,
80
+ "projectId" uuid NOT NULL,
81
+ "displayName" varchar NOT NULL,
82
+ "entityType" varchar NOT NULL,
83
+ "source" varchar,
84
+ "firstSeenAt" timestamptz,
85
+ "lastSeenAt" timestamptz,
86
+ "isArchived" boolean NOT NULL DEFAULT false,
87
+ "deletedAt" timestamptz
88
+ )`);
89
+ runner = database.createQueryRunner();
90
+ await runner.connect();
91
+ await runner.startTransaction();
92
+ now = (await runner.query("SELECT CURRENT_TIMESTAMP AS now"))[0].now;
93
+
94
+ // Discovered, on each side of both liveness boundaries.
95
+ await seed(EntityType.Service, EntitySource.Discovered, 0);
96
+ await seed(EntityType.Service, EntitySource.Discovered, 31 * MINUTE - 1);
97
+ await seed(EntityType.Service, EntitySource.Discovered, 1441 * MINUTE - 1);
98
+ await seed(EntityType.Service, EntitySource.Discovered, 1441 * MINUTE);
99
+ await seed(EntityType.Host, EntitySource.Discovered, 3 * 1440 * MINUTE);
100
+ await seed(EntityType.Host, EntitySource.Discovered, null);
101
+ // Ancient timestamps that must never read as stale.
102
+ await seed(EntityType.Host, EntitySource.Inventory, 365 * 1440 * MINUTE);
103
+ await seed(EntityType.Host, EntitySource.Inventory, null);
104
+ await seed(
105
+ EntityType.ExternalService,
106
+ EntitySource.Manual,
107
+ 90 * 1440 * MINUTE,
108
+ );
109
+ await seed(EntityType.Service, "some-future-source", 3 * 1440 * MINUTE);
110
+ await seed("some.future.type", EntitySource.Discovered, 3 * 1440 * MINUTE);
111
+ // Outside the Overview: archived, and another project's.
112
+ await seed(EntityType.Service, EntitySource.Discovered, 3 * 1440 * MINUTE, {
113
+ archived: true,
114
+ });
115
+ await seed(EntityType.Service, EntitySource.Manual, 0, {
116
+ projectId: otherProjectId,
117
+ });
118
+
119
+ const scope: SelectQueryBuilder<InventoryItem> = runner.manager
120
+ .createQueryBuilder(InventoryItem, "InventoryItem")
121
+ .setFindOptions({ where: where() });
122
+
123
+ // The same select/group shape DatabaseService.aggregateBy builds.
124
+ const columns: Array<AggregateColumn> = [
125
+ ...INVENTORY_OVERVIEW_GROUP_BY,
126
+ ...INVENTORY_OVERVIEW_SELECT,
127
+ ];
128
+ scope.select(columns[0]!.expression, columns[0]!.alias);
129
+ for (const column of columns.slice(1)) {
130
+ scope.addSelect(column.expression, column.alias);
131
+ }
132
+ for (const column of INVENTORY_OVERVIEW_GROUP_BY) {
133
+ scope.addGroupBy(column.expression);
134
+ }
135
+
136
+ counts = readInventoryOverviewGroups(
137
+ (await scope.getRawMany()) as Array<AggregateRow>,
138
+ );
139
+ });
140
+
141
+ afterAll(async () => {
142
+ if (runner) {
143
+ if (runner.isTransactionActive) {
144
+ await runner.rollbackTransaction();
145
+ }
146
+ await runner.release();
147
+ }
148
+ if (database?.isInitialized) {
149
+ await database.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
150
+ await database.destroy();
151
+ }
152
+ });
153
+
154
+ async function seed(
155
+ entityType: string,
156
+ source: string | null,
157
+ age: number | null,
158
+ options: { archived?: boolean; projectId?: ObjectID } = {},
159
+ ): Promise<void> {
160
+ await runner.query(
161
+ `INSERT INTO "${schema}"."InventoryItem" ("_id", "projectId", "displayName", "entityType", "source", "firstSeenAt", "lastSeenAt", "isArchived") VALUES ($1, $2, $3, $4, $5, $6, $6, $7)`,
162
+ [
163
+ ObjectID.generate().toString(),
164
+ (options.projectId || projectId).toString(),
165
+ `${entityType} ${source}`,
166
+ entityType,
167
+ source,
168
+ age === null ? null : new Date(now.getTime() - age),
169
+ Boolean(options.archived),
170
+ ],
171
+ );
172
+ }
173
+
174
+ // The live Items list's base query, which the Overview's count shares.
175
+ function where(
176
+ query: Query<InventoryItem> = {},
177
+ ): FindOptionsWhere<InventoryItem> {
178
+ return QueryUtil.serializeQuery(InventoryItem, {
179
+ projectId,
180
+ isArchived: false,
181
+ ...query,
182
+ }) as unknown as FindOptionsWhere<InventoryItem>;
183
+ }
184
+
185
+ async function listCount(query: Query<InventoryItem>): Promise<number> {
186
+ return runner.manager
187
+ .getRepository(InventoryItem)
188
+ .count({ where: where(query) });
189
+ }
190
+
191
+ // The query a tile's drill-down lands on: its scope, as the Items page's facets.
192
+ function drillDownQuery(tile: InventorySummaryTile): Query<InventoryItem> {
193
+ const query: Query<InventoryItem> = {};
194
+
195
+ if (tile.scope.entityType) {
196
+ query.entityType = new Includes([tile.scope.entityType]);
197
+ }
198
+
199
+ if (tile.scope.source) {
200
+ query.source = new Includes([tile.scope.source]);
201
+ }
202
+
203
+ if (tile.scope.staleOnly) {
204
+ query.inventoryStatus = new Includes([InventoryLiveness.Stale]);
205
+ }
206
+
207
+ return query;
208
+ }
209
+
210
+ test.each(INVENTORY_SUMMARY_TILES)(
211
+ "the $key tile counts exactly the rows its drill-down lists",
212
+ async (tile: InventorySummaryTile) => {
213
+ expect(counts[tile.countField]).toBe(
214
+ await listCount(drillDownQuery(tile)),
215
+ );
216
+ },
217
+ );
218
+
219
+ test("the counts are the estate's, with archived and foreign rows left out", () => {
220
+ // Pinned as numbers too, so a degenerate estate cannot pass the tiles above vacuously.
221
+ expect(counts).toEqual({
222
+ total: 11,
223
+ discovered: 7,
224
+ mirrored: 2,
225
+ manual: 1,
226
+ stale: 3,
227
+ countsByType: {
228
+ [EntityType.Service]: 5,
229
+ [EntityType.Host]: 4,
230
+ [EntityType.ExternalService]: 1,
231
+ "some.future.type": 1,
232
+ },
233
+ });
234
+ });
235
+
236
+ test("each type's count is the rows the type facet lists", async () => {
237
+ for (const [entityType, count] of Object.entries(counts.countsByType)) {
238
+ expect(count).toBe(
239
+ await listCount({ entityType: new Includes([entityType]) }),
240
+ );
241
+ }
242
+ });
243
+
244
+ test("the breakdown sums to the total", () => {
245
+ expect(
246
+ Object.values(counts.countsByType).reduce(
247
+ (sum: number, count: number): number => {
248
+ return sum + count;
249
+ },
250
+ 0,
251
+ ),
252
+ ).toBe(counts.total);
253
+ });
254
+ });