@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.
- package/Models/DatabaseModels/InventoryItem.ts +23 -12
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/AlertService.ts +32 -36
- package/Server/Services/IncidentAlertService.ts +609 -2
- package/Server/Services/IncidentService.ts +134 -44
- package/Server/Services/InventoryItemService.ts +38 -0
- package/Server/Services/IoTDeviceCredentialService.ts +13 -4
- package/Server/Services/ScheduledMaintenanceService.ts +50 -46
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- package/Server/Utils/Response.ts +31 -0
- package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
- package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
- package/Server/Utils/Topology/TopologyRequest.ts +463 -0
- package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
- package/Server/Utils/Topology/TopologySql.ts +1024 -0
- package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
- package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
- package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
- package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
- package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
- package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
- package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
- package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
- package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
- package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
- package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
- package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
- package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
- package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
- package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
- package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
- package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
- package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
- package/Tests/Server/Services/DomainService.test.ts +525 -0
- package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
- package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
- package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
- package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
- package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
- package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
- package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
- package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
- package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
- package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
- package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
- package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
- package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
- package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
- package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
- package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
- package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -0
- package/Utils/Incident/IncidentFromAlerts.ts +78 -0
- package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +21 -33
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +457 -3
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +96 -42
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +31 -0
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
- package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +30 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
- package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
- package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
- package/build/dist/Types/Topology/TopologyApi.js +55 -0
- package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- 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
|
+
}
|