@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,463 @@
1
+ import BadDataException from "../../../Types/Exception/BadDataException";
2
+ import { JSONObject } from "../../../Types/JSON";
3
+ import {
4
+ TOPOLOGY_CONNECTION_SECTIONS,
5
+ TopologyApiLimits,
6
+ TopologyCollectionCursorJSON,
7
+ TopologyConnectionSection,
8
+ } from "../../../Types/Topology/TopologyApi";
9
+ import { isFlatInfrastructureType } from "../../../Types/Topology/TopologyTypeRules";
10
+
11
+ /*
12
+ * Strict parsing of Topology API request bodies. Everything the SQL binds
13
+ * comes out of here typed, bounded and normalized; anything else is a 400
14
+ * (BadDataException) before a connection is taken.
15
+ */
16
+
17
+ /* A request's range start may run ahead of this server's clock by this much. */
18
+ export const RANGE_START_MAX_FUTURE_SKEW_MS: number = 5 * 60 * 1000;
19
+
20
+ /* Earliest accepted range start; Postgres has no year 0 and nothing is older. */
21
+ const RANGE_START_MIN_MS: number = Date.UTC(1970, 0, 1);
22
+
23
+ /*
24
+ * The oldest range start the maps honour; an older one is clamped to it (and
25
+ * the clamped value is what the response echoes). Relationships are pruned
26
+ * after 30 days and discovered resources sooner, so an older start draws the
27
+ * same map — but every distinct minute is its own whole-inventory build and
28
+ * cache entry, so an open-ended range would let one caller fan out builds by
29
+ * varying the minute.
30
+ */
31
+ export const MAP_RANGE_START_MAX_AGE_MS: number =
32
+ TopologyApiLimits.MaxMapRangeStartAgeDays * 24 * 60 * 60 * 1000;
33
+
34
+ /* Entity keys and types are varchar(100); a little slack for multi-byte text. */
35
+ export const MAX_ENTITY_KEY_LENGTH: number = 512;
36
+ export const MAX_ENTITY_TYPE_LENGTH: number = 200;
37
+ export const MAX_CURSOR_NAME_LENGTH: number = 512;
38
+
39
+ const ISO_DATE_TIME_PATTERN: RegExp =
40
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})$/;
41
+
42
+ export interface TopologyRangeRequest {
43
+ /* Floored to the minute: every "in range" decision and the cache use it. */
44
+ rangeStart: Date;
45
+ }
46
+
47
+ /* The Service Map and Infrastructure maps. */
48
+ export interface TopologyMapRequest extends TopologyRangeRequest {
49
+ /* An explicit refresh: skip the cached copy (see TopologyMapRequestJSON). */
50
+ fresh: boolean;
51
+ }
52
+
53
+ export interface TopologyCollectionRequest extends TopologyRangeRequest {
54
+ entityType: string;
55
+ includeInactive: boolean;
56
+ nameTerms: Array<string>;
57
+ cursor: TopologyCollectionCursorJSON | null;
58
+ limit: number;
59
+ }
60
+
61
+ export interface TopologyCollectionSearchType {
62
+ entityType: string;
63
+ nameTerms: Array<string>;
64
+ }
65
+
66
+ export interface TopologyCollectionSearchRequest extends TopologyRangeRequest {
67
+ includeInactive: boolean;
68
+ types: Array<TopologyCollectionSearchType>;
69
+ }
70
+
71
+ /* Which entity a drawer or inventory read is about. */
72
+ export interface TopologyEntityTarget {
73
+ entityKey: string;
74
+ entityType: string | null;
75
+ }
76
+
77
+ export interface TopologyEntityRequest
78
+ extends TopologyRangeRequest,
79
+ TopologyEntityTarget {}
80
+
81
+ /* One page of one section ("Show more"). */
82
+ export interface TopologyConnectionsPage {
83
+ section: TopologyConnectionSection;
84
+ offset: number;
85
+ limit: number;
86
+ }
87
+
88
+ export interface TopologyEntityConnectionsRequest
89
+ extends TopologyEntityRequest,
90
+ TopologyConnectionsPage {}
91
+
92
+ /* The all-time variant: no range (see TopologyEntityAllTimeRequestJSON). */
93
+ export type TopologyEntityAllTimeRequest = TopologyEntityTarget;
94
+
95
+ export interface TopologyEntityAllTimeConnectionsRequest
96
+ extends TopologyEntityTarget,
97
+ TopologyConnectionsPage {}
98
+
99
+ /* Rows a section returns before "Show more", and the default page size. */
100
+ export function rowsForSection(section: TopologyConnectionSection): number {
101
+ return section === "calls" || section === "calledBy"
102
+ ? TopologyApiLimits.EntityDependencyRows
103
+ : TopologyApiLimits.EntityOtherRows;
104
+ }
105
+
106
+ /* Floors a date to the whole minute (what the server uses and echoes). */
107
+ export function floorToMinute(date: Date): Date {
108
+ return new Date(Math.floor(date.getTime() / 60_000) * 60_000);
109
+ }
110
+
111
+ export default class TopologyRequest {
112
+ public static parseMapRequest(
113
+ body: unknown,
114
+ now: Date = new Date(),
115
+ ): TopologyMapRequest {
116
+ const json: JSONObject = TopologyRequest.asObject(body);
117
+ const rangeStart: Date = TopologyRequest.clampMapRangeStart(
118
+ TopologyRequest.parseRangeStart(json, now),
119
+ now,
120
+ );
121
+ const fresh: unknown = json["fresh"];
122
+ return {
123
+ rangeStart,
124
+ fresh:
125
+ fresh === undefined
126
+ ? false
127
+ : TopologyRequest.parseBoolean(fresh, "fresh"),
128
+ };
129
+ }
130
+
131
+ public static parseCollectionRequest(
132
+ body: unknown,
133
+ now: Date = new Date(),
134
+ ): TopologyCollectionRequest {
135
+ const json: JSONObject = TopologyRequest.asObject(body);
136
+ return {
137
+ rangeStart: TopologyRequest.parseRangeStart(json, now),
138
+ entityType: TopologyRequest.parseCollectionType(
139
+ json["entityType"],
140
+ "entityType",
141
+ ),
142
+ includeInactive: TopologyRequest.parseBoolean(
143
+ json["includeInactive"],
144
+ "includeInactive",
145
+ ),
146
+ nameTerms: TopologyRequest.parseNameTerms(
147
+ json["nameTerms"],
148
+ "nameTerms",
149
+ true,
150
+ ),
151
+ cursor: TopologyRequest.parseCursor(json["cursor"]),
152
+ limit: TopologyRequest.parseInteger(json["limit"], "limit", {
153
+ min: 1,
154
+ max: TopologyApiLimits.CollectionPageSizeMax,
155
+ defaultValue: TopologyApiLimits.CollectionPageSizeDefault,
156
+ }),
157
+ };
158
+ }
159
+
160
+ public static parseCollectionSearchRequest(
161
+ body: unknown,
162
+ now: Date = new Date(),
163
+ ): TopologyCollectionSearchRequest {
164
+ const json: JSONObject = TopologyRequest.asObject(body);
165
+ const rawTypes: unknown = json["types"];
166
+ if (!Array.isArray(rawTypes)) {
167
+ throw new BadDataException("types must be an array");
168
+ }
169
+ if (rawTypes.length > TopologyApiLimits.MaxCollectionSearchTypes) {
170
+ throw new BadDataException(
171
+ `types cannot have more than ${TopologyApiLimits.MaxCollectionSearchTypes} entries`,
172
+ );
173
+ }
174
+ const seen: Set<string> = new Set<string>();
175
+ const types: Array<TopologyCollectionSearchType> = rawTypes.map(
176
+ (rawType: unknown, index: number): TopologyCollectionSearchType => {
177
+ const entry: JSONObject = TopologyRequest.asObject(
178
+ rawType,
179
+ `types[${index}]`,
180
+ );
181
+ const entityType: string = TopologyRequest.parseCollectionType(
182
+ entry["entityType"],
183
+ `types[${index}].entityType`,
184
+ );
185
+ if (seen.has(entityType)) {
186
+ throw new BadDataException(
187
+ `types lists "${entityType}" more than once`,
188
+ );
189
+ }
190
+ seen.add(entityType);
191
+ return {
192
+ entityType,
193
+ nameTerms: TopologyRequest.parseNameTerms(
194
+ entry["nameTerms"],
195
+ `types[${index}].nameTerms`,
196
+ false,
197
+ ),
198
+ };
199
+ },
200
+ );
201
+ return {
202
+ rangeStart: TopologyRequest.parseRangeStart(json, now),
203
+ includeInactive: TopologyRequest.parseBoolean(
204
+ json["includeInactive"],
205
+ "includeInactive",
206
+ ),
207
+ types,
208
+ };
209
+ }
210
+
211
+ public static parseEntityRequest(
212
+ body: unknown,
213
+ now: Date = new Date(),
214
+ ): TopologyEntityRequest {
215
+ const json: JSONObject = TopologyRequest.asObject(body);
216
+ return {
217
+ rangeStart: TopologyRequest.parseRangeStart(json, now),
218
+ ...TopologyRequest.parseEntityTarget(json),
219
+ };
220
+ }
221
+
222
+ public static parseEntityConnectionsRequest(
223
+ body: unknown,
224
+ now: Date = new Date(),
225
+ ): TopologyEntityConnectionsRequest {
226
+ const json: JSONObject = TopologyRequest.asObject(body);
227
+ const entity: TopologyEntityRequest = TopologyRequest.parseEntityRequest(
228
+ json,
229
+ now,
230
+ );
231
+ return { ...entity, ...TopologyRequest.parseConnectionsPage(json) };
232
+ }
233
+
234
+ /* The all-time reads take no range start; one sent anyway is ignored. */
235
+ public static parseEntityAllTimeRequest(
236
+ body: unknown,
237
+ ): TopologyEntityAllTimeRequest {
238
+ return TopologyRequest.parseEntityTarget(TopologyRequest.asObject(body));
239
+ }
240
+
241
+ public static parseEntityAllTimeConnectionsRequest(
242
+ body: unknown,
243
+ ): TopologyEntityAllTimeConnectionsRequest {
244
+ const json: JSONObject = TopologyRequest.asObject(body);
245
+ return {
246
+ ...TopologyRequest.parseEntityTarget(json),
247
+ ...TopologyRequest.parseConnectionsPage(json),
248
+ };
249
+ }
250
+
251
+ // -------------------------------------------------------------- fields
252
+
253
+ private static parseEntityTarget(json: JSONObject): TopologyEntityTarget {
254
+ return {
255
+ entityKey: TopologyRequest.parseString(json["entityKey"], "entityKey", {
256
+ maxLength: MAX_ENTITY_KEY_LENGTH,
257
+ allowEmpty: false,
258
+ }),
259
+ entityType: TopologyRequest.parseOptionalEntityType(json["entityType"]),
260
+ };
261
+ }
262
+
263
+ private static parseConnectionsPage(
264
+ json: JSONObject,
265
+ ): TopologyConnectionsPage {
266
+ const rawSection: unknown = json["section"];
267
+ const section: TopologyConnectionSection | undefined =
268
+ TOPOLOGY_CONNECTION_SECTIONS.find(
269
+ (candidate: TopologyConnectionSection): boolean => {
270
+ return candidate === rawSection;
271
+ },
272
+ );
273
+ if (!section) {
274
+ throw new BadDataException(
275
+ `section must be one of ${TOPOLOGY_CONNECTION_SECTIONS.join(", ")}`,
276
+ );
277
+ }
278
+ return {
279
+ section,
280
+ offset: TopologyRequest.parseInteger(json["offset"], "offset", {
281
+ min: 0,
282
+ max: TopologyApiLimits.EntityConnectionScanLimit,
283
+ defaultValue: undefined,
284
+ }),
285
+ limit: TopologyRequest.parseInteger(json["limit"], "limit", {
286
+ min: 1,
287
+ max: TopologyApiLimits.EntityConnectionsPageSizeMax,
288
+ defaultValue: rowsForSection(section),
289
+ }),
290
+ };
291
+ }
292
+
293
+ private static asObject(value: unknown, field: string = "body"): JSONObject {
294
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
295
+ throw new BadDataException(`${field} must be a JSON object`);
296
+ }
297
+ return value as JSONObject;
298
+ }
299
+
300
+ /*
301
+ * A custom range can start long before anything the maps could draw; clamp
302
+ * it to the oldest start they honour, on a minute boundary like every other.
303
+ */
304
+ public static clampMapRangeStart(rangeStart: Date, now: Date): Date {
305
+ const oldest: Date = new Date(
306
+ Math.ceil((now.getTime() - MAP_RANGE_START_MAX_AGE_MS) / 60_000) * 60_000,
307
+ );
308
+ return rangeStart.getTime() < oldest.getTime() ? oldest : rangeStart;
309
+ }
310
+
311
+ private static parseRangeStart(json: JSONObject, now: Date): Date {
312
+ const value: unknown = json["rangeStart"];
313
+ if (typeof value !== "string" || value.length > 64) {
314
+ throw new BadDataException("rangeStart must be an ISO 8601 date-time");
315
+ }
316
+ if (!ISO_DATE_TIME_PATTERN.test(value)) {
317
+ throw new BadDataException("rangeStart must be an ISO 8601 date-time");
318
+ }
319
+ const parsed: number = Date.parse(value);
320
+ if (Number.isNaN(parsed)) {
321
+ throw new BadDataException("rangeStart is not a valid date");
322
+ }
323
+ if (parsed > now.getTime() + RANGE_START_MAX_FUTURE_SKEW_MS) {
324
+ throw new BadDataException("rangeStart cannot be in the future");
325
+ }
326
+ if (parsed < RANGE_START_MIN_MS) {
327
+ throw new BadDataException("rangeStart is too far in the past");
328
+ }
329
+ return floorToMinute(new Date(parsed));
330
+ }
331
+
332
+ private static parseString(
333
+ value: unknown,
334
+ field: string,
335
+ options: { maxLength: number; allowEmpty: boolean },
336
+ ): string {
337
+ if (typeof value !== "string") {
338
+ throw new BadDataException(`${field} must be a string`);
339
+ }
340
+ if (!options.allowEmpty && value.length === 0) {
341
+ throw new BadDataException(`${field} cannot be empty`);
342
+ }
343
+ if (value.length > options.maxLength) {
344
+ throw new BadDataException(
345
+ `${field} cannot be longer than ${options.maxLength} characters`,
346
+ );
347
+ }
348
+ return value;
349
+ }
350
+
351
+ private static parseOptionalEntityType(value: unknown): string | null {
352
+ if (value === undefined || value === null || value === "") {
353
+ return null;
354
+ }
355
+ return TopologyRequest.parseString(value, "entityType", {
356
+ maxLength: MAX_ENTITY_TYPE_LENGTH,
357
+ allowEmpty: false,
358
+ });
359
+ }
360
+
361
+ private static parseCollectionType(value: unknown, field: string): string {
362
+ const entityType: string = TopologyRequest.parseString(value, field, {
363
+ maxLength: MAX_ENTITY_TYPE_LENGTH,
364
+ allowEmpty: false,
365
+ });
366
+ if (!isFlatInfrastructureType(entityType)) {
367
+ throw new BadDataException(
368
+ `${field} "${entityType}" is not a flat infrastructure type`,
369
+ );
370
+ }
371
+ return entityType;
372
+ }
373
+
374
+ private static parseBoolean(value: unknown, field: string): boolean {
375
+ if (typeof value !== "boolean") {
376
+ throw new BadDataException(`${field} must be a boolean`);
377
+ }
378
+ return value;
379
+ }
380
+
381
+ private static parseInteger(
382
+ value: unknown,
383
+ field: string,
384
+ options: { min: number; max: number; defaultValue: number | undefined },
385
+ ): number {
386
+ if (value === undefined || value === null) {
387
+ if (options.defaultValue === undefined) {
388
+ throw new BadDataException(`${field} is required`);
389
+ }
390
+ return options.defaultValue;
391
+ }
392
+ if (
393
+ typeof value !== "number" ||
394
+ !Number.isInteger(value) ||
395
+ value < options.min ||
396
+ value > options.max
397
+ ) {
398
+ throw new BadDataException(
399
+ `${field} must be an integer from ${options.min} to ${options.max}`,
400
+ );
401
+ }
402
+ return value;
403
+ }
404
+
405
+ /*
406
+ * Search terms: trimmed and lowercased (the match is case-insensitive
407
+ * anyway), each non-empty and bounded, at most MaxSearchTerms of them.
408
+ */
409
+ private static parseNameTerms(
410
+ value: unknown,
411
+ field: string,
412
+ optional: boolean,
413
+ ): Array<string> {
414
+ if (value === undefined || value === null) {
415
+ if (optional) {
416
+ return [];
417
+ }
418
+ throw new BadDataException(`${field} must be an array of strings`);
419
+ }
420
+ if (!Array.isArray(value)) {
421
+ throw new BadDataException(`${field} must be an array of strings`);
422
+ }
423
+ if (value.length > TopologyApiLimits.MaxSearchTerms) {
424
+ throw new BadDataException(
425
+ `${field} cannot have more than ${TopologyApiLimits.MaxSearchTerms} terms`,
426
+ );
427
+ }
428
+ return value.map((term: unknown): string => {
429
+ if (typeof term !== "string") {
430
+ throw new BadDataException(`${field} must be an array of strings`);
431
+ }
432
+ const normalized: string = term.trim().toLowerCase();
433
+ if (normalized.length === 0) {
434
+ throw new BadDataException(`${field} cannot contain an empty term`);
435
+ }
436
+ if (normalized.length > TopologyApiLimits.MaxSearchTermLength) {
437
+ throw new BadDataException(
438
+ `${field} terms cannot be longer than ${TopologyApiLimits.MaxSearchTermLength} characters`,
439
+ );
440
+ }
441
+ return normalized;
442
+ });
443
+ }
444
+
445
+ private static parseCursor(
446
+ value: unknown,
447
+ ): TopologyCollectionCursorJSON | null {
448
+ if (value === undefined || value === null) {
449
+ return null;
450
+ }
451
+ const cursor: JSONObject = TopologyRequest.asObject(value, "cursor");
452
+ return {
453
+ name: TopologyRequest.parseString(cursor["name"], "cursor.name", {
454
+ maxLength: MAX_CURSOR_NAME_LENGTH,
455
+ allowEmpty: true,
456
+ }),
457
+ key: TopologyRequest.parseString(cursor["key"], "cursor.key", {
458
+ maxLength: MAX_ENTITY_KEY_LENGTH,
459
+ allowEmpty: false,
460
+ }),
461
+ };
462
+ }
463
+ }
@@ -0,0 +1,183 @@
1
+ /*
2
+ * In-process cache for the Topology maps' serialized responses.
3
+ *
4
+ * Opening the Topology page, switching back to it and every other viewer of
5
+ * the same project inside a minute ask for the same thing: the maps for one
6
+ * project, as of one (minute-floored) range start. Building one reads the
7
+ * whole inventory, so the serialized JSON is kept for a short TTL and handed
8
+ * out as the string it already is — no re-serialization per request.
9
+ *
10
+ * Concurrent cold requests for one key share a single build (single flight):
11
+ * the first caller runs it, everyone arriving while it is in flight awaits the
12
+ * same promise. A failed build is never cached; its followers see the same
13
+ * failure and the next request starts over.
14
+ *
15
+ * An explicit refresh (`fresh`) skips the cached copy but not the rest: it
16
+ * joins a build of the key already in flight (which began reading at most one
17
+ * build's duration before the refresh) or starts one, and the result replaces
18
+ * the cached copy for everyone.
19
+ *
20
+ * Bounded by a byte budget rather than an entry count: a large estate's
21
+ * infrastructure payload is tens of megabytes, so a count bound says nothing
22
+ * about memory. Oldest entries are evicted first (Map iteration order is
23
+ * insertion order), and a single value over the whole budget is simply not
24
+ * kept. Per process, with no cross-process invalidation — the TTL is the
25
+ * staleness bound, and the maps already show data up to a minute old.
26
+ *
27
+ * Authorization is NOT this module's concern: callers must authorize the
28
+ * request before asking for a key, so a cache hit never skips a check.
29
+ */
30
+
31
+ export const TOPOLOGY_RESPONSE_CACHE_TTL_MS: number = 60_000;
32
+
33
+ /* ~256 MB, counting two bytes per UTF-16 code unit (the worst case in V8). */
34
+ export const TOPOLOGY_RESPONSE_CACHE_MAX_BYTES: number = 256 * 1024 * 1024;
35
+
36
+ /* One cached response per (route, project, minute-floored range start). */
37
+ export function topologyResponseCacheKey(data: {
38
+ route: string;
39
+ projectId: string;
40
+ rangeStart: Date;
41
+ }): string {
42
+ return `${data.route}|${data.projectId}|${data.rangeStart.getTime()}`;
43
+ }
44
+
45
+ interface CacheEntry {
46
+ value: string;
47
+ bytes: number;
48
+ expiresAt: number;
49
+ }
50
+
51
+ export class TopologyResponseCache {
52
+ private store: Map<string, CacheEntry> = new Map<string, CacheEntry>();
53
+ private bytes: number = 0;
54
+ private inFlight: Map<string, Promise<string>> = new Map<
55
+ string,
56
+ Promise<string>
57
+ >();
58
+
59
+ public constructor(
60
+ private ttlMs: number = TOPOLOGY_RESPONSE_CACHE_TTL_MS,
61
+ private maxBytes: number = TOPOLOGY_RESPONSE_CACHE_MAX_BYTES,
62
+ private now: () => number = (): number => {
63
+ return Date.now();
64
+ },
65
+ ) {}
66
+
67
+ /*
68
+ * The cached value for `key`, or the result of `build` (shared, cached).
69
+ * With `fresh`, never the cached value: a build in flight or a new one.
70
+ */
71
+ public async getOrBuild(
72
+ key: string,
73
+ build: () => Promise<string>,
74
+ options: { fresh?: boolean | undefined } = {},
75
+ ): Promise<string> {
76
+ if (!options.fresh) {
77
+ const cached: string | undefined = this.get(key);
78
+ if (cached !== undefined) {
79
+ return cached;
80
+ }
81
+ }
82
+
83
+ const running: Promise<string> | undefined = this.inFlight.get(key);
84
+ if (running) {
85
+ return await running;
86
+ }
87
+
88
+ /*
89
+ * Wrapped so a `build` that throws synchronously still yields a promise
90
+ * to register and settle; otherwise the key would never be released.
91
+ */
92
+ const promise: Promise<string> = (async (): Promise<string> => {
93
+ const value: string = await build();
94
+ this.set(key, value);
95
+ return value;
96
+ })();
97
+
98
+ this.inFlight.set(key, promise);
99
+ try {
100
+ return await promise;
101
+ } finally {
102
+ if (this.inFlight.get(key) === promise) {
103
+ this.inFlight.delete(key);
104
+ }
105
+ }
106
+ }
107
+
108
+ public get(key: string): string | undefined {
109
+ const entry: CacheEntry | undefined = this.store.get(key);
110
+ if (!entry) {
111
+ return undefined;
112
+ }
113
+ if (this.now() > entry.expiresAt) {
114
+ this.delete(key);
115
+ return undefined;
116
+ }
117
+ return entry.value;
118
+ }
119
+
120
+ public set(key: string, value: string): void {
121
+ this.delete(key);
122
+
123
+ const bytes: number = value.length * 2;
124
+ if (bytes > this.maxBytes) {
125
+ return;
126
+ }
127
+
128
+ this.sweepExpired();
129
+
130
+ while (this.store.size > 0 && this.bytes + bytes > this.maxBytes) {
131
+ const oldest: string | undefined = this.store.keys().next().value;
132
+ if (oldest === undefined) {
133
+ break;
134
+ }
135
+ this.delete(oldest);
136
+ }
137
+
138
+ this.store.set(key, {
139
+ value,
140
+ bytes,
141
+ expiresAt: this.now() + this.ttlMs,
142
+ });
143
+ this.bytes += bytes;
144
+ }
145
+
146
+ public delete(key: string): void {
147
+ const entry: CacheEntry | undefined = this.store.get(key);
148
+ if (!entry) {
149
+ return;
150
+ }
151
+ this.bytes -= entry.bytes;
152
+ this.store.delete(key);
153
+ }
154
+
155
+ public clear(): void {
156
+ this.store.clear();
157
+ this.inFlight.clear();
158
+ this.bytes = 0;
159
+ }
160
+
161
+ public size(): number {
162
+ return this.store.size;
163
+ }
164
+
165
+ public byteSize(): number {
166
+ return this.bytes;
167
+ }
168
+
169
+ public inFlightCount(): number {
170
+ return this.inFlight.size;
171
+ }
172
+
173
+ private sweepExpired(): void {
174
+ const now: number = this.now();
175
+ for (const [key, entry] of this.store) {
176
+ if (now > entry.expiresAt) {
177
+ this.delete(key);
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ export default new TopologyResponseCache();