@oneuptime/common 14.0.6 → 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 (127) 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/ScheduledMaintenanceService.ts +50 -46
  10. package/Server/Services/UserNotificationRuleService.ts +55 -53
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  12. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  13. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  14. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  15. package/Server/Utils/Response.ts +31 -0
  16. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  17. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  18. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  19. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  20. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  21. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  22. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  23. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  24. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  25. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  26. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  27. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  28. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  29. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  30. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  31. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  32. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  33. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  34. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  35. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  36. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  37. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  38. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  39. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  40. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  41. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  42. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  43. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  44. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  45. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  46. package/Tests/Server/Services/DomainService.test.ts +525 -0
  47. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  48. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  49. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  50. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  51. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  52. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  53. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  54. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  55. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  56. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  57. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  58. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  59. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  60. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  61. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  62. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  63. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  64. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  65. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  66. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  67. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  68. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  69. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  70. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  71. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  72. package/Types/Incident/IncidentAlertLink.ts +19 -0
  73. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  74. package/Types/Topology/TopologyApi.ts +428 -0
  75. package/Types/Topology/TopologyTypeRules.ts +191 -0
  76. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  77. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  78. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  83. package/build/dist/Server/Services/AlertService.js +21 -33
  84. package/build/dist/Server/Services/AlertService.js.map +1 -1
  85. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  86. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +96 -42
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  90. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  91. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  92. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  93. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  94. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  95. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  96. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  97. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  98. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  99. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  100. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  101. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  102. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  103. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  104. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  105. package/build/dist/Server/Utils/Response.js +30 -0
  106. package/build/dist/Server/Utils/Response.js.map +1 -1
  107. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  108. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  109. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  110. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  111. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  112. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  113. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  114. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  115. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  116. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  117. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  118. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  119. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  120. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  121. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  122. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  123. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  124. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  125. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  126. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  127. package/package.json +1 -1
@@ -0,0 +1,1018 @@
1
+ import { JSONObject, JSONValue } from "../../../../Types/JSON";
2
+ import EntityRelationshipType from "../../../../Types/Telemetry/EntityRelationshipType";
3
+ import EntitySource from "../../../../Types/Telemetry/EntitySource";
4
+ import EntityType from "../../../../Types/Telemetry/EntityType";
5
+ import {
6
+ TOPOLOGY_API_FORMAT_VERSION,
7
+ TOPOLOGY_CONNECTION_SECTIONS,
8
+ TopologyApiLimits,
9
+ TopologyConnectionRowJSON,
10
+ TopologyConnectionSection,
11
+ TopologyConnectionSectionJSON,
12
+ TopologyDependencyJSON,
13
+ TopologyEntityDetailJSON,
14
+ TopologyEntityResponseJSON,
15
+ TopologyInfrastructureCollectionJSON,
16
+ TopologyInfrastructureDependencyJSON,
17
+ TopologyInfrastructureNodeJSON,
18
+ TopologyInfrastructureResponseJSON,
19
+ TopologyInfrastructureServiceJSON,
20
+ TopologyRunsOnCountJSON,
21
+ TopologyServiceMapEntityJSON,
22
+ TopologyServiceMapResponseJSON,
23
+ } from "../../../../Types/Topology/TopologyApi";
24
+ import {
25
+ CONTAINER_SPECIFICITY,
26
+ NESTABLE_CHILD_TYPES,
27
+ NESTING_RELATIONSHIP_PRIORITY,
28
+ PLACEMENT_RELATIONSHIP_TYPES,
29
+ SERVICE_MAP_DETAIL_ATTRIBUTE_KEYS,
30
+ isFlatInfrastructureType,
31
+ isTopologyInfrastructureType,
32
+ } from "../../../../Types/Topology/TopologyTypeRules";
33
+
34
+ /*
35
+ * A REFERENCE implementation of what the Topology API must return, written
36
+ * from the semantics the build spec lists (activity, item identity,
37
+ * relationships in range, nesting, placements, the Service Map payload and the
38
+ * drawer's sections) in plain TypeScript over whole rows.
39
+ *
40
+ * It deliberately shares nothing with the server's SQL (TopologySql /
41
+ * TopologyQueries): the differential tests compare the two, and the Dashboard
42
+ * parity test feeds this output through the real decoders to prove that the
43
+ * reduced payload rebuilds exactly the maps the old whole-inventory load gave.
44
+ * The only shared inputs are the contract types and the type rules in
45
+ * Common/Types/Topology, which are the specification both sides implement.
46
+ *
47
+ * Safety caps (TopologyApiLimits.Max*) are not modelled: the estates these
48
+ * tests generate stay far below them, so truncation is always null.
49
+ */
50
+
51
+ /* One InventoryItem row, every column the Topology API can read. */
52
+ export interface ReferenceItem {
53
+ /* `_id`, a lowercase UUID. */
54
+ id: string;
55
+ projectId: string;
56
+ key: string;
57
+ type: string;
58
+ name: string | null;
59
+ /* The column is NOT NULL; "" is a valid (blank) value. */
60
+ source: string;
61
+ lastSeenAt: Date | null;
62
+ firstSeenAt: Date | null;
63
+ createdAt: Date;
64
+ isArchived: boolean;
65
+ deleted: boolean;
66
+ /* Any JSON the jsonb column can hold (objects, arrays, scalars), or NULL. */
67
+ descriptiveAttributes: JSONValue | null;
68
+ identifyingAttributes: JSONValue | null;
69
+ resourceType: string | null;
70
+ resourceId: string | null;
71
+ }
72
+
73
+ /* One InventoryItemRelationship row. */
74
+ export interface ReferenceRelationship {
75
+ id: string;
76
+ projectId: string;
77
+ from: string;
78
+ to: string;
79
+ type: string;
80
+ lastSeenAt: Date | null;
81
+ createdAt: Date;
82
+ deleted: boolean;
83
+ callCount: number | null;
84
+ errorCount: number | null;
85
+ avgDurationMs: number | null;
86
+ }
87
+
88
+ export interface ReferenceEstate {
89
+ items: Array<ReferenceItem>;
90
+ relationships: Array<ReferenceRelationship>;
91
+ }
92
+
93
+ export interface ReferenceScope {
94
+ projectId: string;
95
+ /* Floored to the minute here, exactly as the server floors the request. */
96
+ rangeStart: Date;
97
+ }
98
+
99
+ export type ReferenceServiceMapResponse = Omit<
100
+ TopologyServiceMapResponseJSON,
101
+ "generatedAt"
102
+ >;
103
+
104
+ export type ReferenceInfrastructureResponse = Omit<
105
+ TopologyInfrastructureResponseJSON,
106
+ "generatedAt"
107
+ >;
108
+
109
+ export type ReferenceEntityResponse = Omit<
110
+ TopologyEntityResponseJSON,
111
+ "generatedAt"
112
+ >;
113
+
114
+ export interface ReferenceInfrastructureOptions {
115
+ /* Defaults to TopologyApiLimits.InlineFlatItemsPerType. */
116
+ inlineFlatItemsPerType?: number | undefined;
117
+ }
118
+
119
+ export interface ReferenceEntityRequest extends ReferenceScope {
120
+ entityKey: string;
121
+ entityType: string | null;
122
+ }
123
+
124
+ export interface ReferenceEntityOptions {
125
+ /*
126
+ * How the database's default collation orders drawer labels. Labels are
127
+ * the one place the server sorts by the database collation rather than by
128
+ * code points, so a caller that talks to a real database passes that
129
+ * database's order; the default is code-point order.
130
+ */
131
+ compareLabels?: ((left: string, right: string) => number) | undefined;
132
+ /* Relationships read at most (defaults to EntityConnectionScanLimit + 1). */
133
+ scanLimit?: number | undefined;
134
+ dependencyRows?: number | undefined;
135
+ otherRows?: number | undefined;
136
+ }
137
+
138
+ export const REFERENCE_UNNAMED_RESOURCE_LABEL: string = "Unnamed resource";
139
+ export const REFERENCE_UNDISCOVERED_RESOURCE_LABEL: string =
140
+ "Undiscovered resource";
141
+
142
+ // ------------------------------------------------------------ primitives
143
+
144
+ const MINUTE_MS: number = 60 * 1000;
145
+
146
+ export function floorToMinute(date: Date): Date {
147
+ return new Date(Math.floor(date.getTime() / MINUTE_MS) * MINUTE_MS);
148
+ }
149
+
150
+ /*
151
+ * COLLATE "C" on a UTF-8 database: byte order of the UTF-8 encoding, which is
152
+ * code-point order. That equals JavaScript's code-unit order everywhere
153
+ * except where a character outside the Basic Multilingual Plane (a surrogate
154
+ * pair in UTF-16) meets one in U+E000..U+FFFF, so the comparison is done on
155
+ * code points at the first difference.
156
+ */
157
+ export function compareCollateC(left: string, right: string): number {
158
+ if (left === right) {
159
+ return 0;
160
+ }
161
+ const length: number = Math.min(left.length, right.length);
162
+ let index: number = 0;
163
+ while (index < length && left.charCodeAt(index) === right.charCodeAt(index)) {
164
+ index++;
165
+ }
166
+ if (index === length) {
167
+ return left.length - right.length;
168
+ }
169
+ /* A difference in a low surrogate compares the whole pair. */
170
+ let start: number = index;
171
+ if (start > 0) {
172
+ const previous: number = left.charCodeAt(start - 1);
173
+ if (previous >= 0xd800 && previous <= 0xdbff) {
174
+ start--;
175
+ }
176
+ }
177
+ return left.codePointAt(start)! - right.codePointAt(start)!;
178
+ }
179
+
180
+ /* uuid columns compare as 16 raw bytes: the lowercase text's order. */
181
+ function compareIds(left: string, right: string): number {
182
+ const a: string = left.toLowerCase();
183
+ const b: string = right.toLowerCase();
184
+ if (a === b) {
185
+ return 0;
186
+ }
187
+ return a < b ? -1 : 1;
188
+ }
189
+
190
+ /* Semantics 1: the activity predicate. */
191
+ export function isReferenceActive(
192
+ row: { source: string | null; lastSeenAt: Date | null },
193
+ rangeStart: Date,
194
+ ): boolean {
195
+ if (
196
+ row.source !== null &&
197
+ row.source !== "" &&
198
+ row.source !== EntitySource.Discovered
199
+ ) {
200
+ return true;
201
+ }
202
+ if (row.lastSeenAt === null) {
203
+ return true;
204
+ }
205
+ return row.lastSeenAt.getTime() >= rangeStart.getTime();
206
+ }
207
+
208
+ /* Timestamps leave the server as floored epoch milliseconds. */
209
+ export function referenceEpochMs(date: Date | null): number | null {
210
+ return date === null ? null : Math.floor(date.getTime());
211
+ }
212
+
213
+ function isPlainObject(value: JSONValue | null | undefined): boolean {
214
+ return (
215
+ value !== null &&
216
+ value !== undefined &&
217
+ typeof value === "object" &&
218
+ !Array.isArray(value)
219
+ );
220
+ }
221
+
222
+ // -------------------------------------------------------------- snapshot
223
+
224
+ /* What every endpoint reads: the project's live items and in-range rows. */
225
+ interface ReferenceSnapshot {
226
+ rangeStart: Date;
227
+ /* Semantics 2: non-archived, non-deleted rows of the project. */
228
+ liveItems: Array<ReferenceItem>;
229
+ /* The winning live row of every key. */
230
+ winnerByKey: Map<string, ReferenceItem>;
231
+ /* Semantics 3: in-range, non-deleted relationships of the project. */
232
+ inRange: Array<ReferenceRelationship>;
233
+ }
234
+
235
+ /*
236
+ * Semantics 2: among live rows sharing a key the winner is the first by
237
+ * (createdAt ASC, _id DESC).
238
+ */
239
+ function compareWinner(left: ReferenceItem, right: ReferenceItem): number {
240
+ return (
241
+ left.createdAt.getTime() - right.createdAt.getTime() ||
242
+ compareIds(right.id, left.id)
243
+ );
244
+ }
245
+
246
+ function takeSnapshot(
247
+ estate: ReferenceEstate,
248
+ scope: ReferenceScope,
249
+ ): ReferenceSnapshot {
250
+ const rangeStart: Date = floorToMinute(scope.rangeStart);
251
+ const liveItems: Array<ReferenceItem> = estate.items.filter(
252
+ (item: ReferenceItem): boolean => {
253
+ return (
254
+ item.projectId === scope.projectId && !item.isArchived && !item.deleted
255
+ );
256
+ },
257
+ );
258
+ const winnerByKey: Map<string, ReferenceItem> = new Map<
259
+ string,
260
+ ReferenceItem
261
+ >();
262
+ for (const item of liveItems) {
263
+ const current: ReferenceItem | undefined = winnerByKey.get(item.key);
264
+ if (!current || compareWinner(item, current) < 0) {
265
+ winnerByKey.set(item.key, item);
266
+ }
267
+ }
268
+ const inRange: Array<ReferenceRelationship> = estate.relationships.filter(
269
+ (relationship: ReferenceRelationship): boolean => {
270
+ return (
271
+ relationship.projectId === scope.projectId &&
272
+ !relationship.deleted &&
273
+ relationship.lastSeenAt !== null &&
274
+ relationship.lastSeenAt.getTime() >= rangeStart.getTime()
275
+ );
276
+ },
277
+ );
278
+ return { rangeStart, liveItems, winnerByKey, inRange };
279
+ }
280
+
281
+ function winners(snapshot: ReferenceSnapshot): Array<ReferenceItem> {
282
+ return Array.from(snapshot.winnerByKey.values());
283
+ }
284
+
285
+ function byKeyC(left: ReferenceItem, right: ReferenceItem): number {
286
+ return compareCollateC(left.key, right.key);
287
+ }
288
+
289
+ function envelope(snapshot: ReferenceSnapshot): {
290
+ formatVersion: number;
291
+ rangeStart: string;
292
+ } {
293
+ return {
294
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION,
295
+ rangeStart: snapshot.rangeStart.toISOString(),
296
+ };
297
+ }
298
+
299
+ // ----------------------------------------------------------- service map
300
+
301
+ /*
302
+ * Semantics 6: only the detail keys whose value is a JSON string, per bag;
303
+ * a bag with none of them is absent.
304
+ */
305
+ function detailBag(bag: JSONValue | null): Record<string, string> | undefined {
306
+ if (!isPlainObject(bag)) {
307
+ return undefined;
308
+ }
309
+ const object: JSONObject = bag as JSONObject;
310
+ const strings: Record<string, string> = {};
311
+ let count: number = 0;
312
+ for (const key of SERVICE_MAP_DETAIL_ATTRIBUTE_KEYS) {
313
+ const value: JSONValue | undefined = object[key];
314
+ if (typeof value === "string") {
315
+ strings[key] = value;
316
+ count++;
317
+ }
318
+ }
319
+ return count > 0 ? strings : undefined;
320
+ }
321
+
322
+ function toServiceMapEntity(item: ReferenceItem): TopologyServiceMapEntityJSON {
323
+ const entity: TopologyServiceMapEntityJSON = {
324
+ key: item.key,
325
+ type: item.type,
326
+ name: item.name,
327
+ source: item.source,
328
+ lastSeenAt: referenceEpochMs(item.lastSeenAt),
329
+ };
330
+ const descriptive: Record<string, string> | undefined = detailBag(
331
+ item.descriptiveAttributes,
332
+ );
333
+ const identifying: Record<string, string> | undefined = detailBag(
334
+ item.identifyingAttributes,
335
+ );
336
+ if (descriptive) {
337
+ entity.descriptiveAttributes = descriptive;
338
+ }
339
+ if (identifying) {
340
+ entity.identifyingAttributes = identifying;
341
+ }
342
+ return entity;
343
+ }
344
+
345
+ /* The old list API's order: createdAt DESC, _id ASC. */
346
+ function compareListOrder(
347
+ left: ReferenceRelationship,
348
+ right: ReferenceRelationship,
349
+ ): number {
350
+ return (
351
+ right.createdAt.getTime() - left.createdAt.getTime() ||
352
+ compareIds(left.id, right.id)
353
+ );
354
+ }
355
+
356
+ /*
357
+ * Semantics 5 / 6: distinct (service, target) pairs over in-range runs-on /
358
+ * hosted-on rows whose `from` is one of the given services.
359
+ */
360
+ function placementPairs(
361
+ snapshot: ReferenceSnapshot,
362
+ serviceKeys: Set<string>,
363
+ ): Array<[string, string]> {
364
+ const seen: Set<string> = new Set<string>();
365
+ const pairs: Array<[string, string]> = [];
366
+ for (const relationship of snapshot.inRange) {
367
+ if (
368
+ !PLACEMENT_RELATIONSHIP_TYPES.has(relationship.type) ||
369
+ !serviceKeys.has(relationship.from)
370
+ ) {
371
+ continue;
372
+ }
373
+ const id: string = `${relationship.from}\u0000${relationship.to}`;
374
+ if (seen.has(id)) {
375
+ continue;
376
+ }
377
+ seen.add(id);
378
+ pairs.push([relationship.from, relationship.to]);
379
+ }
380
+ return pairs;
381
+ }
382
+
383
+ export function referenceServiceMap(
384
+ estate: ReferenceEstate,
385
+ scope: ReferenceScope,
386
+ ): ReferenceServiceMapResponse {
387
+ const snapshot: ReferenceSnapshot = takeSnapshot(estate, scope);
388
+
389
+ /* Every live service, active or not. */
390
+ const services: Array<ReferenceItem> = winners(snapshot)
391
+ .filter((item: ReferenceItem): boolean => {
392
+ return item.type === EntityType.Service;
393
+ })
394
+ .sort(byKeyC);
395
+ const serviceKeys: Set<string> = new Set<string>(
396
+ services.map((item: ReferenceItem): string => {
397
+ return item.key;
398
+ }),
399
+ );
400
+
401
+ const dependencies: Array<TopologyDependencyJSON> = snapshot.inRange
402
+ .filter((relationship: ReferenceRelationship): boolean => {
403
+ return (
404
+ relationship.type === EntityRelationshipType.DependsOn &&
405
+ serviceKeys.has(relationship.from) &&
406
+ relationship.from !== relationship.to
407
+ );
408
+ })
409
+ .sort(compareListOrder)
410
+ .map((relationship: ReferenceRelationship): TopologyDependencyJSON => {
411
+ return {
412
+ from: relationship.from,
413
+ to: relationship.to,
414
+ callCount: relationship.callCount,
415
+ errorCount: relationship.errorCount,
416
+ avgDurationMs: relationship.avgDurationMs,
417
+ };
418
+ });
419
+
420
+ /*
421
+ * Callees: live items a dependency points at. A callee that is a service is
422
+ * already listed with the services.
423
+ */
424
+ const calleeKeys: Set<string> = new Set<string>(
425
+ dependencies.map((dependency: TopologyDependencyJSON): string => {
426
+ return dependency.to;
427
+ }),
428
+ );
429
+ const callees: Array<ReferenceItem> = winners(snapshot)
430
+ .filter((item: ReferenceItem): boolean => {
431
+ return calleeKeys.has(item.key) && !serviceKeys.has(item.key);
432
+ })
433
+ .sort(byKeyC);
434
+
435
+ /* Per (service, target type): distinct targets, and the active ones. */
436
+ const counts: Map<string, TopologyRunsOnCountJSON> = new Map<
437
+ string,
438
+ TopologyRunsOnCountJSON
439
+ >();
440
+ for (const [service, targetKey] of placementPairs(snapshot, serviceKeys)) {
441
+ const target: ReferenceItem | undefined =
442
+ snapshot.winnerByKey.get(targetKey);
443
+ if (!target) {
444
+ continue;
445
+ }
446
+ const id: string = `${service}\u0000${target.type}`;
447
+ let count: TopologyRunsOnCountJSON | undefined = counts.get(id);
448
+ if (!count) {
449
+ count = { service, type: target.type, active: 0, total: 0 };
450
+ counts.set(id, count);
451
+ }
452
+ count.total++;
453
+ if (isReferenceActive(target, snapshot.rangeStart)) {
454
+ count.active++;
455
+ }
456
+ }
457
+ const runsOn: Array<TopologyRunsOnCountJSON> = Array.from(
458
+ counts.values(),
459
+ ).sort(
460
+ (left: TopologyRunsOnCountJSON, right: TopologyRunsOnCountJSON): number => {
461
+ return (
462
+ compareCollateC(left.service, right.service) ||
463
+ compareCollateC(left.type, right.type)
464
+ );
465
+ },
466
+ );
467
+
468
+ if (services.length === 0) {
469
+ return {
470
+ ...envelope(snapshot),
471
+ entities: [],
472
+ dependencies: [],
473
+ runsOn: [],
474
+ entityTruncation: null,
475
+ dependencyTruncation: null,
476
+ };
477
+ }
478
+
479
+ return {
480
+ ...envelope(snapshot),
481
+ entities: [...services, ...callees].map(toServiceMapEntity),
482
+ dependencies,
483
+ runsOn,
484
+ entityTruncation: null,
485
+ dependencyTruncation: null,
486
+ };
487
+ }
488
+
489
+ // -------------------------------------------------------- infrastructure
490
+
491
+ interface NestingCandidate {
492
+ parent: ReferenceItem;
493
+ via: string;
494
+ priority: number;
495
+ specificity: number;
496
+ }
497
+
498
+ /* Semantics 4: priority DESC, specificity DESC, parent key (C) ASC. */
499
+ function compareCandidates(
500
+ left: NestingCandidate,
501
+ right: NestingCandidate,
502
+ ): number {
503
+ return (
504
+ right.priority - left.priority ||
505
+ right.specificity - left.specificity ||
506
+ compareCollateC(left.parent.key, right.parent.key)
507
+ );
508
+ }
509
+
510
+ function best(
511
+ candidates: Array<NestingCandidate>,
512
+ ): NestingCandidate | undefined {
513
+ let winner: NestingCandidate | undefined = undefined;
514
+ for (const candidate of candidates) {
515
+ if (!winner || compareCandidates(candidate, winner) < 0) {
516
+ winner = candidate;
517
+ }
518
+ }
519
+ return winner;
520
+ }
521
+
522
+ function maxDate(dates: Array<Date | null>): Date | null {
523
+ let latest: Date | null = null;
524
+ for (const date of dates) {
525
+ if (date && (!latest || date.getTime() > latest.getTime())) {
526
+ latest = date;
527
+ }
528
+ }
529
+ return latest;
530
+ }
531
+
532
+ export function referenceInfrastructure(
533
+ estate: ReferenceEstate,
534
+ scope: ReferenceScope,
535
+ options: ReferenceInfrastructureOptions = {},
536
+ ): ReferenceInfrastructureResponse {
537
+ const snapshot: ReferenceSnapshot = takeSnapshot(estate, scope);
538
+ const inlineBudget: number =
539
+ options.inlineFlatItemsPerType ?? TopologyApiLimits.InlineFlatItemsPerType;
540
+ const rangeStart: Date = snapshot.rangeStart;
541
+
542
+ /*
543
+ * Collections: a flat type with more live ROWS than the inline budget.
544
+ * Counted over rows (the collection's pages list rows of the type), with
545
+ * the latest report over all of them and over the active ones.
546
+ */
547
+ const rowsByType: Map<string, Array<ReferenceItem>> = new Map<
548
+ string,
549
+ Array<ReferenceItem>
550
+ >();
551
+ for (const item of snapshot.liveItems) {
552
+ if (!isTopologyInfrastructureType(item.type)) {
553
+ continue;
554
+ }
555
+ const rows: Array<ReferenceItem> = rowsByType.get(item.type) || [];
556
+ rows.push(item);
557
+ rowsByType.set(item.type, rows);
558
+ }
559
+ const collections: Array<TopologyInfrastructureCollectionJSON> = [];
560
+ const collectedTypes: Set<string> = new Set<string>();
561
+ for (const type of Array.from(rowsByType.keys()).sort(compareCollateC)) {
562
+ const rows: Array<ReferenceItem> = rowsByType.get(type)!;
563
+ if (!isFlatInfrastructureType(type) || rows.length <= inlineBudget) {
564
+ continue;
565
+ }
566
+ const active: Array<ReferenceItem> = rows.filter(
567
+ (item: ReferenceItem): boolean => {
568
+ return isReferenceActive(item, rangeStart);
569
+ },
570
+ );
571
+ collectedTypes.add(type);
572
+ collections.push({
573
+ type,
574
+ total: rows.length,
575
+ active: active.length,
576
+ lastSeenAt: referenceEpochMs(
577
+ maxDate(
578
+ rows.map((item: ReferenceItem): Date | null => {
579
+ return item.lastSeenAt;
580
+ }),
581
+ ),
582
+ ),
583
+ activeLastSeenAt: referenceEpochMs(
584
+ maxDate(
585
+ active.map((item: ReferenceItem): Date | null => {
586
+ return item.lastSeenAt;
587
+ }),
588
+ ),
589
+ ),
590
+ });
591
+ }
592
+
593
+ /* Semantics 4: the node set, ordered by (type, key) in code points. */
594
+ const nodes: Array<ReferenceItem> = winners(snapshot)
595
+ .filter((item: ReferenceItem): boolean => {
596
+ return (
597
+ isTopologyInfrastructureType(item.type) &&
598
+ !collectedTypes.has(item.type)
599
+ );
600
+ })
601
+ .sort((left: ReferenceItem, right: ReferenceItem): number => {
602
+ return compareCollateC(left.type, right.type) || byKeyC(left, right);
603
+ });
604
+ const nodeIndexByKey: Map<string, number> = new Map<string, number>();
605
+ nodes.forEach((node: ReferenceItem, index: number): void => {
606
+ nodeIndexByKey.set(node.key, index);
607
+ });
608
+
609
+ const candidatesByChild: Map<string, Array<NestingCandidate>> = new Map<
610
+ string,
611
+ Array<NestingCandidate>
612
+ >();
613
+ for (const relationship of snapshot.inRange) {
614
+ const priority: number | undefined =
615
+ NESTING_RELATIONSHIP_PRIORITY[
616
+ relationship.type as EntityRelationshipType
617
+ ];
618
+ if (priority === undefined || relationship.from === relationship.to) {
619
+ continue;
620
+ }
621
+ const childIndex: number | undefined = nodeIndexByKey.get(
622
+ relationship.from,
623
+ );
624
+ const parentIndex: number | undefined = nodeIndexByKey.get(relationship.to);
625
+ if (childIndex === undefined || parentIndex === undefined) {
626
+ continue;
627
+ }
628
+ const child: ReferenceItem = nodes[childIndex]!;
629
+ const parent: ReferenceItem = nodes[parentIndex]!;
630
+ const specificity: number | undefined =
631
+ CONTAINER_SPECIFICITY[parent.type as EntityType];
632
+ if (
633
+ !NESTABLE_CHILD_TYPES.has(child.type as EntityType) ||
634
+ specificity === undefined
635
+ ) {
636
+ continue;
637
+ }
638
+ const candidates: Array<NestingCandidate> =
639
+ candidatesByChild.get(child.key) || [];
640
+ candidates.push({
641
+ parent,
642
+ via: relationship.type,
643
+ priority,
644
+ specificity,
645
+ });
646
+ candidatesByChild.set(child.key, candidates);
647
+ }
648
+
649
+ let activeResources: number = 0;
650
+ const nodeRows: Array<TopologyInfrastructureNodeJSON> = nodes.map(
651
+ (node: ReferenceItem): TopologyInfrastructureNodeJSON => {
652
+ if (isReferenceActive(node, rangeStart)) {
653
+ activeResources++;
654
+ }
655
+ const row: TopologyInfrastructureNodeJSON = {
656
+ key: node.key,
657
+ type: node.type,
658
+ name: node.name,
659
+ source: node.source,
660
+ lastSeenAt: referenceEpochMs(node.lastSeenAt),
661
+ };
662
+ const candidates: Array<NestingCandidate> =
663
+ candidatesByChild.get(node.key) || [];
664
+ const parent: NestingCandidate | undefined = best(candidates);
665
+ if (!parent) {
666
+ return row;
667
+ }
668
+ row.parent = nodeIndexByKey.get(parent.parent.key)!;
669
+ row.parentVia = parent.via;
670
+ if (!isReferenceActive(parent.parent, rangeStart)) {
671
+ const activeParent: NestingCandidate | undefined = best(
672
+ candidates.filter((candidate: NestingCandidate): boolean => {
673
+ return isReferenceActive(candidate.parent, rangeStart);
674
+ }),
675
+ );
676
+ if (activeParent) {
677
+ row.activeParent = nodeIndexByKey.get(activeParent.parent.key)!;
678
+ row.activeParentVia = activeParent.via;
679
+ } else {
680
+ row.activeParent = -1;
681
+ }
682
+ }
683
+ return row;
684
+ },
685
+ );
686
+
687
+ const services: Array<ReferenceItem> = winners(snapshot)
688
+ .filter((item: ReferenceItem): boolean => {
689
+ return item.type === EntityType.Service;
690
+ })
691
+ .sort(byKeyC);
692
+ const serviceIndexByKey: Map<string, number> = new Map<string, number>();
693
+ services.forEach((service: ReferenceItem, index: number): void => {
694
+ serviceIndexByKey.set(service.key, index);
695
+ });
696
+
697
+ const placements: Array<[number, number]> = [];
698
+ for (const [service, target] of placementPairs(
699
+ snapshot,
700
+ new Set<string>(serviceIndexByKey.keys()),
701
+ )) {
702
+ const nodeIndex: number | undefined = nodeIndexByKey.get(target);
703
+ if (nodeIndex !== undefined) {
704
+ placements.push([serviceIndexByKey.get(service)!, nodeIndex]);
705
+ }
706
+ }
707
+ placements.sort((left: [number, number], right: [number, number]): number => {
708
+ return left[0] - right[0] || left[1] - right[1];
709
+ });
710
+
711
+ /*
712
+ * Infrastructure traffic: in-range calls between two different services that each
713
+ * have a placement onto a shipped node, by service index, ordered by
714
+ * (from, to). A pair is at most one row (the relationship natural key).
715
+ */
716
+ const placedServiceKeys: Set<string> = new Set<string>(
717
+ placements.map((placement: [number, number]): string => {
718
+ return services[placement[0]]!.key;
719
+ }),
720
+ );
721
+ const dependencies: Array<TopologyInfrastructureDependencyJSON> =
722
+ snapshot.inRange
723
+ .filter((relationship: ReferenceRelationship): boolean => {
724
+ return (
725
+ relationship.type === EntityRelationshipType.DependsOn &&
726
+ relationship.from !== relationship.to &&
727
+ placedServiceKeys.has(relationship.from) &&
728
+ placedServiceKeys.has(relationship.to)
729
+ );
730
+ })
731
+ .map(
732
+ (
733
+ relationship: ReferenceRelationship,
734
+ ): TopologyInfrastructureDependencyJSON => {
735
+ return {
736
+ from: serviceIndexByKey.get(relationship.from)!,
737
+ to: serviceIndexByKey.get(relationship.to)!,
738
+ callCount: relationship.callCount,
739
+ errorCount: relationship.errorCount,
740
+ avgDurationMs: relationship.avgDurationMs,
741
+ };
742
+ },
743
+ )
744
+ .sort(
745
+ (
746
+ left: TopologyInfrastructureDependencyJSON,
747
+ right: TopologyInfrastructureDependencyJSON,
748
+ ): number => {
749
+ return left.from - right.from || left.to - right.to;
750
+ },
751
+ );
752
+
753
+ let resources: number = nodes.length;
754
+ for (const collection of collections) {
755
+ resources += collection.total;
756
+ activeResources += collection.active;
757
+ }
758
+
759
+ return {
760
+ ...envelope(snapshot),
761
+ nodes: nodeRows,
762
+ services: services.map(
763
+ (service: ReferenceItem): TopologyInfrastructureServiceJSON => {
764
+ return { key: service.key, name: service.name };
765
+ },
766
+ ),
767
+ placements,
768
+ dependencies,
769
+ dependencyTruncation: null,
770
+ collections,
771
+ totals: { resources, activeResources },
772
+ truncation: null,
773
+ };
774
+ }
775
+
776
+ // ---------------------------------------------------------------- drawer
777
+
778
+ interface ClassifiedConnection {
779
+ section: TopologyConnectionSection;
780
+ label: string;
781
+ row: TopologyConnectionRowJSON;
782
+ }
783
+
784
+ function compareScanOrder(
785
+ left: ReferenceRelationship,
786
+ right: ReferenceRelationship,
787
+ ): number {
788
+ return (
789
+ right.lastSeenAt!.getTime() - left.lastSeenAt!.getTime() ||
790
+ compareIds(left.id, right.id)
791
+ );
792
+ }
793
+
794
+ function emptySection(): TopologyConnectionSectionJSON {
795
+ return { total: 0, unknownTotal: 0, rows: [], nextOffset: null };
796
+ }
797
+
798
+ function emptySections(): Record<
799
+ TopologyConnectionSection,
800
+ TopologyConnectionSectionJSON
801
+ > {
802
+ return {
803
+ calls: emptySection(),
804
+ calledBy: emptySection(),
805
+ runsOn: emptySection(),
806
+ related: emptySection(),
807
+ };
808
+ }
809
+
810
+ function detailObject(value: JSONValue | null): JSONObject | null {
811
+ return isPlainObject(value) ? (value as JSONObject) : null;
812
+ }
813
+
814
+ /*
815
+ * The drawer (semantics 8): the entity's full row and its in-range
816
+ * relationships in four sections with exact totals and the top rows.
817
+ */
818
+ export function referenceEntity(
819
+ estate: ReferenceEstate,
820
+ request: ReferenceEntityRequest,
821
+ options: ReferenceEntityOptions = {},
822
+ ): ReferenceEntityResponse {
823
+ const snapshot: ReferenceSnapshot = takeSnapshot(estate, request);
824
+ const compareLabels: (left: string, right: string) => number =
825
+ options.compareLabels || compareCollateC;
826
+ const scanLimit: number =
827
+ options.scanLimit ?? TopologyApiLimits.EntityConnectionScanLimit + 1;
828
+ const dependencyRows: number =
829
+ options.dependencyRows ?? TopologyApiLimits.EntityDependencyRows;
830
+ const otherRows: number =
831
+ options.otherRows ?? TopologyApiLimits.EntityOtherRows;
832
+
833
+ /*
834
+ * The key's live rows; a known type narrows the choice, then the winner
835
+ * rule decides.
836
+ */
837
+ const rows: Array<ReferenceItem> = snapshot.liveItems
838
+ .filter((item: ReferenceItem): boolean => {
839
+ return item.key === request.entityKey;
840
+ })
841
+ .sort((left: ReferenceItem, right: ReferenceItem): number => {
842
+ const leftRank: number =
843
+ request.entityType !== null && left.type === request.entityType ? 0 : 1;
844
+ const rightRank: number =
845
+ request.entityType !== null && right.type === request.entityType
846
+ ? 0
847
+ : 1;
848
+ return leftRank - rightRank || compareWinner(left, right);
849
+ });
850
+ const item: ReferenceItem | undefined = rows[0];
851
+ if (!item) {
852
+ return {
853
+ ...envelope(snapshot),
854
+ entity: null,
855
+ sections: emptySections(),
856
+ isScanLimited: false,
857
+ };
858
+ }
859
+
860
+ const entity: TopologyEntityDetailJSON = {
861
+ key: item.key,
862
+ type: item.type,
863
+ name: item.name,
864
+ source: item.source,
865
+ lastSeenAt: referenceEpochMs(item.lastSeenAt),
866
+ id: item.id,
867
+ firstSeenAt: referenceEpochMs(item.firstSeenAt),
868
+ resourceType: item.resourceType,
869
+ resourceId: item.resourceId,
870
+ identifyingAttributes: detailObject(item.identifyingAttributes),
871
+ descriptiveAttributes: detailObject(item.descriptiveAttributes),
872
+ };
873
+
874
+ /* Outbound first, then inbound (self-loops once, as outbound). */
875
+ const outbound: Array<ReferenceRelationship> = snapshot.inRange
876
+ .filter((relationship: ReferenceRelationship): boolean => {
877
+ return relationship.from === item.key;
878
+ })
879
+ .sort(compareScanOrder)
880
+ .slice(0, scanLimit);
881
+ const inbound: Array<ReferenceRelationship> = snapshot.inRange
882
+ .filter((relationship: ReferenceRelationship): boolean => {
883
+ return relationship.to === item.key && relationship.from !== item.key;
884
+ })
885
+ .sort(compareScanOrder)
886
+ .slice(0, Math.max(0, scanLimit - outbound.length));
887
+
888
+ const isService: boolean = item.type === EntityType.Service;
889
+ const classified: Array<ClassifiedConnection> = [];
890
+ const scanned: Array<[ReferenceRelationship, "out" | "in"]> = [
891
+ ...outbound.map(
892
+ (
893
+ relationship: ReferenceRelationship,
894
+ ): [ReferenceRelationship, "out" | "in"] => {
895
+ return [relationship, "out"];
896
+ },
897
+ ),
898
+ ...inbound.map(
899
+ (
900
+ relationship: ReferenceRelationship,
901
+ ): [ReferenceRelationship, "out" | "in"] => {
902
+ return [relationship, "in"];
903
+ },
904
+ ),
905
+ ];
906
+ for (const [relationship, direction] of scanned) {
907
+ const otherKey: string =
908
+ direction === "out" ? relationship.to : relationship.from;
909
+ const other: ReferenceItem | undefined = snapshot.winnerByKey.get(otherKey);
910
+ let section: TopologyConnectionSection = "related";
911
+ if (relationship.type === EntityRelationshipType.DependsOn) {
912
+ section = direction === "out" ? "calls" : "calledBy";
913
+ } else if (
914
+ direction === "out" &&
915
+ isService &&
916
+ PLACEMENT_RELATIONSHIP_TYPES.has(relationship.type)
917
+ ) {
918
+ section = "runsOn";
919
+ }
920
+ const label: string =
921
+ other && other.name
922
+ ? other.name
923
+ : other
924
+ ? REFERENCE_UNNAMED_RESOURCE_LABEL
925
+ : REFERENCE_UNDISCOVERED_RESOURCE_LABEL;
926
+ classified.push({
927
+ section,
928
+ label,
929
+ row: {
930
+ relationshipType: relationship.type,
931
+ direction,
932
+ otherKey,
933
+ otherKnown: Boolean(other),
934
+ otherName: other ? other.name : null,
935
+ otherType: other ? other.type : null,
936
+ callCount: relationship.callCount,
937
+ errorCount: relationship.errorCount,
938
+ avgDurationMs: relationship.avgDurationMs,
939
+ lastSeenAt: referenceEpochMs(relationship.lastSeenAt),
940
+ },
941
+ });
942
+ }
943
+
944
+ /*
945
+ * calls / calledBy: callCount DESC NULLS LAST; runsOn / related: known
946
+ * first. Then label, other key (C), and — for rows that still tie (one
947
+ * other end reached by several relationship types or both directions) —
948
+ * relationship type (C) and direction.
949
+ */
950
+ const compareRows: (
951
+ left: ClassifiedConnection,
952
+ right: ClassifiedConnection,
953
+ ) => number = (
954
+ left: ClassifiedConnection,
955
+ right: ClassifiedConnection,
956
+ ): number => {
957
+ let primary: number = 0;
958
+ if (left.section === "calls" || left.section === "calledBy") {
959
+ const a: number | null = left.row.callCount;
960
+ const b: number | null = right.row.callCount;
961
+ if (a === null || b === null) {
962
+ primary = (a === null ? 1 : 0) - (b === null ? 1 : 0);
963
+ } else {
964
+ primary = b - a;
965
+ }
966
+ } else {
967
+ primary = Number(right.row.otherKnown) - Number(left.row.otherKnown);
968
+ }
969
+ return (
970
+ primary ||
971
+ compareLabels(left.label, right.label) ||
972
+ compareCollateC(left.row.otherKey, right.row.otherKey) ||
973
+ compareCollateC(left.row.relationshipType, right.row.relationshipType) ||
974
+ compareCollateC(left.row.direction, right.row.direction)
975
+ );
976
+ };
977
+
978
+ const sections: Record<
979
+ TopologyConnectionSection,
980
+ TopologyConnectionSectionJSON
981
+ > = emptySections();
982
+ for (const section of TOPOLOGY_CONNECTION_SECTIONS) {
983
+ const members: Array<ClassifiedConnection> = classified
984
+ .filter((connection: ClassifiedConnection): boolean => {
985
+ return connection.section === section;
986
+ })
987
+ .sort(compareRows);
988
+ const budget: number =
989
+ section === "calls" || section === "calledBy"
990
+ ? dependencyRows
991
+ : otherRows;
992
+ const shown: Array<TopologyConnectionRowJSON> = members
993
+ .slice(0, budget)
994
+ .map((connection: ClassifiedConnection): TopologyConnectionRowJSON => {
995
+ return connection.row;
996
+ });
997
+ sections[section] = {
998
+ total: members.length,
999
+ unknownTotal: members.filter(
1000
+ (connection: ClassifiedConnection): boolean => {
1001
+ return !connection.row.otherKnown;
1002
+ },
1003
+ ).length,
1004
+ rows: shown,
1005
+ nextOffset:
1006
+ shown.length > 0 && shown.length < members.length ? shown.length : null,
1007
+ };
1008
+ }
1009
+
1010
+ return {
1011
+ ...envelope(snapshot),
1012
+ entity,
1013
+ sections,
1014
+ isScanLimited:
1015
+ outbound.length + inbound.length >
1016
+ TopologyApiLimits.EntityConnectionScanLimit,
1017
+ };
1018
+ }