@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
@@ -1,14 +1,70 @@
1
1
  import "@testing-library/jest-dom";
2
- import { afterEach, describe, expect, jest, test } from "@jest/globals";
3
- import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ RenderResult,
16
+ screen,
17
+ waitFor,
18
+ within,
19
+ } from "@testing-library/react";
4
20
  import * as React from "react";
5
- import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
6
- import InventoryItemRelationship from "../../../Models/DatabaseModels/InventoryItemRelationship";
7
- import EntityType from "../../../Types/Telemetry/EntityType";
8
- import EntityRelationshipType from "../../../Types/Telemetry/EntityRelationshipType";
9
- import EntityDetailPanel from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/EntityDetailPanel";
21
+ import type { SpyInstance } from "jest-mock";
10
22
  import getJestMockFunction, { MockFunction } from "../../MockType";
11
23
 
24
+ /*
25
+ * The topology detail drawer. It opens on what the map already knows (the
26
+ * header renders at once), then asks the server for the entity's full row
27
+ * and its connections — classified, ordered and counted over the whole
28
+ * inventory — and pages further rows on "Show more". Pinned here against a
29
+ * fake Topology API behind the real request and decoding code:
30
+ *
31
+ * - the header before the data, a busy status while loading;
32
+ * - exact server totals ("Calls (N)", "N+" when the scan stopped early),
33
+ * server order, and paging that appends and moves focus;
34
+ * - connections to resources nothing reported as static text;
35
+ * - a key no item has any more, failures with an in-drawer retry;
36
+ * - never a row fetched for another entity or range;
37
+ * - best-effort links resolved once, from the full row;
38
+ * - where each row leads.
39
+ */
40
+
41
+ const postMock: MockFunction = getJestMockFunction();
42
+ const getListMock: MockFunction = getJestMockFunction();
43
+
44
+ // The arrow wrappers are load bearing: jest.mock is hoisted above the mocks.
45
+ jest.mock("../../../UI/Utils/API/API", () => {
46
+ return {
47
+ __esModule: true,
48
+ default: {
49
+ post: (...args: Array<any>) => {
50
+ return postMock(...args);
51
+ },
52
+ },
53
+ };
54
+ });
55
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
56
+ return {
57
+ __esModule: true,
58
+ default: {
59
+ getList: (...args: Array<any>) => {
60
+ return getListMock(...args);
61
+ },
62
+ getCommonHeaders: () => {
63
+ return { tenantid: "project-1" };
64
+ },
65
+ },
66
+ };
67
+ });
12
68
  jest.mock("../../../UI/Utils/Translation", () => {
13
69
  return {
14
70
  __esModule: true,
@@ -28,119 +84,546 @@ jest.mock("../../../UI/Components/SideOver/SideOver", () => {
28
84
  return {
29
85
  __esModule: true,
30
86
  SideOverSize: { Small: "small" },
31
- default: (props: { title: string; children: React.ReactNode }) => {
32
- return <section aria-label={props.title}>{props.children}</section>;
87
+ default: (props: {
88
+ title: string;
89
+ description: string;
90
+ children: React.ReactNode;
91
+ }) => {
92
+ return (
93
+ <section aria-label={props.title}>
94
+ <p data-testid="drawer-description">{props.description}</p>
95
+ {props.children}
96
+ </section>
97
+ );
33
98
  },
34
99
  };
35
100
  });
36
101
 
37
- function entity(key: string, label: string): InventoryItem {
102
+ import EntityDetailPanel, {
103
+ ComponentProps,
104
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/EntityDetailPanel";
105
+ import { EntityDetailTarget } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyData";
106
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
107
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
108
+ import { JSONObject } from "../../../Types/JSON";
109
+ import ObjectID from "../../../Types/ObjectID";
110
+ import EntityType from "../../../Types/Telemetry/EntityType";
111
+ import {
112
+ TOPOLOGY_API_FORMAT_VERSION,
113
+ TopologyConnectionRowJSON,
114
+ TopologyConnectionSection,
115
+ TopologyConnectionSectionJSON,
116
+ TopologyEntityConnectionsResponseJSON,
117
+ TopologyEntityDetailJSON,
118
+ TopologyEntityResponseJSON,
119
+ } from "../../../Types/Topology/TopologyApi";
120
+ import Navigation from "../../../UI/Utils/Navigation";
121
+ import ProjectUtil from "../../../UI/Utils/Project";
122
+
123
+ const PROJECT_ID: ObjectID = new ObjectID(
124
+ "5b2f5b1c-0000-4000-8000-000000000001",
125
+ );
126
+ const SERVICE_ID: string = "5e7f0000-0000-4000-8000-000000000001";
127
+ const ITEM_ID: string = "1a1a0000-0000-4000-8000-000000000001";
128
+ const RANGE_START: Date = new Date("2026-09-26T10:00:00.000Z");
129
+ const LATER_RANGE_START: Date = new Date("2026-09-26T11:00:00.000Z");
130
+
131
+ interface PostRequest {
132
+ url: { toString: () => string };
133
+ data: JSONObject;
134
+ options: { signal?: AbortSignal };
135
+ }
136
+
137
+ type Answer = JSONObject | HTTPErrorResponse | Promise<JSONObject>;
138
+
139
+ interface Deferred {
140
+ promise: Promise<JSONObject>;
141
+ resolve: (value: JSONObject) => void;
142
+ reject: (error: unknown) => void;
143
+ }
144
+
145
+ function deferred(): Deferred {
146
+ let resolve: (value: JSONObject) => void = () => {
147
+ return undefined;
148
+ };
149
+ let reject: (error: unknown) => void = () => {
150
+ return undefined;
151
+ };
152
+ const promise: Promise<JSONObject> = new Promise<JSONObject>(
153
+ (res: (value: JSONObject) => void, rej: (error: unknown) => void) => {
154
+ resolve = res;
155
+ reject = rej;
156
+ },
157
+ );
158
+ return { promise, resolve, reject };
159
+ }
160
+
161
+ function asJSON(value: unknown): JSONObject {
162
+ return JSON.parse(JSON.stringify(value)) as JSONObject;
163
+ }
164
+
165
+ function entityRow(
166
+ overrides: Partial<TopologyEntityDetailJSON> = {},
167
+ ): TopologyEntityDetailJSON {
38
168
  return {
39
- entityKey: key,
40
- displayName: label,
41
- entityType: EntityType.Container,
42
- } as InventoryItem;
169
+ id: ITEM_ID,
170
+ key: "svc",
171
+ type: EntityType.Service,
172
+ name: "Checkout API",
173
+ source: "telemetry",
174
+ lastSeenAt: Date.parse("2026-09-26T10:14:00.000Z"),
175
+ firstSeenAt: Date.parse("2026-08-01T00:00:00.000Z"),
176
+ resourceType: "Service",
177
+ resourceId: SERVICE_ID,
178
+ identifyingAttributes: { "service.name": "Checkout API" },
179
+ descriptiveAttributes: { "telemetry.sdk.language": "nodejs" },
180
+ ...overrides,
181
+ };
43
182
  }
44
- const API: InventoryItem = entity("api", "Checkout API");
45
- const DATABASE: InventoryItem = entity("db", "Orders database");
46
- const WEB: InventoryItem = entity("web", "Web frontend");
47
- const RELATIONSHIPS: Array<InventoryItemRelationship> = [
48
- {
49
- fromEntityKey: "api",
50
- toEntityKey: "db",
51
- relationshipType: EntityRelationshipType.DependsOn,
52
- callCount: 100,
53
- errorCount: 2,
54
- avgDurationMs: 30,
55
- },
56
- {
57
- fromEntityKey: "web",
58
- toEntityKey: "api",
59
- relationshipType: EntityRelationshipType.DependsOn,
60
- },
183
+
184
+ function connection(
185
+ overrides: Partial<TopologyConnectionRowJSON>,
186
+ ): TopologyConnectionRowJSON {
187
+ return {
188
+ relationshipType: "depends-on",
189
+ direction: "out",
190
+ otherKey: "other",
191
+ otherKnown: true,
192
+ otherName: "Other",
193
+ otherType: EntityType.Service,
194
+ callCount: null,
195
+ errorCount: null,
196
+ avgDurationMs: null,
197
+ lastSeenAt: null,
198
+ ...overrides,
199
+ };
200
+ }
201
+
202
+ function sectionOf(
203
+ rows: Array<TopologyConnectionRowJSON>,
204
+ overrides: Partial<TopologyConnectionSectionJSON> = {},
205
+ ): TopologyConnectionSectionJSON {
206
+ return {
207
+ total: rows.length,
208
+ unknownTotal: 0,
209
+ rows: rows,
210
+ nextOffset: null,
211
+ ...overrides,
212
+ };
213
+ }
214
+
215
+ function pod(index: number): TopologyConnectionRowJSON {
216
+ return connection({
217
+ relationshipType: "runs-on",
218
+ otherKey: `pod-${index}`,
219
+ otherName: `checkout-pod-${index}`,
220
+ otherType: EntityType.KubernetesPod,
221
+ });
222
+ }
223
+
224
+ function pods(from: number, to: number): Array<TopologyConnectionRowJSON> {
225
+ const rows: Array<TopologyConnectionRowJSON> = [];
226
+ for (let index: number = from; index <= to; index++) {
227
+ rows.push(pod(index));
228
+ }
229
+ return rows;
230
+ }
231
+
232
+ const EMPTY: Record<TopologyConnectionSection, TopologyConnectionSectionJSON> =
61
233
  {
62
- fromEntityKey: "api",
63
- toEntityKey: "missing",
64
- relationshipType: EntityRelationshipType.DependsOn,
65
- },
66
- ] as Array<InventoryItemRelationship>;
67
-
68
- function renderPanel(
69
- options: {
70
- onSelectEntity?: (key: string) => void;
71
- onFocus?: (key: string) => void;
72
- focusButtonLabel?: string;
73
- } = {},
74
- ): void {
75
- render(
234
+ calls: sectionOf([]),
235
+ calledBy: sectionOf([]),
236
+ runsOn: sectionOf([]),
237
+ related: sectionOf([]),
238
+ };
239
+
240
+ function entityResponse(
241
+ entity: TopologyEntityDetailJSON | null,
242
+ sections: Partial<
243
+ Record<TopologyConnectionSection, TopologyConnectionSectionJSON>
244
+ > = {},
245
+ isScanLimited: boolean = false,
246
+ ): JSONObject {
247
+ const response: TopologyEntityResponseJSON = {
248
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION,
249
+ rangeStart: RANGE_START.toISOString(),
250
+ generatedAt: "2026-09-26T10:15:00.000Z",
251
+ entity: entity,
252
+ sections: { ...EMPTY, ...sections },
253
+ isScanLimited: isScanLimited,
254
+ };
255
+ return asJSON(response);
256
+ }
257
+
258
+ function connectionsResponse(
259
+ section: TopologyConnectionSection,
260
+ connections: TopologyConnectionSectionJSON,
261
+ isScanLimited: boolean = false,
262
+ ): JSONObject {
263
+ const response: TopologyEntityConnectionsResponseJSON = {
264
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION,
265
+ rangeStart: RANGE_START.toISOString(),
266
+ generatedAt: "2026-09-26T10:15:00.000Z",
267
+ section: section,
268
+ connections: connections,
269
+ isScanLimited: isScanLimited,
270
+ };
271
+ return asJSON(response);
272
+ }
273
+
274
+ /* The service the drawer usually shows: calls, callers and where it runs. */
275
+ function checkoutResponse(): JSONObject {
276
+ return entityResponse(entityRow(), {
277
+ calls: sectionOf([
278
+ connection({
279
+ otherKey: "db",
280
+ otherName: "Orders database",
281
+ otherType: EntityType.Database,
282
+ callCount: 100,
283
+ errorCount: 2,
284
+ avgDurationMs: 30,
285
+ }),
286
+ connection({
287
+ otherKey: "missing",
288
+ otherKnown: false,
289
+ otherName: null,
290
+ otherType: null,
291
+ }),
292
+ ]),
293
+ calledBy: sectionOf([
294
+ connection({
295
+ direction: "in",
296
+ otherKey: "web",
297
+ otherName: "Web frontend",
298
+ }),
299
+ ]),
300
+ runsOn: sectionOf(pods(1, 25), {
301
+ total: 40,
302
+ unknownTotal: 3,
303
+ nextOffset: 25,
304
+ }),
305
+ });
306
+ }
307
+
308
+ function isConnectionsRequest(request: PostRequest): boolean {
309
+ return request.url.toString().endsWith("/entity/connections");
310
+ }
311
+
312
+ function requests(): Array<PostRequest> {
313
+ return postMock.mock.calls.map((call: Array<unknown>): PostRequest => {
314
+ return call[0] as PostRequest;
315
+ });
316
+ }
317
+
318
+ function entityRequests(): Array<PostRequest> {
319
+ return requests().filter((request: PostRequest): boolean => {
320
+ return !isConnectionsRequest(request);
321
+ });
322
+ }
323
+
324
+ /*
325
+ * Answer each request by its SHAPE (which endpoint, which key), so tests pin
326
+ * what was asked for rather than the order requests were issued in.
327
+ */
328
+ function serve(answer: (request: PostRequest) => Answer): void {
329
+ postMock.mockImplementation(async (...args: Array<unknown>) => {
330
+ const request: PostRequest = args[0] as PostRequest;
331
+ const result: Answer = answer(request);
332
+ if (result instanceof HTTPErrorResponse) {
333
+ return result;
334
+ }
335
+ return new HTTPResponse<JSONObject>(200, await result, {});
336
+ });
337
+ }
338
+
339
+ function serveEntity(response: JSONObject): void {
340
+ serve((request: PostRequest): Answer => {
341
+ if (isConnectionsRequest(request)) {
342
+ throw new Error("No page is served in this test.");
343
+ }
344
+ return response;
345
+ });
346
+ }
347
+
348
+ const CHECKOUT: EntityDetailTarget = {
349
+ entityKey: "svc",
350
+ entityType: EntityType.Service,
351
+ displayName: "Checkout API",
352
+ };
353
+
354
+ function renderPanel(overrides: Partial<ComponentProps> = {}): RenderResult {
355
+ return render(
76
356
  <EntityDetailPanel
77
- entity={API}
78
- relationships={RELATIONSHIPS}
79
- entityByKey={
80
- new Map<string, InventoryItem>([
81
- ["api", API],
82
- ["db", DATABASE],
83
- ["web", WEB],
84
- ])
85
- }
357
+ entity={CHECKOUT}
358
+ rangeStart={RANGE_START}
86
359
  metricsWindowSeconds={60}
87
360
  onClose={() => {
88
361
  return undefined;
89
362
  }}
90
- onFocus={
91
- options.onFocus ||
92
- (() => {
93
- return undefined;
94
- })
95
- }
96
- onSelectEntity={options.onSelectEntity}
97
- focusButtonLabel={options.focusButtonLabel}
363
+ {...overrides}
98
364
  />,
99
365
  );
100
366
  }
101
367
 
368
+ async function waitForConnections(): Promise<void> {
369
+ await waitFor(() => {
370
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
371
+ });
372
+ }
373
+
374
+ beforeEach(() => {
375
+ postMock.mockReset();
376
+ getListMock.mockReset();
377
+ getListMock.mockImplementation(async () => {
378
+ return { data: [], count: 0 };
379
+ });
380
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
381
+ });
382
+
102
383
  afterEach(() => {
103
384
  cleanup();
385
+ jest.restoreAllMocks();
104
386
  });
105
387
 
106
- describe("topology connection details", () => {
107
- test("offers a clear explore-connections action for the selected resource", () => {
108
- const onFocus: MockFunction = getJestMockFunction();
109
- renderPanel({ onFocus });
110
- fireEvent.click(
111
- screen.getByRole("button", { name: "Explore connections" }),
388
+ describe("opening the drawer", () => {
389
+ test("the header renders from the preview before the server answers", async () => {
390
+ const answer: Deferred = deferred();
391
+ serve((): Answer => {
392
+ return answer.promise;
393
+ });
394
+
395
+ renderPanel();
396
+
397
+ expect(
398
+ screen.getByRole("region", { name: "Checkout API" }),
399
+ ).toBeInTheDocument();
400
+ expect(screen.getByTestId("drawer-description")).toHaveTextContent(
401
+ "Service",
402
+ );
403
+ const status: HTMLElement = screen.getByRole("status");
404
+ expect(status).toHaveAttribute("aria-busy", "true");
405
+ expect(status).toHaveTextContent("Loading connections…");
406
+ expect(screen.queryByTestId("entity-detail-calls")).not.toBeInTheDocument();
407
+ expect(screen.queryByText("Inventory details")).not.toBeInTheDocument();
408
+
409
+ await act(async () => {
410
+ answer.resolve(checkoutResponse());
411
+ });
412
+
413
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
414
+ expect(screen.getByTestId("entity-detail-calls")).toBeInTheDocument();
415
+ });
416
+
417
+ test("asks for the entity once, with the key, the known type and the map's range start", async () => {
418
+ serveEntity(checkoutResponse());
419
+
420
+ renderPanel();
421
+ await waitForConnections();
422
+
423
+ expect(entityRequests()).toHaveLength(1);
424
+ expect(entityRequests()[0]!.url.toString()).toMatch(
425
+ /\/telemetry\/topology\/entity$/,
112
426
  );
113
- expect(onFocus).toHaveBeenCalledWith("api");
114
- expect(screen.queryByText("Focus on this node")).not.toBeInTheDocument();
427
+ expect(entityRequests()[0]!.data).toEqual({
428
+ rangeStart: RANGE_START.toISOString(),
429
+ entityKey: "svc",
430
+ entityType: EntityType.Service,
431
+ });
115
432
  });
116
433
 
117
- test("infrastructure can describe its resource exploration action", () => {
118
- renderPanel({ focusButtonLabel: "Explore this resource" });
434
+ test("a deep link that knows only the key shows the key until the row arrives", async () => {
435
+ const answer: Deferred = deferred();
436
+ serve((): Answer => {
437
+ return answer.promise;
438
+ });
439
+
440
+ renderPanel({ entity: { entityKey: "pod-key" } });
441
+
442
+ expect(screen.getByRole("region", { name: "pod-key" })).toBeInTheDocument();
443
+ expect(entityRequests()[0]!.data).toEqual({
444
+ rangeStart: RANGE_START.toISOString(),
445
+ entityKey: "pod-key",
446
+ });
447
+
448
+ await act(async () => {
449
+ answer.resolve(
450
+ entityResponse(
451
+ entityRow({
452
+ key: "pod-key",
453
+ type: EntityType.KubernetesPod,
454
+ name: "checkout-7d9f",
455
+ }),
456
+ ),
457
+ );
458
+ });
459
+
119
460
  expect(
120
- screen.getByRole("button", { name: "Explore this resource" }),
461
+ screen.getByRole("region", { name: "checkout-7d9f" }),
121
462
  ).toBeInTheDocument();
463
+ expect(screen.getByTestId("drawer-description")).toHaveTextContent(
464
+ "K8s Pod",
465
+ );
122
466
  });
123
467
 
124
- test("known dependencies and callers can be explored without closing the drawer", () => {
125
- const onSelectEntity: MockFunction = getJestMockFunction();
126
- renderPanel({ onSelectEntity });
127
- fireEvent.click(
128
- screen.getByRole("button", { name: "View details for Orders database" }),
468
+ test("without a range start yet, nothing is requested and the drawer keeps loading", () => {
469
+ serveEntity(checkoutResponse());
470
+
471
+ renderPanel({ rangeStart: null });
472
+
473
+ expect(postMock).not.toHaveBeenCalled();
474
+ expect(screen.getByRole("status")).toHaveTextContent(
475
+ "Loading connections…",
129
476
  );
130
- expect(onSelectEntity).toHaveBeenLastCalledWith("db");
131
- fireEvent.click(
132
- screen.getByRole("button", { name: "View details for Web frontend" }),
477
+ });
478
+
479
+ test("closing the drawer mid-load cancels the request", async () => {
480
+ const answer: Deferred = deferred();
481
+ serve((): Answer => {
482
+ return answer.promise;
483
+ });
484
+
485
+ const view: RenderResult = renderPanel();
486
+ const signal: AbortSignal | undefined = entityRequests()[0]!.options.signal;
487
+ expect(signal?.aborted).toBe(false);
488
+
489
+ view.unmount();
490
+
491
+ expect(signal?.aborted).toBe(true);
492
+ });
493
+ });
494
+
495
+ describe("connection sections", () => {
496
+ test("headers carry the server's exact totals, not the rows it sent", async () => {
497
+ serveEntity(checkoutResponse());
498
+
499
+ renderPanel();
500
+ await waitForConnections();
501
+
502
+ expect(
503
+ within(screen.getByTestId("entity-detail-calls")).getByRole("heading"),
504
+ ).toHaveTextContent("Calls (2)");
505
+ expect(
506
+ within(screen.getByTestId("entity-detail-called-by")).getByRole(
507
+ "heading",
508
+ ),
509
+ ).toHaveTextContent("Called by (1)");
510
+ expect(
511
+ within(screen.getByTestId("entity-detail-runs-on")).getByRole("heading"),
512
+ ).toHaveTextContent("Runs on (40)");
513
+ expect(
514
+ within(screen.getByTestId("entity-detail-runs-on")).getAllByRole(
515
+ "listitem",
516
+ ),
517
+ ).toHaveLength(25);
518
+ expect(screen.getByText("Showing 25 of 40")).toBeInTheDocument();
519
+ expect(
520
+ screen.queryByTestId("entity-detail-related"),
521
+ ).not.toBeInTheDocument();
522
+ });
523
+
524
+ test("large totals are grouped, and a limited scan marks every total as a lower bound", async () => {
525
+ serveEntity(
526
+ entityResponse(
527
+ entityRow(),
528
+ {
529
+ related: sectionOf(pods(1, 25), {
530
+ total: 100_000,
531
+ nextOffset: 25,
532
+ }),
533
+ calledBy: sectionOf(
534
+ [connection({ direction: "in", otherKey: "web" })],
535
+ { total: 1 },
536
+ ),
537
+ },
538
+ true,
539
+ ),
540
+ );
541
+
542
+ renderPanel();
543
+ await waitForConnections();
544
+
545
+ expect(
546
+ within(screen.getByTestId("entity-detail-related")).getByRole("heading"),
547
+ ).toHaveTextContent("Related infrastructure (100,000+)");
548
+ expect(
549
+ within(screen.getByTestId("entity-detail-called-by")).getByRole(
550
+ "heading",
551
+ ),
552
+ ).toHaveTextContent("Called by (1+)");
553
+ expect(screen.getByTestId("entity-detail-scan-limited")).toHaveTextContent(
554
+ "totals are lower bounds",
555
+ );
556
+ });
557
+
558
+ test("an exact response says nothing about lower bounds", async () => {
559
+ serveEntity(checkoutResponse());
560
+
561
+ renderPanel();
562
+ await waitForConnections();
563
+
564
+ expect(
565
+ screen.queryByTestId("entity-detail-scan-limited"),
566
+ ).not.toBeInTheDocument();
567
+ expect(screen.queryByText(/\+\)/)).not.toBeInTheDocument();
568
+ });
569
+
570
+ test("rows keep the server's order", async () => {
571
+ serveEntity(
572
+ entityResponse(entityRow(), {
573
+ related: sectionOf([
574
+ connection({
575
+ relationshipType: "member-of",
576
+ otherKey: "z",
577
+ otherName: "Zulu",
578
+ otherType: EntityType.KubernetesNamespace,
579
+ }),
580
+ connection({
581
+ relationshipType: "member-of",
582
+ otherKey: "a",
583
+ otherName: "Alpha",
584
+ otherType: EntityType.KubernetesNamespace,
585
+ }),
586
+ ]),
587
+ }),
133
588
  );
134
- expect(onSelectEntity).toHaveBeenLastCalledWith("web");
589
+
590
+ renderPanel();
591
+ await waitForConnections();
592
+
593
+ const items: Array<HTMLElement> = within(
594
+ screen.getByTestId("entity-detail-related"),
595
+ ).getAllByRole("listitem");
596
+ expect(items[0]).toHaveTextContent("Checkout API member of Zulu");
597
+ expect(items[1]).toHaveTextContent("Checkout API member of Alpha");
598
+ });
599
+
600
+ test("outbound rows read from the entity, inbound rows towards it, with call metrics", async () => {
601
+ serveEntity(checkoutResponse());
602
+
603
+ renderPanel();
604
+ await waitForConnections();
605
+
606
+ expect(
607
+ screen.getByText("Checkout API depends on Orders database"),
608
+ ).toBeInTheDocument();
609
+ expect(
610
+ screen.getByText("Web frontend depends on Checkout API"),
611
+ ).toBeInTheDocument();
135
612
  expect(screen.getByText(/100\/min/)).toHaveTextContent("2.0% errors");
613
+ expect(screen.getByText(/100\/min/)).toHaveTextContent("avg 30ms");
614
+ expect(screen.getByText(/100\/min/)).toHaveTextContent("Database");
136
615
  });
137
616
 
138
- test("unresolved connections are explained and never presented as broken actions", () => {
617
+ test("connections to resources nothing reported are static text, never broken actions", async () => {
618
+ serveEntity(checkoutResponse());
619
+
139
620
  renderPanel({
140
621
  onSelectEntity: () => {
141
622
  return undefined;
142
623
  },
143
624
  });
625
+ await waitForConnections();
626
+
144
627
  expect(
145
628
  screen.getByText("Checkout API depends on Undiscovered resource"),
146
629
  ).toBeInTheDocument();
@@ -149,180 +632,1416 @@ describe("topology connection details", () => {
149
632
  ).not.toBeInTheDocument();
150
633
  });
151
634
 
152
- test("callers without navigation support retain readable static connections", () => {
635
+ test("where it runs and related infrastructure say how many ends are no longer in inventory", async () => {
636
+ serveEntity(
637
+ entityResponse(entityRow(), {
638
+ runsOn: sectionOf(pods(1, 2), { total: 5, unknownTotal: 3 }),
639
+ related: sectionOf(
640
+ [
641
+ connection({
642
+ relationshipType: "member-of",
643
+ otherKey: "gone",
644
+ otherKnown: false,
645
+ otherName: null,
646
+ otherType: null,
647
+ }),
648
+ ],
649
+ { unknownTotal: 1 },
650
+ ),
651
+ calls: sectionOf(
652
+ [
653
+ connection({
654
+ otherKey: "gone-dep",
655
+ otherKnown: false,
656
+ otherName: null,
657
+ }),
658
+ ],
659
+ { unknownTotal: 1 },
660
+ ),
661
+ }),
662
+ );
663
+
153
664
  renderPanel();
665
+ await waitForConnections();
666
+
154
667
  expect(
155
- screen.getByText("Checkout API depends on Orders database"),
668
+ within(screen.getByTestId("entity-detail-runs-on")).getByText(
669
+ "3 no longer in inventory",
670
+ ),
156
671
  ).toBeInTheDocument();
157
672
  expect(
158
- screen.queryByRole("button", {
159
- name: "View details for Orders database",
160
- }),
673
+ within(screen.getByTestId("entity-detail-related")).getByText(
674
+ "1 no longer in inventory",
675
+ ),
676
+ ).toBeInTheDocument();
677
+ /* Calls name their unknown ends row by row instead. */
678
+ expect(
679
+ within(screen.getByTestId("entity-detail-calls")).queryByText(
680
+ /no longer in inventory/,
681
+ ),
161
682
  ).not.toBeInTheDocument();
162
683
  });
163
- });
164
-
165
- describe("service map details", () => {
166
- const SERVICE: InventoryItem = {
167
- entityKey: "svc",
168
- displayName: "api",
169
- entityType: EntityType.Service,
170
- descriptiveAttributes: { "telemetry.sdk.language": "nodejs" },
171
- } as unknown as InventoryItem;
172
- const POD: InventoryItem = {
173
- entityKey: "pod",
174
- displayName: "api-6d4f8b9c7d-x2k9p",
175
- entityType: EntityType.KubernetesPod,
176
- } as InventoryItem;
177
- const POSTGRES: InventoryItem = {
178
- entityKey: "pg",
179
- displayName: "orders",
180
- entityType: EntityType.Database,
181
- identifyingAttributes: { "db.system.name": "postgresql" },
182
- } as unknown as InventoryItem;
183
- const byKey: Map<string, InventoryItem> = new Map<string, InventoryItem>([
184
- ["svc", SERVICE],
185
- ["pod", POD],
186
- ["pg", POSTGRES],
187
- ]);
188
- const relationships: Array<InventoryItemRelationship> = [
189
- {
190
- fromEntityKey: "svc",
191
- toEntityKey: "pg",
192
- relationshipType: EntityRelationshipType.DependsOn,
193
- callCount: 60,
194
- errorCount: 0,
195
- avgDurationMs: 4,
196
- },
197
- {
198
- fromEntityKey: "svc",
199
- toEntityKey: "pod",
200
- relationshipType: EntityRelationshipType.RunsOn,
201
- },
202
- ] as Array<InventoryItemRelationship>;
203
684
 
204
- function renderService(
205
- options: {
206
- onOpenInfrastructure?: (key: string) => void;
207
- onSelectEntity?: (key: string) => void;
208
- } = {},
209
- ): void {
210
- render(
211
- <EntityDetailPanel
212
- entity={SERVICE}
213
- relationships={relationships}
214
- entityByKey={byKey}
215
- metricsWindowSeconds={60}
216
- traffic={{
217
- inbound: { calls: 0, errors: 0, avgDurationMs: null },
218
- outbound: { calls: 60, errors: 0, avgDurationMs: 4 },
219
- statusLabel: "Entry point",
220
- statusColor: "#6366f1",
221
- subtitle: "Service · Node.js",
222
- }}
223
- onClose={() => {
224
- return undefined;
225
- }}
226
- onFocus={() => {
227
- return undefined;
228
- }}
229
- onSelectEntity={options.onSelectEntity}
230
- onOpenInfrastructure={options.onOpenInfrastructure}
231
- />,
685
+ test("a known resource without a name is unnamed, not undiscovered", async () => {
686
+ serveEntity(
687
+ entityResponse(entityRow(), {
688
+ related: sectionOf([
689
+ connection({
690
+ relationshipType: "member-of",
691
+ otherKey: "ns",
692
+ otherName: null,
693
+ otherType: EntityType.KubernetesNamespace,
694
+ }),
695
+ ]),
696
+ }),
232
697
  );
233
- }
234
698
 
235
- test("leads with status and the traffic in both directions", () => {
236
- renderService();
237
- expect(screen.getByTestId("entity-detail-status")).toHaveTextContent(
238
- "Entry point",
239
- );
699
+ renderPanel({
700
+ onSelectEntity: () => {
701
+ return undefined;
702
+ },
703
+ });
704
+ await waitForConnections();
705
+
240
706
  expect(
241
- screen.getByText("Requests it answered").parentElement,
242
- ).toHaveTextContent("None observed");
243
- expect(screen.getByText("Calls it made").parentElement).toHaveTextContent(
244
- "60/min",
245
- );
246
- expect(screen.getByText("Calls it made").parentElement).toHaveTextContent(
247
- "4ms",
248
- );
707
+ screen.getByRole("button", { name: "View details for Unnamed resource" }),
708
+ ).toHaveTextContent("Checkout API member of Unnamed resource");
249
709
  });
250
710
 
251
- test("separates what it calls from where it runs", () => {
252
- renderService();
253
- expect(screen.getByTestId("entity-detail-calls")).toHaveTextContent(
254
- "api depends on orders",
255
- );
256
- expect(screen.getByTestId("entity-detail-calls")).toHaveTextContent(
257
- "Database",
258
- );
259
- expect(screen.getByTestId("entity-detail-runs-on")).toHaveTextContent(
260
- "api runs on api-6d4f8b9c7d-x2k9p",
711
+ test("a self-loop appears once, as an outbound row", async () => {
712
+ serveEntity(
713
+ entityResponse(entityRow(), {
714
+ calls: sectionOf([
715
+ connection({ otherKey: "svc", otherName: "Checkout API" }),
716
+ ]),
717
+ }),
261
718
  );
719
+
720
+ renderPanel();
721
+ await waitForConnections();
722
+
723
+ expect(
724
+ within(screen.getByTestId("entity-detail-calls")).getAllByRole(
725
+ "listitem",
726
+ ),
727
+ ).toHaveLength(1);
728
+ expect(
729
+ screen.getByText("Checkout API depends on Checkout API"),
730
+ ).toBeInTheDocument();
262
731
  expect(
263
732
  screen.queryByTestId("entity-detail-called-by"),
264
733
  ).not.toBeInTheDocument();
265
734
  });
266
735
 
267
- test("placements open in the Infrastructure view, calls stay on the map", () => {
268
- const onOpenInfrastructure: MockFunction = getJestMockFunction();
269
- const onSelectEntity: MockFunction = getJestMockFunction();
270
- renderService({ onOpenInfrastructure, onSelectEntity });
271
- fireEvent.click(
272
- screen.getByRole("button", {
273
- name: "View details for api-6d4f8b9c7d-x2k9p",
274
- }),
275
- );
276
- expect(onOpenInfrastructure).toHaveBeenCalledWith("pod");
277
- fireEvent.click(
278
- screen.getByRole("button", { name: "View details for orders" }),
279
- );
280
- expect(onSelectEntity).toHaveBeenCalledWith("pg");
281
- });
736
+ test("an entity with no connections in range says so", async () => {
737
+ serveEntity(entityResponse(entityRow()));
738
+
739
+ renderPanel();
740
+ await waitForConnections();
282
741
 
283
- test("a resource lists the services running on it as related infrastructure", () => {
284
- render(
285
- <EntityDetailPanel
286
- entity={POD}
287
- relationships={relationships}
288
- entityByKey={byKey}
289
- metricsWindowSeconds={60}
290
- onClose={() => {
291
- return undefined;
292
- }}
293
- onFocus={() => {
294
- return undefined;
295
- }}
296
- />,
297
- );
298
- expect(screen.getByTestId("entity-detail-related")).toHaveTextContent(
299
- "api runs on api-6d4f8b9c7d-x2k9p",
300
- );
301
742
  expect(
302
- screen.queryByTestId("entity-detail-runs-on"),
303
- ).not.toBeInTheDocument();
743
+ screen.getByText("No connections in the selected time range."),
744
+ ).toBeInTheDocument();
304
745
  });
746
+ });
305
747
 
306
- test("shows the attributes that identify a dependency", () => {
307
- render(
308
- <EntityDetailPanel
309
- entity={POSTGRES}
310
- relationships={relationships}
311
- entityByKey={byKey}
312
- metricsWindowSeconds={60}
313
- onClose={() => {
314
- return undefined;
748
+ describe("Show more", () => {
749
+ function servePaging(
750
+ page: (request: PostRequest) => Answer = (): Answer => {
751
+ return connectionsResponse(
752
+ "runsOn",
753
+ sectionOf(pods(26, 40), {
754
+ total: 40,
755
+ unknownTotal: 3,
756
+ nextOffset: null,
757
+ }),
758
+ );
759
+ },
760
+ ): void {
761
+ serve((request: PostRequest): Answer => {
762
+ return isConnectionsRequest(request) ? page(request) : checkoutResponse();
763
+ });
764
+ }
765
+
766
+ test("fetches the next page of that section, appends it, and moves focus to the first new row", async () => {
767
+ servePaging();
768
+ const onOpenInfrastructure: MockFunction = getJestMockFunction();
769
+
770
+ renderPanel({ onOpenInfrastructure });
771
+ await waitForConnections();
772
+
773
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
774
+
775
+ await waitFor(() => {
776
+ expect(
777
+ within(screen.getByTestId("entity-detail-runs-on")).getAllByRole(
778
+ "listitem",
779
+ ),
780
+ ).toHaveLength(40);
781
+ });
782
+
783
+ const pageRequests: Array<PostRequest> =
784
+ requests().filter(isConnectionsRequest);
785
+ expect(pageRequests).toHaveLength(1);
786
+ expect(pageRequests[0]!.url.toString()).toMatch(
787
+ /\/telemetry\/topology\/entity\/connections$/,
788
+ );
789
+ expect(pageRequests[0]!.data).toEqual({
790
+ rangeStart: RANGE_START.toISOString(),
791
+ entityKey: "svc",
792
+ entityType: EntityType.Service,
793
+ section: "runsOn",
794
+ offset: 25,
795
+ limit: 25,
796
+ });
797
+
798
+ await waitFor(() => {
799
+ expect(document.activeElement).toBe(
800
+ screen.getByRole("button", {
801
+ name: "View details for checkout-pod-26",
802
+ }),
803
+ );
804
+ });
805
+ /* The last page reached the end: no more paging. */
806
+ expect(
807
+ screen.queryByRole("button", { name: "Show more: Runs on" }),
808
+ ).not.toBeInTheDocument();
809
+ expect(screen.queryByText(/^Showing/)).not.toBeInTheDocument();
810
+ /* Only that section was fetched again; the entity was not. */
811
+ expect(entityRequests()).toHaveLength(1);
812
+ });
813
+
814
+ test("when the first new row cannot be opened, focus lands on the row itself", async () => {
815
+ servePaging((): Answer => {
816
+ return connectionsResponse(
817
+ "runsOn",
818
+ sectionOf(
819
+ [
820
+ connection({
821
+ relationshipType: "runs-on",
822
+ otherKey: "gone-pod",
823
+ otherKnown: false,
824
+ otherName: null,
825
+ otherType: null,
826
+ }),
827
+ ],
828
+ { total: 40, unknownTotal: 3, nextOffset: 26 },
829
+ ),
830
+ );
831
+ });
832
+
833
+ renderPanel({
834
+ onSelectEntity: () => {
835
+ return undefined;
836
+ },
837
+ });
838
+ await waitForConnections();
839
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
840
+
841
+ const row: HTMLElement = await screen.findByText(
842
+ "Checkout API runs on Undiscovered resource",
843
+ );
844
+ await waitFor(() => {
845
+ expect(document.activeElement).toBe(row.closest("li"));
846
+ });
847
+ });
848
+
849
+ test("keeps paging from the offset the server returned", async () => {
850
+ servePaging((request: PostRequest): Answer => {
851
+ const offset: number = request.data["offset"] as number;
852
+ return connectionsResponse(
853
+ "runsOn",
854
+ sectionOf(pods(offset + 1, offset + 5), {
855
+ total: 40,
856
+ nextOffset: offset + 5,
857
+ }),
858
+ );
859
+ });
860
+
861
+ renderPanel();
862
+ await waitForConnections();
863
+
864
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
865
+ await screen.findByText("Showing 30 of 40");
866
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
867
+ await screen.findByText("Showing 35 of 40");
868
+
869
+ expect(
870
+ requests()
871
+ .filter(isConnectionsRequest)
872
+ .map((request: PostRequest): unknown => {
873
+ return request.data["offset"];
874
+ }),
875
+ ).toEqual([25, 30]);
876
+ });
877
+
878
+ test("the button is busy while its page loads and cannot fire twice", async () => {
879
+ const page: Deferred = deferred();
880
+ servePaging((): Answer => {
881
+ return page.promise;
882
+ });
883
+
884
+ renderPanel();
885
+ await waitForConnections();
886
+
887
+ const button: HTMLElement = screen.getByRole("button", {
888
+ name: "Show more: Runs on",
889
+ });
890
+ fireEvent.click(button);
891
+ await waitFor(() => {
892
+ expect(button).toBeDisabled();
893
+ });
894
+ expect(button).toHaveTextContent("Loading…");
895
+ fireEvent.click(button);
896
+ expect(requests().filter(isConnectionsRequest)).toHaveLength(1);
897
+
898
+ await act(async () => {
899
+ page.resolve(
900
+ connectionsResponse(
901
+ "runsOn",
902
+ sectionOf(pods(26, 30), { total: 40, nextOffset: 30 }),
903
+ ),
904
+ );
905
+ });
906
+ expect(button).not.toBeDisabled();
907
+ });
908
+
909
+ test("a failed page is reported in its section and can be retried", async () => {
910
+ let fail: boolean = true;
911
+ servePaging((): Answer => {
912
+ if (fail) {
913
+ return new HTTPErrorResponse(500, { message: "Database busy" }, {});
914
+ }
915
+ return connectionsResponse(
916
+ "runsOn",
917
+ sectionOf(pods(26, 40), { total: 40, nextOffset: null }),
918
+ );
919
+ });
920
+
921
+ renderPanel();
922
+ await waitForConnections();
923
+
924
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
925
+ const alert: HTMLElement = await within(
926
+ screen.getByTestId("entity-detail-runs-on"),
927
+ ).findByRole("alert");
928
+ expect(alert).toHaveTextContent("Could not load more connections.");
929
+ expect(alert).toHaveTextContent("Database busy");
930
+ /* What was already shown stays. */
931
+ expect(
932
+ within(screen.getByTestId("entity-detail-runs-on")).getAllByRole(
933
+ "listitem",
934
+ ),
935
+ ).toHaveLength(25);
936
+
937
+ fail = false;
938
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
939
+ await waitFor(() => {
940
+ expect(
941
+ within(screen.getByTestId("entity-detail-runs-on")).getAllByRole(
942
+ "listitem",
943
+ ),
944
+ ).toHaveLength(40);
945
+ });
946
+ expect(
947
+ within(screen.getByTestId("entity-detail-runs-on")).queryByRole("alert"),
948
+ ).not.toBeInTheDocument();
949
+ });
950
+
951
+ test("a page that reports a limited scan turns the totals into lower bounds", async () => {
952
+ servePaging((): Answer => {
953
+ return connectionsResponse(
954
+ "runsOn",
955
+ sectionOf(pods(26, 30), { total: 41, nextOffset: 30 }),
956
+ true,
957
+ );
958
+ });
959
+
960
+ renderPanel();
961
+ await waitForConnections();
962
+
963
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
964
+
965
+ await waitFor(() => {
966
+ expect(
967
+ within(screen.getByTestId("entity-detail-runs-on")).getByRole(
968
+ "heading",
969
+ ),
970
+ ).toHaveTextContent("Runs on (41+)");
971
+ });
972
+ expect(
973
+ within(screen.getByTestId("entity-detail-calls")).getByRole("heading"),
974
+ ).toHaveTextContent("Calls (2+)");
975
+ });
976
+
977
+ test("a page that arrives after the drawer moved to another range is dropped", async () => {
978
+ const page: Deferred = deferred();
979
+ servePaging((): Answer => {
980
+ return page.promise;
981
+ });
982
+
983
+ const view: RenderResult = renderPanel();
984
+ await waitForConnections();
985
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
986
+ const pageSignal: AbortSignal | undefined =
987
+ requests().filter(isConnectionsRequest)[0]!.options.signal;
988
+
989
+ view.rerender(
990
+ <EntityDetailPanel
991
+ entity={CHECKOUT}
992
+ rangeStart={LATER_RANGE_START}
993
+ metricsWindowSeconds={60}
994
+ onClose={() => {
995
+ return undefined;
996
+ }}
997
+ />,
998
+ );
999
+ expect(pageSignal?.aborted).toBe(true);
1000
+ await waitForConnections();
1001
+
1002
+ await act(async () => {
1003
+ page.resolve(
1004
+ connectionsResponse(
1005
+ "runsOn",
1006
+ sectionOf(pods(26, 40), { total: 40, nextOffset: null }),
1007
+ ),
1008
+ );
1009
+ });
1010
+
1011
+ expect(
1012
+ within(screen.getByTestId("entity-detail-runs-on")).getAllByRole(
1013
+ "listitem",
1014
+ ),
1015
+ ).toHaveLength(25);
1016
+ });
1017
+ });
1018
+
1019
+ describe("a list that changed while the user paged it", () => {
1020
+ /*
1021
+ * Paging is by offset over a ranking the server recomputes per request,
1022
+ * and the ranking moves while the drawer is open. The worked example:
1023
+ * pod-3 is pruned after the first page, so it now sorts last as unknown
1024
+ * and pod-26 moves up to rank 25 — the page at offset 25 starts at pod-27,
1025
+ * repeats pod-3 at its end, and pod-26 is never returned.
1026
+ */
1027
+ function driftedPage(): JSONObject {
1028
+ return connectionsResponse(
1029
+ "runsOn",
1030
+ sectionOf(
1031
+ [
1032
+ ...pods(27, 40),
1033
+ connection({
1034
+ relationshipType: "runs-on",
1035
+ otherKey: "pod-3",
1036
+ otherKnown: false,
1037
+ otherName: null,
1038
+ otherType: null,
1039
+ }),
1040
+ ],
1041
+ { total: 40, unknownTotal: 4, nextOffset: null },
1042
+ ),
1043
+ );
1044
+ }
1045
+
1046
+ function runsOnSection(): HTMLElement {
1047
+ return screen.getByTestId("entity-detail-runs-on");
1048
+ }
1049
+
1050
+ function runsOnRows(): Array<HTMLElement> {
1051
+ return within(runsOnSection()).getAllByRole("listitem");
1052
+ }
1053
+
1054
+ function changedNote(): HTMLElement | null {
1055
+ return screen.queryByTestId("entity-detail-runs-on-changed");
1056
+ }
1057
+
1058
+ test("a page that repeats rows and ends short of the total says so, and offers a reload", async () => {
1059
+ let reloaded: boolean = false;
1060
+ serve((request: PostRequest): Answer => {
1061
+ if (!isConnectionsRequest(request)) {
1062
+ return checkoutResponse();
1063
+ }
1064
+ if (request.data["offset"] === 0) {
1065
+ reloaded = true;
1066
+ return connectionsResponse(
1067
+ "runsOn",
1068
+ sectionOf(pods(1, 40), { total: 40, nextOffset: null }),
1069
+ );
1070
+ }
1071
+ return driftedPage();
1072
+ });
1073
+
1074
+ renderPanel({
1075
+ onSelectEntity: () => {
1076
+ return undefined;
1077
+ },
1078
+ });
1079
+ await waitForConnections();
1080
+ expect(changedNote()).toBeNull();
1081
+
1082
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1083
+
1084
+ await waitFor(() => {
1085
+ expect(changedNote()).not.toBeNull();
1086
+ });
1087
+ expect(changedNote()).toHaveTextContent(
1088
+ "This list changed while you were browsing.",
1089
+ );
1090
+ /* 39 rows under "Runs on (40)", and no way to page on. */
1091
+ expect(runsOnRows()).toHaveLength(39);
1092
+ expect(within(runsOnSection()).getByRole("heading")).toHaveTextContent(
1093
+ "Runs on (40)",
1094
+ );
1095
+ expect(
1096
+ screen.queryByRole("button", { name: "Show more: Runs on" }),
1097
+ ).not.toBeInTheDocument();
1098
+ /* The page still added rows: focus is on the first of them. */
1099
+ await waitFor(() => {
1100
+ expect(document.activeElement).toBe(
1101
+ screen.getByRole("button", {
1102
+ name: "View details for checkout-pod-27",
1103
+ }),
1104
+ );
1105
+ });
1106
+
1107
+ fireEvent.click(
1108
+ screen.getByRole("button", { name: "Reload list: Runs on" }),
1109
+ );
1110
+
1111
+ await waitFor(() => {
1112
+ expect(changedNote()).toBeNull();
1113
+ });
1114
+ expect(reloaded).toBe(true);
1115
+ const reload: PostRequest = requests().filter(isConnectionsRequest)[1]!;
1116
+ /* From the start, as many rows as it showed plus a page. */
1117
+ expect(reload.data).toEqual({
1118
+ rangeStart: RANGE_START.toISOString(),
1119
+ entityKey: "svc",
1120
+ entityType: EntityType.Service,
1121
+ section: "runsOn",
1122
+ offset: 0,
1123
+ limit: 39 + 25,
1124
+ });
1125
+ expect(runsOnRows()).toHaveLength(40);
1126
+ expect(
1127
+ screen.getByRole("button", { name: "View details for checkout-pod-26" }),
1128
+ ).toBeInTheDocument();
1129
+ /* The reload replaced the list: pod-3 is back in its place, once. */
1130
+ expect(runsOnRows()[2]).toHaveTextContent("checkout-pod-3");
1131
+ await waitFor(() => {
1132
+ expect(document.activeElement).toBe(
1133
+ screen.getByRole("button", {
1134
+ name: "View details for checkout-pod-1",
1135
+ }),
1136
+ );
1137
+ });
1138
+ /* Only that section was asked for again; the entity was not. */
1139
+ expect(entityRequests()).toHaveLength(1);
1140
+ });
1141
+
1142
+ test("a last page that ends short of its total says so even with nothing repeated", async () => {
1143
+ serve((request: PostRequest): Answer => {
1144
+ return isConnectionsRequest(request)
1145
+ ? connectionsResponse(
1146
+ "runsOn",
1147
+ sectionOf(pods(27, 40), { total: 40, nextOffset: null }),
1148
+ )
1149
+ : checkoutResponse();
1150
+ });
1151
+
1152
+ renderPanel();
1153
+ await waitForConnections();
1154
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1155
+
1156
+ await waitFor(() => {
1157
+ expect(runsOnRows()).toHaveLength(39);
1158
+ });
1159
+ expect(changedNote()).toHaveTextContent(
1160
+ "This list changed while you were browsing.",
1161
+ );
1162
+ });
1163
+
1164
+ test("a page of nothing but repeated rows moves focus to the reload", async () => {
1165
+ serve((request: PostRequest): Answer => {
1166
+ return isConnectionsRequest(request)
1167
+ ? connectionsResponse(
1168
+ "runsOn",
1169
+ sectionOf(pods(21, 25), { total: 40, nextOffset: 30 }),
1170
+ )
1171
+ : checkoutResponse();
1172
+ });
1173
+
1174
+ renderPanel();
1175
+ await waitForConnections();
1176
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1177
+
1178
+ const reload: HTMLElement = await screen.findByRole("button", {
1179
+ name: "Reload list: Runs on",
1180
+ });
1181
+ await waitFor(() => {
1182
+ expect(document.activeElement).toBe(reload);
1183
+ });
1184
+ expect(runsOnRows()).toHaveLength(25);
1185
+ /* Paging can still go on; the note stays until the list is reloaded. */
1186
+ expect(
1187
+ screen.getByRole("button", { name: "Show more: Runs on" }),
1188
+ ).toBeInTheDocument();
1189
+ });
1190
+
1191
+ test("a page that lines up with what is shown says nothing", async () => {
1192
+ serve((request: PostRequest): Answer => {
1193
+ return isConnectionsRequest(request)
1194
+ ? connectionsResponse(
1195
+ "runsOn",
1196
+ sectionOf(pods(26, 40), { total: 40, nextOffset: null }),
1197
+ )
1198
+ : checkoutResponse();
1199
+ });
1200
+
1201
+ renderPanel();
1202
+ await waitForConnections();
1203
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1204
+
1205
+ await waitFor(() => {
1206
+ expect(runsOnRows()).toHaveLength(40);
1207
+ });
1208
+ expect(changedNote()).toBeNull();
1209
+ expect(
1210
+ screen.queryByRole("button", { name: /Reload list/ }),
1211
+ ).not.toBeInTheDocument();
1212
+ });
1213
+
1214
+ test("a failed reload keeps the list and the offer to reload it", async () => {
1215
+ let failReload: boolean = true;
1216
+ serve((request: PostRequest): Answer => {
1217
+ if (!isConnectionsRequest(request)) {
1218
+ return checkoutResponse();
1219
+ }
1220
+ if (request.data["offset"] === 0) {
1221
+ return failReload
1222
+ ? new HTTPErrorResponse(500, { message: "Database busy" }, {})
1223
+ : connectionsResponse(
1224
+ "runsOn",
1225
+ sectionOf(pods(1, 40), { total: 40, nextOffset: null }),
1226
+ );
1227
+ }
1228
+ return driftedPage();
1229
+ });
1230
+
1231
+ renderPanel();
1232
+ await waitForConnections();
1233
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1234
+ fireEvent.click(
1235
+ await screen.findByRole("button", { name: "Reload list: Runs on" }),
1236
+ );
1237
+
1238
+ const alert: HTMLElement =
1239
+ await within(runsOnSection()).findByRole("alert");
1240
+ expect(alert).toHaveTextContent("Could not reload this list.");
1241
+ expect(alert).toHaveTextContent("Database busy");
1242
+ expect(runsOnRows()).toHaveLength(39);
1243
+
1244
+ failReload = false;
1245
+ fireEvent.click(
1246
+ screen.getByRole("button", { name: "Reload list: Runs on" }),
1247
+ );
1248
+ await waitFor(() => {
1249
+ expect(runsOnRows()).toHaveLength(40);
1250
+ });
1251
+ expect(changedNote()).toBeNull();
1252
+ expect(
1253
+ within(runsOnSection()).queryByRole("alert"),
1254
+ ).not.toBeInTheDocument();
1255
+ });
1256
+
1257
+ test("reloading the drawer for a new range drops the note", async () => {
1258
+ serve((request: PostRequest): Answer => {
1259
+ return isConnectionsRequest(request) ? driftedPage() : checkoutResponse();
1260
+ });
1261
+
1262
+ const view: RenderResult = renderPanel();
1263
+ await waitForConnections();
1264
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1265
+ await waitFor(() => {
1266
+ expect(changedNote()).not.toBeNull();
1267
+ });
1268
+
1269
+ view.rerender(
1270
+ <EntityDetailPanel
1271
+ entity={CHECKOUT}
1272
+ rangeStart={LATER_RANGE_START}
1273
+ metricsWindowSeconds={60}
1274
+ onClose={() => {
1275
+ return undefined;
1276
+ }}
1277
+ />,
1278
+ );
1279
+ await waitForConnections();
1280
+
1281
+ expect(runsOnRows()).toHaveLength(25);
1282
+ expect(changedNote()).toBeNull();
1283
+ });
1284
+ });
1285
+
1286
+ describe("a resource that is gone, and failures", () => {
1287
+ test("a key no item has any more keeps the header and says it is gone", async () => {
1288
+ serveEntity(entityResponse(null));
1289
+ const onFocus: MockFunction = getJestMockFunction();
1290
+
1291
+ renderPanel({ onFocus });
1292
+ await waitForConnections();
1293
+
1294
+ expect(
1295
+ screen.getByRole("region", { name: "Checkout API" }),
1296
+ ).toBeInTheDocument();
1297
+ expect(
1298
+ screen.getByText("This resource is no longer in Inventory."),
1299
+ ).toBeInTheDocument();
1300
+ expect(
1301
+ screen.queryByRole("button", { name: "Explore connections" }),
1302
+ ).not.toBeInTheDocument();
1303
+ expect(screen.queryByText("Inventory details")).not.toBeInTheDocument();
1304
+ expect(screen.queryByText("Details")).not.toBeInTheDocument();
1305
+ expect(
1306
+ screen.queryByText("No connections in the selected time range."),
1307
+ ).not.toBeInTheDocument();
1308
+ });
1309
+
1310
+ test("a failed load is an alert with an in-drawer retry", async () => {
1311
+ let fail: boolean = true;
1312
+ serve((): Answer => {
1313
+ if (fail) {
1314
+ return new HTTPErrorResponse(
1315
+ 500,
1316
+ { message: "Inventory is unavailable" },
1317
+ {},
1318
+ );
1319
+ }
1320
+ return checkoutResponse();
1321
+ });
1322
+
1323
+ renderPanel();
1324
+
1325
+ const alert: HTMLElement = await screen.findByRole("alert");
1326
+ expect(alert).toHaveTextContent(
1327
+ "Could not load this resource's connections.",
1328
+ );
1329
+ expect(alert).toHaveTextContent("Inventory is unavailable");
1330
+ expect(
1331
+ screen.getByRole("region", { name: "Checkout API" }),
1332
+ ).toBeInTheDocument();
1333
+
1334
+ fail = false;
1335
+ fireEvent.click(within(alert).getByRole("button", { name: "Try again" }));
1336
+
1337
+ expect(
1338
+ await screen.findByTestId("entity-detail-calls"),
1339
+ ).toBeInTheDocument();
1340
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
1341
+ expect(entityRequests()).toHaveLength(2);
1342
+ });
1343
+
1344
+ test("a server without the endpoint asks for a reload", async () => {
1345
+ serve((): Answer => {
1346
+ return new HTTPErrorResponse(404, { message: "Not found" }, {});
1347
+ });
1348
+
1349
+ renderPanel();
1350
+
1351
+ expect(await screen.findByRole("alert")).toHaveTextContent(
1352
+ "Topology was updated. Reload the page.",
1353
+ );
1354
+ });
1355
+
1356
+ test("a response in another format asks for a reload too", async () => {
1357
+ serve((): Answer => {
1358
+ return { ...checkoutResponse(), formatVersion: 999 };
1359
+ });
1360
+
1361
+ renderPanel();
1362
+
1363
+ expect(await screen.findByRole("alert")).toHaveTextContent(
1364
+ "Topology was updated. Reload the page.",
1365
+ );
1366
+ });
1367
+
1368
+ test("an outdated server is offered a page reload, never a retry that cannot work", async () => {
1369
+ const reload: SpyInstance<() => void> = jest
1370
+ .spyOn(Navigation, "reload")
1371
+ .mockImplementation(() => {
1372
+ return undefined;
1373
+ });
1374
+ serve((): Answer => {
1375
+ return { ...checkoutResponse(), formatVersion: 999 };
1376
+ });
1377
+
1378
+ renderPanel();
1379
+
1380
+ const alert: HTMLElement = await screen.findByRole("alert");
1381
+ expect(
1382
+ within(alert).queryByRole("button", { name: "Try again" }),
1383
+ ).not.toBeInTheDocument();
1384
+ fireEvent.click(within(alert).getByRole("button", { name: "Reload page" }));
1385
+ expect(reload).toHaveBeenCalledTimes(1);
1386
+ expect(entityRequests()).toHaveLength(1);
1387
+ });
1388
+
1389
+ test("a busy server says so, and trying again works", async () => {
1390
+ const reload: SpyInstance<() => void> = jest
1391
+ .spyOn(Navigation, "reload")
1392
+ .mockImplementation(() => {
1393
+ return undefined;
1394
+ });
1395
+ let busy: boolean = true;
1396
+ serve((): Answer => {
1397
+ return busy
1398
+ ? new HTTPErrorResponse(
1399
+ 429,
1400
+ { message: "Too many topology requests are running." },
1401
+ {},
1402
+ )
1403
+ : checkoutResponse();
1404
+ });
1405
+
1406
+ renderPanel();
1407
+
1408
+ const alert: HTMLElement = await screen.findByRole("alert");
1409
+ expect(alert).toHaveTextContent(
1410
+ "Could not load this resource's connections.",
1411
+ );
1412
+ expect(alert).toHaveTextContent(
1413
+ "The topology service is busy. Try again in a moment.",
1414
+ );
1415
+ expect(
1416
+ within(alert).queryByRole("button", { name: "Reload page" }),
1417
+ ).not.toBeInTheDocument();
1418
+
1419
+ busy = false;
1420
+ fireEvent.click(within(alert).getByRole("button", { name: "Try again" }));
1421
+
1422
+ expect(
1423
+ await screen.findByTestId("entity-detail-calls"),
1424
+ ).toBeInTheDocument();
1425
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
1426
+ expect(entityRequests()).toHaveLength(2);
1427
+ expect(reload).not.toHaveBeenCalled();
1428
+ });
1429
+
1430
+ test("Show more against an outdated server offers a page reload and stops paging that section", async () => {
1431
+ const reload: SpyInstance<() => void> = jest
1432
+ .spyOn(Navigation, "reload")
1433
+ .mockImplementation(() => {
1434
+ return undefined;
1435
+ });
1436
+ serve((request: PostRequest): Answer => {
1437
+ return isConnectionsRequest(request)
1438
+ ? new HTTPErrorResponse(404, { message: "Not found" }, {})
1439
+ : checkoutResponse();
1440
+ });
1441
+
1442
+ renderPanel();
1443
+ await waitForConnections();
1444
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1445
+
1446
+ const runsOn: HTMLElement = screen.getByTestId("entity-detail-runs-on");
1447
+ const alert: HTMLElement = await within(runsOn).findByRole("alert");
1448
+ expect(alert).toHaveTextContent(
1449
+ "Could not load more connections. Topology was updated. Reload the page.",
1450
+ );
1451
+ /* A further page would fail the same way: no Show more to press. */
1452
+ expect(
1453
+ screen.queryByRole("button", { name: "Show more: Runs on" }),
1454
+ ).not.toBeInTheDocument();
1455
+ expect(within(runsOn).getAllByRole("listitem")).toHaveLength(25);
1456
+
1457
+ fireEvent.click(within(alert).getByRole("button", { name: "Reload page" }));
1458
+ expect(reload).toHaveBeenCalledTimes(1);
1459
+ expect(requests().filter(isConnectionsRequest)).toHaveLength(1);
1460
+ });
1461
+
1462
+ test("Show more against a busy server says so, and can be pressed again", async () => {
1463
+ let busy: boolean = true;
1464
+ serve((request: PostRequest): Answer => {
1465
+ if (!isConnectionsRequest(request)) {
1466
+ return checkoutResponse();
1467
+ }
1468
+ return busy
1469
+ ? new HTTPErrorResponse(429, { message: "Busy" }, {})
1470
+ : connectionsResponse(
1471
+ "runsOn",
1472
+ sectionOf(pods(26, 40), { total: 40, nextOffset: null }),
1473
+ );
1474
+ });
1475
+
1476
+ renderPanel();
1477
+ await waitForConnections();
1478
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1479
+
1480
+ const runsOn: HTMLElement = screen.getByTestId("entity-detail-runs-on");
1481
+ const alert: HTMLElement = await within(runsOn).findByRole("alert");
1482
+ expect(alert).toHaveTextContent(
1483
+ "Could not load more connections. The topology service is busy. Try again in a moment.",
1484
+ );
1485
+ expect(
1486
+ within(alert).queryByRole("button", { name: "Reload page" }),
1487
+ ).not.toBeInTheDocument();
1488
+
1489
+ busy = false;
1490
+ fireEvent.click(screen.getByRole("button", { name: "Show more: Runs on" }));
1491
+ await waitFor(() => {
1492
+ expect(within(runsOn).getAllByRole("listitem")).toHaveLength(40);
1493
+ });
1494
+ expect(within(runsOn).queryByRole("alert")).not.toBeInTheDocument();
1495
+ });
1496
+ });
1497
+
1498
+ describe("switching entities and ranges", () => {
1499
+ function webResponse(): JSONObject {
1500
+ return entityResponse(
1501
+ entityRow({
1502
+ id: "2b2b0000-0000-4000-8000-000000000002",
1503
+ key: "web",
1504
+ name: "Web frontend",
1505
+ resourceId: null,
1506
+ resourceType: null,
1507
+ }),
1508
+ {
1509
+ calls: sectionOf([
1510
+ connection({ otherKey: "svc", otherName: "Checkout API" }),
1511
+ ]),
1512
+ },
1513
+ );
1514
+ }
1515
+
1516
+ test("a new entity never shows the previous entity's rows, even without a remount", async () => {
1517
+ const web: Deferred = deferred();
1518
+ serve((request: PostRequest): Answer => {
1519
+ return request.data["entityKey"] === "web"
1520
+ ? web.promise
1521
+ : checkoutResponse();
1522
+ });
1523
+
1524
+ /*
1525
+ * What the page showed at each commit, tagged with the entity it had
1526
+ * asked the drawer for. A sibling's layout effect runs once React has
1527
+ * written a commit to the DOM and before any passive effect — including
1528
+ * the drawer's own reset to "loading" — so it sees the FIRST commit for
1529
+ * the new entity: the one frame in which rows fetched for the previous
1530
+ * entity could still be painted under the new header. Assertions made
1531
+ * after rerender() cannot: act() has flushed that reset by then.
1532
+ */
1533
+ const commits: Array<{ entityKey: string; text: string }> = [];
1534
+ const Probe: React.FunctionComponent<{ entityKey: string }> = (props: {
1535
+ entityKey: string;
1536
+ }): null => {
1537
+ React.useLayoutEffect(() => {
1538
+ commits.push({
1539
+ entityKey: props.entityKey,
1540
+ text: document.body.textContent || "",
1541
+ });
1542
+ });
1543
+ return null;
1544
+ };
1545
+ const page: (target: EntityDetailTarget) => React.ReactElement = (
1546
+ target: EntityDetailTarget,
1547
+ ): React.ReactElement => {
1548
+ return (
1549
+ <>
1550
+ <EntityDetailPanel
1551
+ entity={target}
1552
+ rangeStart={RANGE_START}
1553
+ metricsWindowSeconds={60}
1554
+ onClose={() => {
1555
+ return undefined;
1556
+ }}
1557
+ />
1558
+ <Probe entityKey={target.entityKey} />
1559
+ </>
1560
+ );
1561
+ };
1562
+
1563
+ const view: RenderResult = render(page(CHECKOUT));
1564
+ await screen.findByText("Checkout API depends on Orders database");
1565
+
1566
+ view.rerender(
1567
+ page({
1568
+ entityKey: "web",
1569
+ entityType: EntityType.Service,
1570
+ displayName: "Web frontend",
1571
+ }),
1572
+ );
1573
+
1574
+ const webCommits: Array<{ entityKey: string; text: string }> =
1575
+ commits.filter((commit: { entityKey: string; text: string }): boolean => {
1576
+ return commit.entityKey === "web";
1577
+ });
1578
+ expect(webCommits.length).toBeGreaterThan(0);
1579
+ for (const commit of webCommits) {
1580
+ expect(commit.text).not.toContain("Orders database");
1581
+ expect(commit.text).not.toContain("Calls (2)");
1582
+ expect(commit.text).not.toContain("Runs on (40)");
1583
+ expect(commit.text).toContain("Loading connections…");
1584
+ }
1585
+
1586
+ expect(
1587
+ screen.queryByText("Checkout API depends on Orders database"),
1588
+ ).not.toBeInTheDocument();
1589
+ expect(screen.queryByText(/Orders database/)).not.toBeInTheDocument();
1590
+ expect(screen.getByRole("status")).toBeInTheDocument();
1591
+ expect(
1592
+ screen.getByRole("region", { name: "Web frontend" }),
1593
+ ).toBeInTheDocument();
1594
+
1595
+ await act(async () => {
1596
+ web.resolve(webResponse());
1597
+ });
1598
+
1599
+ expect(
1600
+ screen.getByText("Web frontend depends on Checkout API"),
1601
+ ).toBeInTheDocument();
1602
+ });
1603
+
1604
+ test("an answer for the previous entity that arrives late is ignored", async () => {
1605
+ const checkout: Deferred = deferred();
1606
+ serve((request: PostRequest): Answer => {
1607
+ return request.data["entityKey"] === "web"
1608
+ ? webResponse()
1609
+ : checkout.promise;
1610
+ });
1611
+
1612
+ const view: RenderResult = renderPanel();
1613
+ const firstSignal: AbortSignal | undefined =
1614
+ entityRequests()[0]!.options.signal;
1615
+
1616
+ view.rerender(
1617
+ <EntityDetailPanel
1618
+ entity={{ entityKey: "web", displayName: "Web frontend" }}
1619
+ rangeStart={RANGE_START}
1620
+ metricsWindowSeconds={60}
1621
+ onClose={() => {
1622
+ return undefined;
315
1623
  }}
316
- onFocus={() => {
1624
+ />,
1625
+ );
1626
+ expect(firstSignal?.aborted).toBe(true);
1627
+ await screen.findByText("Web frontend depends on Checkout API");
1628
+
1629
+ await act(async () => {
1630
+ checkout.resolve(checkoutResponse());
1631
+ });
1632
+
1633
+ expect(
1634
+ screen.queryByText("Checkout API depends on Orders database"),
1635
+ ).not.toBeInTheDocument();
1636
+ expect(
1637
+ screen.getByText("Web frontend depends on Checkout API"),
1638
+ ).toBeInTheDocument();
1639
+ });
1640
+
1641
+ test("a new range reloads the connections instead of showing the old range's", async () => {
1642
+ const later: Deferred = deferred();
1643
+ serve((request: PostRequest): Answer => {
1644
+ return request.data["rangeStart"] === LATER_RANGE_START.toISOString()
1645
+ ? later.promise
1646
+ : checkoutResponse();
1647
+ });
1648
+
1649
+ const view: RenderResult = renderPanel();
1650
+ await screen.findByText("Checkout API depends on Orders database");
1651
+
1652
+ view.rerender(
1653
+ <EntityDetailPanel
1654
+ entity={CHECKOUT}
1655
+ rangeStart={new Date(LATER_RANGE_START.getTime())}
1656
+ metricsWindowSeconds={60}
1657
+ onClose={() => {
1658
+ return undefined;
1659
+ }}
1660
+ />,
1661
+ );
1662
+
1663
+ expect(
1664
+ screen.queryByText("Checkout API depends on Orders database"),
1665
+ ).not.toBeInTheDocument();
1666
+ expect(screen.getByRole("status")).toBeInTheDocument();
1667
+
1668
+ await act(async () => {
1669
+ later.resolve(
1670
+ entityResponse(entityRow(), {
1671
+ calledBy: sectionOf([
1672
+ connection({
1673
+ direction: "in",
1674
+ otherKey: "cron",
1675
+ otherName: "cron",
1676
+ }),
1677
+ ]),
1678
+ }),
1679
+ );
1680
+ });
1681
+ expect(
1682
+ screen.getByText("cron depends on Checkout API"),
1683
+ ).toBeInTheDocument();
1684
+ });
1685
+
1686
+ test("re-rendering with an equal preview and range does not refetch", async () => {
1687
+ serveEntity(checkoutResponse());
1688
+
1689
+ const view: RenderResult = renderPanel();
1690
+ await waitForConnections();
1691
+
1692
+ view.rerender(
1693
+ <EntityDetailPanel
1694
+ entity={{ ...CHECKOUT }}
1695
+ rangeStart={new Date(RANGE_START.getTime())}
1696
+ metricsWindowSeconds={60}
1697
+ onClose={() => {
1698
+ return undefined;
1699
+ }}
1700
+ />,
1701
+ );
1702
+ await waitForConnections();
1703
+
1704
+ expect(entityRequests()).toHaveLength(1);
1705
+ });
1706
+ });
1707
+
1708
+ describe("best-effort links from the full row", () => {
1709
+ function serviceLookups(): Array<unknown> {
1710
+ return getListMock.mock.calls.filter((call: Array<unknown>): boolean => {
1711
+ return (
1712
+ (call[0] as { modelType: { name: string } }).modelType.name ===
1713
+ "Service"
1714
+ );
1715
+ });
1716
+ }
1717
+
1718
+ test("the traces link uses the row's own Service pointer, with no lookup", async () => {
1719
+ serveEntity(checkoutResponse());
1720
+
1721
+ renderPanel();
1722
+
1723
+ const link: HTMLElement = await screen.findByText(
1724
+ "Traces for this service",
1725
+ );
1726
+ expect(link.closest("a")?.getAttribute("href")).toContain(SERVICE_ID);
1727
+ expect(getListMock).not.toHaveBeenCalled();
1728
+ });
1729
+
1730
+ test("without a pointer the Service is found by name — once per row, never from the preview", async () => {
1731
+ const first: Deferred = deferred();
1732
+ let served: number = 0;
1733
+ serve((): Answer => {
1734
+ served++;
1735
+ return served === 1
1736
+ ? first.promise
1737
+ : entityResponse(entityRow({ resourceType: null, resourceId: null }));
1738
+ });
1739
+ getListMock.mockImplementation(async () => {
1740
+ return { data: [{ _id: SERVICE_ID }], count: 1 };
1741
+ });
1742
+
1743
+ const view: RenderResult = renderPanel();
1744
+ /* The preview is a named service, but it is not the row. */
1745
+ expect(serviceLookups()).toHaveLength(0);
1746
+
1747
+ await act(async () => {
1748
+ first.resolve(
1749
+ entityResponse(entityRow({ resourceType: null, resourceId: null })),
1750
+ );
1751
+ });
1752
+ const link: HTMLElement = await screen.findByText(
1753
+ "Traces for this service",
1754
+ );
1755
+ expect(link.closest("a")?.getAttribute("href")).toContain(SERVICE_ID);
1756
+ expect(serviceLookups()).toHaveLength(1);
1757
+ expect(
1758
+ (serviceLookups()[0] as Array<{ query: JSONObject }>)[0]!.query,
1759
+ ).toEqual({ projectId: PROJECT_ID, name: "Checkout API" });
1760
+
1761
+ /* A new range reloads the row; the same row is not looked up again. */
1762
+ view.rerender(
1763
+ <EntityDetailPanel
1764
+ entity={CHECKOUT}
1765
+ rangeStart={LATER_RANGE_START}
1766
+ metricsWindowSeconds={60}
1767
+ onClose={() => {
317
1768
  return undefined;
318
1769
  }}
319
1770
  />,
320
1771
  );
321
- expect(screen.getByText("Database engine").parentElement).toHaveTextContent(
322
- "postgresql",
1772
+ await waitForConnections();
1773
+ await screen.findByText("Traces for this service");
1774
+
1775
+ expect(entityRequests()).toHaveLength(2);
1776
+ expect(serviceLookups()).toHaveLength(1);
1777
+ });
1778
+
1779
+ test("a host is matched to the network device of the same name", async () => {
1780
+ serveEntity(
1781
+ entityResponse(
1782
+ entityRow({
1783
+ key: "host-key",
1784
+ type: EntityType.Host,
1785
+ name: "web-1.example.com",
1786
+ resourceType: null,
1787
+ resourceId: null,
1788
+ }),
1789
+ ),
323
1790
  );
324
- expect(screen.getByTestId("entity-detail-called-by")).toHaveTextContent(
325
- "api depends on orders",
1791
+ getListMock.mockImplementation(async () => {
1792
+ return {
1793
+ data: [
1794
+ { _id: "d0d0d0d0-0000-4000-8000-000000000001", name: "core-sw" },
1795
+ {
1796
+ _id: "d0d0d0d0-0000-4000-8000-000000000002",
1797
+ name: "rack-7",
1798
+ hostname: "WEB-1",
1799
+ },
1800
+ ],
1801
+ count: 2,
1802
+ };
1803
+ });
1804
+
1805
+ renderPanel({
1806
+ entity: { entityKey: "host-key", entityType: EntityType.Host },
1807
+ });
1808
+
1809
+ const link: HTMLElement = await screen.findByText(/Network device:/);
1810
+ expect(link).toHaveTextContent("Network device: rack-7");
1811
+ expect(getListMock).toHaveBeenCalledTimes(1);
1812
+ });
1813
+
1814
+ test("the inventory link opens the row the server returned", async () => {
1815
+ serveEntity(checkoutResponse());
1816
+
1817
+ renderPanel();
1818
+
1819
+ const link: HTMLElement = await screen.findByText("Inventory details");
1820
+ expect(link.closest("a")?.getAttribute("href")).toContain(ITEM_ID);
1821
+ });
1822
+
1823
+ test("details come from the full row's attributes", async () => {
1824
+ serveEntity(
1825
+ entityResponse(
1826
+ entityRow({
1827
+ key: "pg",
1828
+ type: EntityType.Database,
1829
+ name: "orders",
1830
+ resourceType: null,
1831
+ resourceId: null,
1832
+ identifyingAttributes: { "db.system.name": "postgresql" },
1833
+ descriptiveAttributes: {
1834
+ "k8s.pod.name": "orders",
1835
+ "host.ip": 10,
1836
+ },
1837
+ }),
1838
+ ),
1839
+ );
1840
+
1841
+ renderPanel({
1842
+ entity: { entityKey: "pg", entityType: EntityType.Database },
1843
+ });
1844
+
1845
+ expect(
1846
+ (await screen.findByText("Database engine")).parentElement,
1847
+ ).toHaveTextContent("postgresql");
1848
+ /* A value equal to the name, or not a string, is not repeated. */
1849
+ expect(screen.queryByText("Pod name")).not.toBeInTheDocument();
1850
+ expect(screen.queryByText("IP Address")).not.toBeInTheDocument();
1851
+ expect(screen.getByText("First seen")).toBeInTheDocument();
1852
+ });
1853
+ });
1854
+
1855
+ describe("where rows lead", () => {
1856
+ test("dependencies and callers open in place, with what the drawer knows about them", async () => {
1857
+ serveEntity(checkoutResponse());
1858
+ const onSelectEntity: MockFunction = getJestMockFunction();
1859
+
1860
+ renderPanel({ onSelectEntity });
1861
+ await waitForConnections();
1862
+
1863
+ fireEvent.click(
1864
+ screen.getByRole("button", { name: "View details for Orders database" }),
1865
+ );
1866
+ expect(onSelectEntity).toHaveBeenLastCalledWith({
1867
+ entityKey: "db",
1868
+ entityType: EntityType.Database,
1869
+ displayName: "Orders database",
1870
+ });
1871
+ fireEvent.click(
1872
+ screen.getByRole("button", { name: "View details for Web frontend" }),
1873
+ );
1874
+ expect(onSelectEntity).toHaveBeenLastCalledWith({
1875
+ entityKey: "web",
1876
+ entityType: EntityType.Service,
1877
+ displayName: "Web frontend",
1878
+ });
1879
+ });
1880
+
1881
+ test("placements open in the Infrastructure view when the page offers it", async () => {
1882
+ serveEntity(checkoutResponse());
1883
+ const onOpenInfrastructure: MockFunction = getJestMockFunction();
1884
+ const onSelectEntity: MockFunction = getJestMockFunction();
1885
+
1886
+ renderPanel({ onOpenInfrastructure, onSelectEntity });
1887
+ await waitForConnections();
1888
+
1889
+ fireEvent.click(
1890
+ screen.getByRole("button", { name: "View details for checkout-pod-1" }),
1891
+ );
1892
+ expect(onOpenInfrastructure).toHaveBeenCalledWith("pod-1");
1893
+ expect(onSelectEntity).not.toHaveBeenCalled();
1894
+ });
1895
+
1896
+ test("without an Infrastructure view, placements open in place too", async () => {
1897
+ serveEntity(checkoutResponse());
1898
+ const onSelectEntity: MockFunction = getJestMockFunction();
1899
+
1900
+ renderPanel({ onSelectEntity });
1901
+ await waitForConnections();
1902
+
1903
+ fireEvent.click(
1904
+ screen.getByRole("button", { name: "View details for checkout-pod-1" }),
1905
+ );
1906
+ expect(onSelectEntity).toHaveBeenCalledWith({
1907
+ entityKey: "pod-1",
1908
+ entityType: EntityType.KubernetesPod,
1909
+ displayName: "checkout-pod-1",
1910
+ });
1911
+ });
1912
+
1913
+ test("related infrastructure opens in place, never in the Infrastructure view", async () => {
1914
+ serveEntity(
1915
+ entityResponse(entityRow(), {
1916
+ related: sectionOf([
1917
+ connection({
1918
+ relationshipType: "member-of",
1919
+ otherKey: "ns",
1920
+ otherName: "shop",
1921
+ otherType: EntityType.KubernetesNamespace,
1922
+ }),
1923
+ ]),
1924
+ }),
1925
+ );
1926
+ const onOpenInfrastructure: MockFunction = getJestMockFunction();
1927
+ const onSelectEntity: MockFunction = getJestMockFunction();
1928
+
1929
+ renderPanel({ onOpenInfrastructure, onSelectEntity });
1930
+ await waitForConnections();
1931
+
1932
+ fireEvent.click(
1933
+ screen.getByRole("button", { name: "View details for shop" }),
1934
+ );
1935
+ expect(onSelectEntity).toHaveBeenCalledWith({
1936
+ entityKey: "ns",
1937
+ entityType: EntityType.KubernetesNamespace,
1938
+ displayName: "shop",
1939
+ });
1940
+ expect(onOpenInfrastructure).not.toHaveBeenCalled();
1941
+ });
1942
+
1943
+ test("callers without navigation support keep readable static connections", async () => {
1944
+ serveEntity(checkoutResponse());
1945
+
1946
+ renderPanel();
1947
+ await waitForConnections();
1948
+
1949
+ expect(
1950
+ screen.getByText("Checkout API depends on Orders database"),
1951
+ ).toBeInTheDocument();
1952
+ expect(
1953
+ screen.queryByRole("button", { name: /View details for/ }),
1954
+ ).not.toBeInTheDocument();
1955
+ });
1956
+
1957
+ test("the explore action is offered only by callers that can show it, with their wording", async () => {
1958
+ serveEntity(checkoutResponse());
1959
+ const onFocus: MockFunction = getJestMockFunction();
1960
+
1961
+ const view: RenderResult = renderPanel({ onFocus });
1962
+ fireEvent.click(
1963
+ screen.getByRole("button", { name: "Explore connections" }),
1964
+ );
1965
+ expect(onFocus).toHaveBeenCalledWith("svc");
1966
+ await waitForConnections();
1967
+ view.unmount();
1968
+
1969
+ renderPanel({ onFocus, focusButtonLabel: "Show where it is" });
1970
+ expect(
1971
+ screen.getByRole("button", { name: "Show where it is" }),
1972
+ ).toBeInTheDocument();
1973
+ await waitForConnections();
1974
+ cleanup();
1975
+
1976
+ renderPanel();
1977
+ expect(
1978
+ screen.queryByRole("button", { name: "Explore connections" }),
1979
+ ).not.toBeInTheDocument();
1980
+ await waitForConnections();
1981
+ });
1982
+ });
1983
+
1984
+ describe("service map context", () => {
1985
+ test("leads with status and the traffic in both directions", async () => {
1986
+ serveEntity(checkoutResponse());
1987
+
1988
+ renderPanel({
1989
+ traffic: {
1990
+ inbound: { calls: 0, errors: 0, avgDurationMs: null },
1991
+ outbound: { calls: 60, errors: 0, avgDurationMs: 4 },
1992
+ statusLabel: "Entry point",
1993
+ statusColor: "#6366f1",
1994
+ subtitle: "Service · Node.js",
1995
+ },
1996
+ });
1997
+
1998
+ expect(screen.getByTestId("entity-detail-status")).toHaveTextContent(
1999
+ "Entry point",
2000
+ );
2001
+ expect(screen.getByTestId("drawer-description")).toHaveTextContent(
2002
+ "Service · Node.js",
2003
+ );
2004
+ expect(
2005
+ screen.getByText("Requests it answered").parentElement,
2006
+ ).toHaveTextContent("None observed");
2007
+ expect(screen.getByText("Calls it made").parentElement).toHaveTextContent(
2008
+ "60/min",
2009
+ );
2010
+ expect(screen.getByText("Calls it made").parentElement).toHaveTextContent(
2011
+ "4ms",
2012
+ );
2013
+ await waitForConnections();
2014
+ });
2015
+
2016
+ test("lists active incidents and alerts for the service", async () => {
2017
+ serveEntity(checkoutResponse());
2018
+
2019
+ renderPanel({
2020
+ incidentStatus: {
2021
+ serviceId: SERVICE_ID,
2022
+ activeIncidentCount: 1,
2023
+ worstIncidentSeverityName: "Sev 1",
2024
+ worstIncidentSeverityColor: "#f00",
2025
+ incidents: [
2026
+ {
2027
+ id: "a1a1a1a1-0000-4000-8000-000000000001",
2028
+ title: "Checkout is down",
2029
+ severityName: "Sev 1",
2030
+ severityColor: "#f00",
2031
+ },
2032
+ ],
2033
+ activeAlertCount: 0,
2034
+ worstAlertSeverityName: null,
2035
+ worstAlertSeverityColor: null,
2036
+ alerts: [],
2037
+ },
2038
+ });
2039
+
2040
+ expect(screen.getByText("Checkout is down")).toBeInTheDocument();
2041
+ expect(screen.getByText(/Active incidents/)).toHaveTextContent(
2042
+ "Active incidents (1)",
326
2043
  );
2044
+ expect(screen.queryByText(/Active alerts/)).not.toBeInTheDocument();
2045
+ await waitForConnections();
327
2046
  });
328
2047
  });