@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
@@ -8,6 +8,7 @@ import {
8
8
  test,
9
9
  } from "@jest/globals";
10
10
  import {
11
+ act,
11
12
  cleanup,
12
13
  fireEvent,
13
14
  render,
@@ -17,44 +18,82 @@ import {
17
18
  import React from "react";
18
19
  import { MemoryRouter } from "react-router-dom";
19
20
  import getJestMockFunction, { MockFunction } from "../../MockType";
20
- import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
21
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
22
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
21
23
  import Route from "../../../Types/API/Route";
24
+ import URL from "../../../Types/API/URL";
25
+ import { JSONObject } from "../../../Types/JSON";
22
26
  import ObjectID from "../../../Types/ObjectID";
23
27
  import EntityType from "../../../Types/Telemetry/EntityType";
28
+ import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
29
+ import TimeRange from "../../../Types/Time/TimeRange";
30
+ import {
31
+ TOPOLOGY_API_FORMAT_VERSION,
32
+ TopologyApiPath,
33
+ } from "../../../Types/Topology/TopologyApi";
24
34
  import TopologyPage from "../../../../App/FeatureSet/Dashboard/src/Pages/Topology/TopologyPage";
35
+ import {
36
+ InfrastructureCollection,
37
+ InfrastructureTotals,
38
+ TopologyEntity,
39
+ TopologyRelationship,
40
+ TopologyRunsOnCounts,
41
+ TopologyTruncation,
42
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyData";
25
43
 
26
- const getListMock: MockFunction = getJestMockFunction();
44
+ /*
45
+ * The Topology page's three tabs load independently: Service Map and
46
+ * Infrastructure each POST their own endpoint the first time they are
47
+ * opened, Network loads nothing here. These tests drive the page through a
48
+ * fake server behind API.post and stub the three views, so they pin what the
49
+ * page asks for, when, and what it hands each view.
50
+ */
27
51
 
28
- jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
52
+ const postMock: MockFunction = getJestMockFunction();
53
+
54
+ /*
55
+ * What the page's translations return. Identity by default; a test that
56
+ * needs to see which fragments were translated swaps it for a marker.
57
+ */
58
+ const identity: (value: string) => string = (value: string): string => {
59
+ return value;
60
+ };
61
+ let translate: (value: string) => string = identity;
62
+
63
+ jest.mock("../../../UI/Utils/API/API", () => {
29
64
  return {
30
65
  __esModule: true,
31
66
  default: {
32
- getList: (...args: Array<unknown>) => {
33
- return getListMock(...args);
67
+ post: (...args: Array<unknown>) => {
68
+ return postMock(...args);
69
+ },
70
+ getFriendlyMessage: (error: unknown): string => {
71
+ if (error && typeof error === "object" && "message" in error) {
72
+ return String((error as { message: unknown }).message);
73
+ }
74
+ return "Unable to load topology";
34
75
  },
35
76
  },
36
77
  };
37
78
  });
38
79
 
39
- jest.mock("../../../UI/Utils/Project", () => {
80
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
40
81
  return {
41
82
  __esModule: true,
42
83
  default: {
43
- getCurrentProjectId: () => {
44
- return new ObjectID("dde060c6-fe0d-49ce-b44c-4035a13bc1db");
84
+ getCommonHeaders: (): Record<string, string> => {
85
+ return { tenantid: "dde060c6-fe0d-49ce-b44c-4035a13bc1db" };
45
86
  },
46
87
  },
47
88
  };
48
89
  });
49
90
 
50
- jest.mock("../../../UI/Utils/API/API", () => {
91
+ jest.mock("../../../UI/Utils/Project", () => {
51
92
  return {
52
93
  __esModule: true,
53
94
  default: {
54
- getFriendlyMessage: (error: unknown): string => {
55
- return error instanceof Error
56
- ? error.message
57
- : "Unable to load topology";
95
+ getCurrentProjectId: () => {
96
+ return new ObjectID("dde060c6-fe0d-49ce-b44c-4035a13bc1db");
58
97
  },
59
98
  },
60
99
  };
@@ -65,7 +104,7 @@ jest.mock("react-i18next", () => {
65
104
  useTranslation: () => {
66
105
  return {
67
106
  t: (value: string): string => {
68
- return value;
107
+ return translate(value);
69
108
  },
70
109
  };
71
110
  },
@@ -86,39 +125,89 @@ jest.mock(
86
125
  () => {
87
126
  return {
88
127
  __esModule: true,
89
- default: (): React.ReactElement => {
128
+ default: (props: {
129
+ onChange: (value: RangeStartAndEndDateTime) => void;
130
+ }): React.ReactElement => {
90
131
  return (
91
- <div data-testid="topology-time-picker">Connection time range</div>
132
+ <div data-testid="topology-time-picker">
133
+ Connection time range
134
+ <button
135
+ type="button"
136
+ onClick={() => {
137
+ props.onChange({ range: TimeRange.PAST_ONE_HOUR });
138
+ }}
139
+ >
140
+ Pick the past hour
141
+ </button>
142
+ </div>
92
143
  );
93
144
  },
94
145
  };
95
146
  },
96
147
  );
97
148
 
149
+ function names(entities: Array<TopologyEntity>): string {
150
+ return entities
151
+ .map((entity: TopologyEntity): string => {
152
+ return entity.displayName || "";
153
+ })
154
+ .join(",");
155
+ }
156
+
98
157
  jest.mock(
99
158
  "../../../../App/FeatureSet/Dashboard/src/Components/Topology/ServiceMapGraph",
100
159
  () => {
101
160
  return {
102
161
  __esModule: true,
103
162
  default: (props: {
104
- entities: Array<InventoryItem>;
163
+ entities: Array<TopologyEntity>;
164
+ relationships: Array<TopologyRelationship>;
165
+ runsOnCounts?: TopologyRunsOnCounts;
105
166
  includeInactive?: boolean;
106
167
  rangeStart?: Date | null;
168
+ metricsWindowSeconds: number;
169
+ timeRange: RangeStartAndEndDateTime;
107
170
  onOpenInfrastructure?: (key: string) => void;
108
171
  }): React.ReactElement => {
109
172
  return (
110
173
  <div data-testid="topology-services">
111
- Services:{" "}
112
- {props.entities
113
- .map((entity: InventoryItem): string => {
114
- return entity.displayName || "";
115
- })
116
- .join(",")}
174
+ Services: {names(props.entities)}
175
+ <span data-testid="services-relationships">
176
+ {props.relationships
177
+ .map((relationship: TopologyRelationship): string => {
178
+ return `${relationship.fromEntityKey}>${relationship.toEntityKey}`;
179
+ })
180
+ .join(",")}
181
+ </span>
182
+ <span data-testid="services-runs-on">
183
+ {Array.from(props.runsOnCounts?.entries() || [])
184
+ .map(
185
+ ([service, counts]: [
186
+ string,
187
+ Array<{ entityType: string; active: number }>,
188
+ ]): string => {
189
+ return `${service}:${counts
190
+ .map(
191
+ (count: {
192
+ entityType: string;
193
+ active: number;
194
+ }): string => {
195
+ return `${count.active} ${count.entityType}`;
196
+ },
197
+ )
198
+ .join("+")}`;
199
+ },
200
+ )
201
+ .join(",")}
202
+ </span>
117
203
  <span data-testid="services-inactive">
118
204
  {String(Boolean(props.includeInactive))}
119
205
  </span>
120
206
  <span data-testid="services-range-start">
121
- {props.rangeStart ? "set" : "unset"}
207
+ {props.rangeStart ? props.rangeStart.toISOString() : "unset"}
208
+ </span>
209
+ <span data-testid="services-metrics-window">
210
+ {props.metricsWindowSeconds}
122
211
  </span>
123
212
  <button
124
213
  type="button"
@@ -141,21 +230,48 @@ jest.mock(
141
230
  return {
142
231
  __esModule: true,
143
232
  default: (props: {
144
- entities: Array<InventoryItem>;
233
+ entities: Array<TopologyEntity>;
234
+ relationships: Array<TopologyRelationship>;
235
+ collections?: Array<InfrastructureCollection>;
236
+ totals?: InfrastructureTotals;
237
+ truncation?: TopologyTruncation | null;
145
238
  includeInactive?: boolean;
239
+ rangeStart?: Date | null;
146
240
  onOpenServiceMap?: (key: string) => void;
147
241
  }): React.ReactElement => {
148
242
  return (
149
243
  <div data-testid="topology-infrastructure">
150
- Infrastructure:{" "}
151
- {props.entities
152
- .map((entity: InventoryItem): string => {
153
- return entity.displayName || "";
154
- })
155
- .join(",")}
244
+ Infrastructure: {names(props.entities)}
245
+ <span data-testid="infrastructure-relationships">
246
+ {props.relationships
247
+ .map((relationship: TopologyRelationship): string => {
248
+ return `${relationship.fromEntityKey}-${relationship.relationshipType}->${relationship.toEntityKey}`;
249
+ })
250
+ .join(",")}
251
+ </span>
252
+ <span data-testid="infrastructure-collections">
253
+ {(props.collections || [])
254
+ .map((collection: InfrastructureCollection): string => {
255
+ return `${collection.entityType}:${collection.active}/${collection.total}`;
256
+ })
257
+ .join(",")}
258
+ </span>
259
+ <span data-testid="infrastructure-totals">
260
+ {props.totals
261
+ ? `${props.totals.activeResources}/${props.totals.resources}`
262
+ : "none"}
263
+ </span>
264
+ <span data-testid="infrastructure-truncation">
265
+ {props.truncation
266
+ ? `${props.truncation.shown}/${props.truncation.total}`
267
+ : "none"}
268
+ </span>
156
269
  <span data-testid="infrastructure-inactive">
157
270
  {String(Boolean(props.includeInactive))}
158
271
  </span>
272
+ <span data-testid="infrastructure-range-start">
273
+ {props.rangeStart ? props.rangeStart.toISOString() : "unset"}
274
+ </span>
159
275
  <button
160
276
  type="button"
161
277
  onClick={() => {
@@ -183,6 +299,159 @@ jest.mock(
183
299
  },
184
300
  );
185
301
 
302
+ interface PostOptions {
303
+ url: URL;
304
+ data: JSONObject;
305
+ }
306
+
307
+ interface RecordedRequest {
308
+ path: string;
309
+ rangeStart: string;
310
+ /* True when the request asked the server to bypass its response cache. */
311
+ fresh: boolean;
312
+ respond: (payload: JSONObject) => void;
313
+ fail: (statusCode: number, message: string) => void;
314
+ }
315
+
316
+ let requests: Array<RecordedRequest> = [];
317
+ /*
318
+ * How the fake server answers a request: a payload, an HTTP failure, or
319
+ * "hold" (the test answers it later).
320
+ */
321
+ type Answer = JSONObject | { status: number; message: string } | "hold";
322
+ let answer: (request: RecordedRequest) => Answer;
323
+
324
+ const ECHOED_RANGE_START: string = "2026-09-20T10:15:00.000Z";
325
+
326
+ function requestsTo(path: TopologyApiPath): Array<RecordedRequest> {
327
+ return requests.filter((request: RecordedRequest): boolean => {
328
+ return request.path === path;
329
+ });
330
+ }
331
+
332
+ function serviceMapPayload(extra: JSONObject = {}): JSONObject {
333
+ return {
334
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION,
335
+ rangeStart: ECHOED_RANGE_START,
336
+ generatedAt: "2026-09-21T10:15:42.000Z",
337
+ entities: [
338
+ {
339
+ key: "checkout",
340
+ type: EntityType.Service,
341
+ name: "Checkout API",
342
+ source: "discovered",
343
+ lastSeenAt: null,
344
+ },
345
+ {
346
+ key: "orders-db",
347
+ type: EntityType.Database,
348
+ name: "Orders DB",
349
+ source: "discovered",
350
+ lastSeenAt: null,
351
+ },
352
+ ],
353
+ dependencies: [
354
+ {
355
+ from: "checkout",
356
+ to: "orders-db",
357
+ callCount: 10,
358
+ errorCount: 0,
359
+ avgDurationMs: 4,
360
+ },
361
+ ],
362
+ runsOn: [
363
+ {
364
+ service: "checkout",
365
+ type: EntityType.KubernetesPod,
366
+ active: 3,
367
+ total: 4,
368
+ },
369
+ ],
370
+ entityTruncation: null,
371
+ dependencyTruncation: null,
372
+ ...extra,
373
+ };
374
+ }
375
+
376
+ function infrastructurePayload(extra: JSONObject = {}): JSONObject {
377
+ return {
378
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION,
379
+ rangeStart: ECHOED_RANGE_START,
380
+ generatedAt: "2026-09-21T10:15:42.000Z",
381
+ nodes: [
382
+ {
383
+ key: "node-a",
384
+ type: EntityType.KubernetesNode,
385
+ name: "Node A",
386
+ source: "discovered",
387
+ lastSeenAt: null,
388
+ },
389
+ {
390
+ key: "pod-1",
391
+ type: EntityType.KubernetesPod,
392
+ name: "Pod 1",
393
+ source: "discovered",
394
+ lastSeenAt: null,
395
+ parent: 0,
396
+ parentVia: "runs-on",
397
+ },
398
+ ],
399
+ services: [{ key: "checkout", name: "Checkout API" }],
400
+ placements: [[0, 1]],
401
+ collections: [
402
+ {
403
+ type: EntityType.IoTDevice,
404
+ total: 25000,
405
+ active: 24000,
406
+ lastSeenAt: null,
407
+ activeLastSeenAt: null,
408
+ },
409
+ ],
410
+ totals: { resources: 25002, activeResources: 24002 },
411
+ truncation: null,
412
+ ...extra,
413
+ };
414
+ }
415
+
416
+ function defaultAnswer(request: RecordedRequest): Answer {
417
+ return request.path === TopologyApiPath.ServiceMap
418
+ ? serviceMapPayload()
419
+ : infrastructurePayload();
420
+ }
421
+
422
+ function installFakeServer(): void {
423
+ postMock.mockImplementation((...args: Array<unknown>) => {
424
+ const options: PostOptions = args[0] as PostOptions;
425
+ return new Promise(
426
+ (
427
+ resolve: (value: HTTPResponse<JSONObject> | HTTPErrorResponse) => void,
428
+ ): void => {
429
+ const request: RecordedRequest = {
430
+ path: options.url.toString().replace(/^.*\/api(?=\/)/, ""),
431
+ rangeStart: String(options.data["rangeStart"]),
432
+ fresh: options.data["fresh"] === true,
433
+ respond: (payload: JSONObject): void => {
434
+ resolve(new HTTPResponse<JSONObject>(200, payload, {}));
435
+ },
436
+ fail: (statusCode: number, message: string): void => {
437
+ resolve(new HTTPErrorResponse(statusCode, { message }, {}));
438
+ },
439
+ };
440
+ requests.push(request);
441
+ const reply: Answer = answer(request);
442
+ if (reply === "hold") {
443
+ return;
444
+ }
445
+ if ("status" in reply && typeof reply["status"] === "number") {
446
+ request.fail(reply["status"], String(reply["message"]));
447
+ return;
448
+ }
449
+ request.respond(reply as JSONObject);
450
+ },
451
+ );
452
+ });
453
+ }
454
+
186
455
  function renderPage(): void {
187
456
  render(
188
457
  <MemoryRouter>
@@ -195,9 +464,16 @@ function renderPage(): void {
195
464
  );
196
465
  }
197
466
 
467
+ function tab(name: string): HTMLElement {
468
+ return screen.getByRole("tab", { name });
469
+ }
470
+
198
471
  beforeEach(() => {
199
- getListMock.mockReset();
200
- getListMock.mockResolvedValue({ data: [], count: 0, skip: 0, limit: 1000 });
472
+ requests = [];
473
+ answer = defaultAnswer;
474
+ translate = identity;
475
+ postMock.mockReset();
476
+ installFakeServer();
201
477
  window.history.replaceState({}, "", "/dashboard/project/topology/overview");
202
478
  });
203
479
 
@@ -206,96 +482,157 @@ afterEach(() => {
206
482
  jest.restoreAllMocks();
207
483
  });
208
484
 
209
- describe("topology map navigation and data isolation", () => {
210
- test("opens live Network while telemetry requests are still pending", async () => {
211
- getListMock.mockReturnValue(new Promise(() => {}));
485
+ describe("what each tab loads", () => {
486
+ test("the Service Map loads its own endpoint once, behind a labelled loader", async () => {
487
+ answer = (): Answer => {
488
+ return "hold";
489
+ };
212
490
  renderPage();
213
- fireEvent.click(await screen.findByRole("tab", { name: "Network" }));
214
- expect(screen.getByTestId("topology-network")).toBeVisible();
491
+ const loader: HTMLElement = await screen.findByText("Loading service map…");
492
+ expect(loader.closest('[role="status"]')).not.toBeNull();
215
493
  expect(
216
- screen.queryByTestId("topology-time-picker"),
217
- ).not.toBeInTheDocument();
218
- expect(new URLSearchParams(window.location.search).get("tab")).toBe(
219
- "Network",
494
+ requests.map((request: RecordedRequest): string => {
495
+ return request.path;
496
+ }),
497
+ ).toEqual([TopologyApiPath.ServiceMap]);
498
+ await act(async () => {
499
+ requests[0]!.respond(serviceMapPayload());
500
+ });
501
+ expect(screen.getByTestId("topology-services")).toHaveTextContent(
502
+ "Checkout API,Orders DB",
220
503
  );
504
+ expect(screen.queryByText("Loading service map…")).not.toBeInTheDocument();
505
+ expect(requests).toHaveLength(1);
221
506
  });
222
507
 
223
- test("telemetry failures stay inside their map tabs and can be retried", async () => {
224
- getListMock.mockRejectedValue(
225
- new Error("Telemetry is temporarily unavailable"),
508
+ test("the Service Map receives entities, dependencies, runs-on counts and the echoed range start", async () => {
509
+ renderPage();
510
+ await screen.findByTestId("topology-services");
511
+ expect(screen.getByTestId("services-relationships")).toHaveTextContent(
512
+ "checkout>orders-db",
513
+ );
514
+ expect(screen.getByTestId("services-runs-on")).toHaveTextContent(
515
+ `checkout:3 ${EntityType.KubernetesPod}`,
516
+ );
517
+ expect(screen.getByTestId("services-range-start")).toHaveTextContent(
518
+ ECHOED_RANGE_START,
226
519
  );
520
+ expect(screen.getByTestId("services-metrics-window")).toHaveTextContent(
521
+ "900",
522
+ );
523
+ });
524
+
525
+ test("Infrastructure receives resources, containment, collections, totals and the echoed range start", async () => {
526
+ window.history.replaceState({}, "", "?tab=Infrastructure");
227
527
  renderPage();
228
- await screen.findByText("Telemetry is temporarily unavailable");
229
- fireEvent.click(screen.getByRole("tab", { name: "Network" }));
230
- expect(screen.getByTestId("topology-network")).toBeVisible();
528
+ await screen.findByTestId("topology-infrastructure");
231
529
  expect(
232
- screen.queryByText("Telemetry is temporarily unavailable"),
233
- ).not.toBeInTheDocument();
234
- fireEvent.click(screen.getByRole("tab", { name: "Infrastructure" }));
235
- expect(
236
- screen.getByText("Telemetry is temporarily unavailable"),
237
- ).toBeVisible();
238
- getListMock.mockResolvedValue({ data: [], count: 0, skip: 0, limit: 1000 });
239
- fireEvent.click(screen.getByRole("button", { name: "Try again" }));
240
- expect(await screen.findByTestId("topology-infrastructure")).toBeVisible();
530
+ requests.map((request: RecordedRequest): string => {
531
+ return request.path;
532
+ }),
533
+ ).toEqual([TopologyApiPath.Infrastructure]);
534
+ expect(screen.getByTestId("topology-infrastructure")).toHaveTextContent(
535
+ "Node A,Pod 1,Checkout API",
536
+ );
241
537
  expect(
242
- screen.queryByText("Telemetry is temporarily unavailable"),
243
- ).not.toBeInTheDocument();
538
+ screen.getByTestId("infrastructure-relationships"),
539
+ ).toHaveTextContent("pod-1-runs-on->node-a,checkout-runs-on->pod-1");
540
+ expect(screen.getByTestId("infrastructure-collections")).toHaveTextContent(
541
+ `${EntityType.IoTDevice}:24000/25000`,
542
+ );
543
+ expect(screen.getByTestId("infrastructure-totals")).toHaveTextContent(
544
+ "24002/25002",
545
+ );
546
+ expect(screen.getByTestId("infrastructure-truncation")).toHaveTextContent(
547
+ "none",
548
+ );
549
+ expect(screen.getByTestId("infrastructure-range-start")).toHaveTextContent(
550
+ ECHOED_RANGE_START,
551
+ );
244
552
  });
245
553
 
246
- test("Service Map and Infrastructure share the same loaded catalog without refetching on a tab change", async () => {
247
- const entity: InventoryItem = new InventoryItem();
248
- entity.entityKey = "checkout";
249
- entity.displayName = "Checkout API";
250
- entity.entityType = EntityType.Service;
251
- getListMock.mockResolvedValueOnce({
252
- data: [entity],
253
- count: 1,
254
- skip: 0,
255
- limit: 1000,
256
- });
257
- getListMock.mockResolvedValueOnce({
258
- data: [],
259
- count: 0,
260
- skip: 0,
261
- limit: 1000,
262
- });
554
+ test("Infrastructure shows its own loader while it loads", async () => {
555
+ window.history.replaceState({}, "", "?tab=Infrastructure");
556
+ answer = (): Answer => {
557
+ return "hold";
558
+ };
263
559
  renderPage();
264
- expect(await screen.findByTestId("topology-services")).toHaveTextContent(
265
- "Checkout API",
560
+ const loader: HTMLElement = await screen.findByText(
561
+ "Loading infrastructure…",
266
562
  );
267
- fireEvent.click(screen.getByRole("tab", { name: "Infrastructure" }));
268
- expect(screen.getByTestId("topology-infrastructure")).toHaveTextContent(
563
+ expect(loader.closest('[role="status"]')).not.toBeNull();
564
+ expect(screen.queryByText("Loading service map…")).not.toBeInTheDocument();
565
+ });
566
+
567
+ test("switching tabs loads each telemetry tab once and never refetches", async () => {
568
+ renderPage();
569
+ expect(await screen.findByTestId("topology-services")).toHaveTextContent(
269
570
  "Checkout API",
270
571
  );
271
- fireEvent.click(screen.getByRole("tab", { name: "Service Map" }));
572
+ fireEvent.click(tab("Infrastructure"));
573
+ expect(
574
+ await screen.findByTestId("topology-infrastructure"),
575
+ ).toHaveTextContent("Node A");
576
+ fireEvent.click(tab("Network"));
577
+ fireEvent.click(tab("Service Map"));
272
578
  expect(screen.getByTestId("topology-services")).toHaveTextContent(
273
579
  "Checkout API",
274
580
  );
275
- expect(getListMock).toHaveBeenCalledTimes(2);
581
+ fireEvent.click(tab("Infrastructure"));
582
+ expect(screen.getByTestId("topology-infrastructure")).toBeVisible();
583
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(1);
584
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
585
+ /* Both tabs were requested for the same pinned range start. */
586
+ expect(requests[0]!.rangeStart).toBe(requests[1]!.rangeStart);
587
+ });
588
+
589
+ test("opens live Network while telemetry is still pending, and loads nothing for it", async () => {
590
+ answer = (): Answer => {
591
+ return "hold";
592
+ };
593
+ renderPage();
594
+ fireEvent.click(await screen.findByRole("tab", { name: "Network" }));
595
+ expect(screen.getByTestId("topology-network")).toBeVisible();
596
+ expect(
597
+ screen.queryByTestId("topology-time-picker"),
598
+ ).not.toBeInTheDocument();
599
+ expect(new URLSearchParams(window.location.search).get("tab")).toBe(
600
+ "Network",
601
+ );
602
+ expect(
603
+ requests.map((request: RecordedRequest): string => {
604
+ return request.path;
605
+ }),
606
+ ).toEqual([TopologyApiPath.ServiceMap]);
276
607
  });
277
608
 
278
609
  test.each(["Infrastructure", "Network"])(
279
610
  "restores a shared %s link on the first render",
280
- async (tab: string) => {
281
- window.history.replaceState({}, "", `?tab=${encodeURIComponent(tab)}`);
611
+ async (tabName: string) => {
612
+ window.history.replaceState(
613
+ {},
614
+ "",
615
+ `?tab=${encodeURIComponent(tabName)}`,
616
+ );
282
617
  renderPage();
283
618
  await waitFor(() => {
284
- expect(screen.getByRole("tab", { name: tab })).toHaveAttribute(
285
- "aria-selected",
286
- "true",
287
- );
619
+ expect(tab(tabName)).toHaveAttribute("aria-selected", "true");
288
620
  });
289
- if (tab === "Network") {
621
+ if (tabName === "Network") {
290
622
  expect(screen.getByTestId("topology-network")).toBeVisible();
291
623
  expect(
292
624
  screen.queryByTestId("topology-time-picker"),
293
625
  ).not.toBeInTheDocument();
626
+ await act(async () => {
627
+ await Promise.resolve();
628
+ });
629
+ expect(postMock).not.toHaveBeenCalled();
294
630
  } else {
295
631
  expect(
296
632
  await screen.findByTestId("topology-infrastructure"),
297
633
  ).toBeVisible();
298
634
  expect(screen.getByTestId("topology-time-picker")).toBeVisible();
635
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(0);
299
636
  }
300
637
  },
301
638
  );
@@ -308,32 +645,396 @@ describe("topology map navigation and data isolation", () => {
308
645
  );
309
646
  renderPage();
310
647
  expect(await screen.findByTestId("topology-services")).toBeVisible();
311
- fireEvent.click(screen.getByRole("tab", { name: "Infrastructure" }));
312
- fireEvent.click(screen.getByRole("tab", { name: "Service Map" }));
648
+ fireEvent.click(tab("Infrastructure"));
649
+ await screen.findByTestId("topology-infrastructure");
650
+ fireEvent.click(tab("Service Map"));
313
651
  const query: URLSearchParams = new URLSearchParams(window.location.search);
314
652
  expect(query.has("tab")).toBe(false);
315
653
  expect(query.get("search")).toBe("checkout");
316
654
  expect(query.get("infraSearch")).toBe("worker");
317
655
  });
656
+ });
318
657
 
658
+ describe("errors stay inside their tab", () => {
659
+ test("a Service Map failure does not block Network or Infrastructure, and Try again retries only the Service Map", async () => {
660
+ answer = (request: RecordedRequest): Answer => {
661
+ return request.path === TopologyApiPath.ServiceMap
662
+ ? { status: 500, message: "Telemetry is temporarily unavailable" }
663
+ : infrastructurePayload();
664
+ };
665
+ renderPage();
666
+ const failure: HTMLElement = await screen.findByText(
667
+ "Telemetry is temporarily unavailable",
668
+ );
669
+ expect(failure.closest('[role="alert"]')).not.toBeNull();
670
+
671
+ fireEvent.click(tab("Network"));
672
+ expect(screen.getByTestId("topology-network")).toBeVisible();
673
+ expect(
674
+ screen.queryByText("Telemetry is temporarily unavailable"),
675
+ ).not.toBeInTheDocument();
676
+
677
+ /* Infrastructure has its own endpoint and loads fine. */
678
+ fireEvent.click(tab("Infrastructure"));
679
+ expect(await screen.findByTestId("topology-infrastructure")).toBeVisible();
680
+ expect(
681
+ screen.queryByText("Telemetry is temporarily unavailable"),
682
+ ).not.toBeInTheDocument();
683
+
684
+ fireEvent.click(tab("Service Map"));
685
+ expect(
686
+ screen.getByText("Telemetry is temporarily unavailable"),
687
+ ).toBeVisible();
688
+ answer = defaultAnswer;
689
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
690
+ expect(await screen.findByTestId("topology-services")).toBeVisible();
691
+ expect(
692
+ screen.queryByText("Telemetry is temporarily unavailable"),
693
+ ).not.toBeInTheDocument();
694
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(2);
695
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
696
+
697
+ /* The retry did not throw Infrastructure's data away. */
698
+ fireEvent.click(tab("Infrastructure"));
699
+ expect(screen.getByTestId("topology-infrastructure")).toBeVisible();
700
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
701
+ });
702
+
703
+ test.each([
704
+ ["a newer payload format", "version"],
705
+ ["a server without the endpoint", "404"],
706
+ ])(
707
+ "%s asks for a page reload instead of offering a pointless retry",
708
+ async (_label: string, mode: string) => {
709
+ answer = (): Answer => {
710
+ return mode === "404"
711
+ ? { status: 404, message: "Not found" }
712
+ : serviceMapPayload({
713
+ formatVersion: TOPOLOGY_API_FORMAT_VERSION + 1,
714
+ });
715
+ };
716
+ renderPage();
717
+ expect(
718
+ await screen.findByText("Topology was updated. Reload the page."),
719
+ ).toBeVisible();
720
+ expect(screen.getByRole("button", { name: "Reload page" })).toBeVisible();
721
+ expect(
722
+ screen.queryByRole("button", { name: "Try again" }),
723
+ ).not.toBeInTheDocument();
724
+ },
725
+ );
726
+
727
+ /*
728
+ * The server caps concurrent topology work and answers 429 when it is
729
+ * full. That passes in a moment, so the tab says so in plain, translated
730
+ * words and offers "Try again" — not the outdated-bundle reload.
731
+ */
732
+ test("a busy server (429) gets a friendly message and Try again, which recovers", async () => {
733
+ answer = (): Answer => {
734
+ return {
735
+ status: 429,
736
+ message: "Too many topology requests are running for this project.",
737
+ };
738
+ };
739
+ translate = (value: string): string => {
740
+ return value === "The topology service is busy. Try again in a moment."
741
+ ? "[busy, translated]"
742
+ : value;
743
+ };
744
+ renderPage();
745
+ const failure: HTMLElement = await screen.findByText("[busy, translated]");
746
+ expect(failure.closest('[role="alert"]')).not.toBeNull();
747
+ expect(
748
+ screen.queryByText(
749
+ "Too many topology requests are running for this project.",
750
+ ),
751
+ ).not.toBeInTheDocument();
752
+ expect(
753
+ screen.queryByText("Topology was updated. Reload the page."),
754
+ ).not.toBeInTheDocument();
755
+ expect(
756
+ screen.queryByRole("button", { name: "Reload page" }),
757
+ ).not.toBeInTheDocument();
758
+
759
+ answer = defaultAnswer;
760
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
761
+ expect(await screen.findByTestId("topology-services")).toBeVisible();
762
+ expect(screen.queryByText("[busy, translated]")).not.toBeInTheDocument();
763
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(2);
764
+ /* A retry is an ordinary load, not an explicit refresh. */
765
+ expect(
766
+ requests.map((request: RecordedRequest): boolean => {
767
+ return request.fresh;
768
+ }),
769
+ ).toEqual([false, false]);
770
+ });
771
+ });
772
+
773
+ describe("the refresh button and the time range", () => {
774
+ test("refresh is disabled while the active tab loads, then reloads only the active tab", async () => {
775
+ answer = (): Answer => {
776
+ return "hold";
777
+ };
778
+ renderPage();
779
+ const refresh: HTMLElement = await screen.findByRole("button", {
780
+ name: "Refresh topology",
781
+ });
782
+ expect(refresh).toBeDisabled();
783
+ expect(refresh).not.toHaveAttribute("title");
784
+ await act(async () => {
785
+ requests[0]!.respond(serviceMapPayload());
786
+ });
787
+ expect(refresh).toBeEnabled();
788
+ expect(refresh.getAttribute("title")).toContain("Last refreshed");
789
+
790
+ /* Infrastructure is loading: the button follows the ACTIVE tab. */
791
+ fireEvent.click(tab("Infrastructure"));
792
+ await waitFor(() => {
793
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
794
+ });
795
+ expect(
796
+ screen.getByRole("button", { name: "Refresh topology" }),
797
+ ).toBeDisabled();
798
+ await act(async () => {
799
+ requestsTo(TopologyApiPath.Infrastructure)[0]!.respond(
800
+ infrastructurePayload(),
801
+ );
802
+ });
803
+ expect(
804
+ screen.getByRole("button", { name: "Refresh topology" }),
805
+ ).toBeEnabled();
806
+
807
+ answer = defaultAnswer;
808
+ fireEvent.click(screen.getByRole("button", { name: "Refresh topology" }));
809
+ await screen.findByTestId("topology-infrastructure");
810
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(2);
811
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(1);
812
+
813
+ /* The Service Map was dropped with the old generation: it loads again. */
814
+ fireEvent.click(tab("Service Map"));
815
+ await screen.findByTestId("topology-services");
816
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(2);
817
+ });
818
+
819
+ /*
820
+ * The server keeps each map for a minute. The refresh button is the user
821
+ * asking for current data, so it — and nothing else — bypasses that cache,
822
+ * for the active tab and for the other tab's first load after it.
823
+ */
824
+ test("refresh asks the server for fresh data; ordinary loads use its cache", async () => {
825
+ renderPage();
826
+ await screen.findByTestId("topology-services");
827
+ fireEvent.click(tab("Infrastructure"));
828
+ await screen.findByTestId("topology-infrastructure");
829
+ fireEvent.click(tab("Service Map"));
830
+ fireEvent.click(screen.getByTestId("topology-show-inactive"));
831
+ expect(
832
+ requests.map((request: RecordedRequest): boolean => {
833
+ return request.fresh;
834
+ }),
835
+ ).toEqual([false, false]);
836
+
837
+ fireEvent.click(screen.getByRole("button", { name: "Refresh topology" }));
838
+ await waitFor(() => {
839
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(2);
840
+ });
841
+ await screen.findByTestId("topology-services");
842
+ fireEvent.click(tab("Infrastructure"));
843
+ await screen.findByTestId("topology-infrastructure");
844
+ expect(
845
+ requests.map((request: RecordedRequest): string => {
846
+ return `${request.path}:${request.fresh ? "fresh" : "cached"}`;
847
+ }),
848
+ ).toEqual([
849
+ `${TopologyApiPath.ServiceMap}:cached`,
850
+ `${TopologyApiPath.Infrastructure}:cached`,
851
+ `${TopologyApiPath.ServiceMap}:fresh`,
852
+ `${TopologyApiPath.Infrastructure}:fresh`,
853
+ ]);
854
+
855
+ /* A new time range is an ordinary load again. */
856
+ fireEvent.click(screen.getByRole("button", { name: "Pick the past hour" }));
857
+ await waitFor(() => {
858
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(3);
859
+ });
860
+ await screen.findByTestId("topology-infrastructure");
861
+ expect(requests[requests.length - 1]!.fresh).toBe(false);
862
+ });
863
+
864
+ test("a new time range reloads the active tab for that range", async () => {
865
+ renderPage();
866
+ await screen.findByTestId("topology-services");
867
+ const firstStart: string = requests[0]!.rangeStart;
868
+ fireEvent.click(screen.getByRole("button", { name: "Pick the past hour" }));
869
+ await waitFor(() => {
870
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(2);
871
+ });
872
+ const secondStart: string = requests[1]!.rangeStart;
873
+ expect(new Date(secondStart).getTime()).toBeGreaterThan(
874
+ new Date(firstStart).getTime(),
875
+ );
876
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(0);
877
+ expect(await screen.findByTestId("topology-services")).toBeVisible();
878
+ });
879
+ });
880
+
881
+ describe("the truncation banner", () => {
882
+ const SERVICE_MAP_NOTE: string =
883
+ "The map, counts and search cover what is shown.";
884
+ const INFRASTRUCTURE_NOTE: string =
885
+ "Counts are exact; the map and search cover the resources shown.";
886
+
887
+ test("appears only for the active tab's truncated payload, with exact totals", async () => {
888
+ answer = (request: RecordedRequest): Answer => {
889
+ return request.path === TopologyApiPath.ServiceMap
890
+ ? serviceMapPayload({
891
+ entityTruncation: { shown: 50000, total: 61234 },
892
+ })
893
+ : infrastructurePayload();
894
+ };
895
+ renderPage();
896
+ await screen.findByTestId("topology-services");
897
+ const banner: HTMLElement = screen.getByTestId("topology-truncation");
898
+ expect(banner).toHaveAttribute("role", "status");
899
+ expect(banner).toHaveTextContent(
900
+ `${(50000).toLocaleString()} of ${(61234).toLocaleString()} resources shown.`,
901
+ );
902
+ /*
903
+ * The Service Map's tiles count what was shipped, so it must not claim
904
+ * its counts are exact.
905
+ */
906
+ expect(banner).toHaveTextContent(SERVICE_MAP_NOTE);
907
+ expect(banner).not.toHaveTextContent("Counts are exact");
908
+ expect(banner).not.toHaveTextContent("connections shown.");
909
+ expect(banner).not.toHaveTextContent("Partial inventory");
910
+
911
+ fireEvent.click(tab("Infrastructure"));
912
+ await screen.findByTestId("topology-infrastructure");
913
+ expect(screen.queryByTestId("topology-truncation")).not.toBeInTheDocument();
914
+
915
+ fireEvent.click(tab("Network"));
916
+ expect(screen.queryByTestId("topology-truncation")).not.toBeInTheDocument();
917
+
918
+ fireEvent.click(tab("Service Map"));
919
+ expect(screen.getByTestId("topology-truncation")).toBeVisible();
920
+ });
921
+
922
+ /*
923
+ * The dependency cap limits connection rows, not resources: calling
924
+ * 200,000 connections "resources" would misstate what is missing.
925
+ */
926
+ test("a Service Map with only its connections capped says connections", async () => {
927
+ answer = (request: RecordedRequest): Answer => {
928
+ return request.path === TopologyApiPath.ServiceMap
929
+ ? serviceMapPayload({
930
+ dependencyTruncation: { shown: 200000, total: 250001 },
931
+ })
932
+ : infrastructurePayload();
933
+ };
934
+ renderPage();
935
+ await screen.findByTestId("topology-services");
936
+ const banner: HTMLElement = screen.getByTestId("topology-truncation");
937
+ expect(banner.textContent).toBe(
938
+ `${(200000).toLocaleString()} of ${(250001).toLocaleString()} connections shown. ${SERVICE_MAP_NOTE}`,
939
+ );
940
+ expect(banner).not.toHaveTextContent("resources shown.");
941
+ expect(banner).not.toHaveTextContent("Counts are exact");
942
+ });
943
+
944
+ test("a Service Map with both caps hit reports both", async () => {
945
+ answer = (): Answer => {
946
+ return serviceMapPayload({
947
+ entityTruncation: { shown: 50000, total: 50001 },
948
+ dependencyTruncation: { shown: 200000, total: 200002 },
949
+ });
950
+ };
951
+ renderPage();
952
+ await screen.findByTestId("topology-services");
953
+ expect(screen.getByTestId("topology-truncation").textContent).toBe(
954
+ `${(50000).toLocaleString()} of ${(50001).toLocaleString()} resources shown. ` +
955
+ `${(200000).toLocaleString()} of ${(200002).toLocaleString()} connections shown. ` +
956
+ SERVICE_MAP_NOTE,
957
+ );
958
+ });
959
+
960
+ /*
961
+ * Every word of the banner goes through translation, and no number is
962
+ * baked into a key: the fragments are translated, the figures are not.
963
+ */
964
+ test("the banner is built from translated fragments with the numbers outside them", async () => {
965
+ translate = (value: string): string => {
966
+ return `«${value}»`;
967
+ };
968
+ answer = (): Answer => {
969
+ return serviceMapPayload({
970
+ entityTruncation: { shown: 50000, total: 50001 },
971
+ dependencyTruncation: { shown: 200000, total: 200002 },
972
+ });
973
+ };
974
+ renderPage();
975
+ await screen.findByTestId("topology-services");
976
+ expect(screen.getByTestId("topology-truncation").textContent).toBe(
977
+ `${(50000).toLocaleString()} «of» ${(50001).toLocaleString()} «resources shown.» ` +
978
+ `${(200000).toLocaleString()} «of» ${(200002).toLocaleString()} «connections shown.» ` +
979
+ `«${SERVICE_MAP_NOTE}»`,
980
+ );
981
+ });
982
+
983
+ test("an Infrastructure cap shows on Infrastructure only, and reaches the explorer", async () => {
984
+ answer = (request: RecordedRequest): Answer => {
985
+ return request.path === TopologyApiPath.Infrastructure
986
+ ? infrastructurePayload({
987
+ truncation: { shown: 200000, total: 212345 },
988
+ })
989
+ : serviceMapPayload();
990
+ };
991
+ renderPage();
992
+ await screen.findByTestId("topology-services");
993
+ expect(screen.queryByTestId("topology-truncation")).not.toBeInTheDocument();
994
+ fireEvent.click(tab("Infrastructure"));
995
+ await screen.findByTestId("topology-infrastructure");
996
+ expect(screen.getByTestId("topology-truncation").textContent).toBe(
997
+ `${(200000).toLocaleString()} of ${(212345).toLocaleString()} resources shown. ${INFRASTRUCTURE_NOTE}`,
998
+ );
999
+ /* Only Infrastructure's summary switches to the server's exact totals. */
1000
+ expect(screen.getByTestId("topology-truncation")).not.toHaveTextContent(
1001
+ SERVICE_MAP_NOTE,
1002
+ );
1003
+ expect(screen.getByTestId("infrastructure-truncation")).toHaveTextContent(
1004
+ "200000/212345",
1005
+ );
1006
+ });
1007
+
1008
+ test("no banner while the tab is still loading", async () => {
1009
+ answer = (): Answer => {
1010
+ return "hold";
1011
+ };
1012
+ renderPage();
1013
+ await screen.findByText("Loading service map…");
1014
+ expect(screen.queryByTestId("topology-truncation")).not.toBeInTheDocument();
1015
+ });
1016
+ });
1017
+
1018
+ describe("show inactive and cross-tab links", () => {
319
1019
  test("show inactive reaches both telemetry views and the shareable URL", async () => {
320
1020
  renderPage();
321
1021
  await screen.findByTestId("topology-services");
322
1022
  expect(screen.getByTestId("services-inactive")).toHaveTextContent("false");
323
- expect(screen.getByTestId("services-range-start")).toHaveTextContent("set");
324
1023
  fireEvent.click(screen.getByTestId("topology-show-inactive"));
325
1024
  expect(screen.getByTestId("services-inactive")).toHaveTextContent("true");
326
1025
  expect(new URLSearchParams(window.location.search).get("inactive")).toBe(
327
1026
  "show",
328
1027
  );
329
- fireEvent.click(screen.getByRole("tab", { name: "Infrastructure" }));
330
- expect(screen.getByTestId("infrastructure-inactive")).toHaveTextContent(
331
- "true",
332
- );
1028
+ fireEvent.click(tab("Infrastructure"));
1029
+ expect(
1030
+ await screen.findByTestId("infrastructure-inactive"),
1031
+ ).toHaveTextContent("true");
333
1032
  fireEvent.click(screen.getByTestId("topology-show-inactive"));
334
1033
  expect(new URLSearchParams(window.location.search).has("inactive")).toBe(
335
1034
  false,
336
1035
  );
1036
+ /* The toggle is client-side: it never refetches. */
1037
+ expect(requests).toHaveLength(2);
337
1038
  });
338
1039
 
339
1040
  test("a shared link restores show inactive", async () => {
@@ -345,51 +1046,54 @@ describe("topology map navigation and data isolation", () => {
345
1046
  expect(screen.getByTestId("topology-show-inactive")).toBeChecked();
346
1047
  });
347
1048
 
348
- test("the Service Map hands a resource to Infrastructure, and back", async () => {
1049
+ test("the Service Map hands a resource to Infrastructure, which loads once, and back", async () => {
349
1050
  renderPage();
350
1051
  await screen.findByTestId("topology-services");
351
1052
  fireEvent.click(
352
1053
  screen.getByRole("button", { name: "Open pod in infrastructure" }),
353
1054
  );
354
- expect(screen.getByRole("tab", { name: "Infrastructure" })).toHaveAttribute(
355
- "aria-selected",
356
- "true",
357
- );
1055
+ expect(tab("Infrastructure")).toHaveAttribute("aria-selected", "true");
358
1056
  let query: URLSearchParams = new URLSearchParams(window.location.search);
359
1057
  expect(query.get("tab")).toBe("Infrastructure");
360
1058
  expect(query.get("infraFocus")).toBe("pod-1");
1059
+ expect(await screen.findByTestId("topology-infrastructure")).toBeVisible();
361
1060
 
362
1061
  fireEvent.click(
363
1062
  screen.getByRole("button", { name: "Open service on map" }),
364
1063
  );
365
- expect(screen.getByRole("tab", { name: "Service Map" })).toHaveAttribute(
366
- "aria-selected",
367
- "true",
368
- );
1064
+ expect(tab("Service Map")).toHaveAttribute("aria-selected", "true");
369
1065
  query = new URLSearchParams(window.location.search);
370
1066
  expect(query.has("tab")).toBe(false);
371
1067
  expect(query.get("focus")).toBe("checkout");
372
1068
  expect(query.get("serviceView")).toBe("map");
373
- // Switching views never refetches the shared snapshot.
374
- expect(getListMock).toHaveBeenCalledTimes(2);
1069
+ expect(screen.getByTestId("topology-services")).toBeVisible();
1070
+
1071
+ /* Round trip again: still no refetch of either tab. */
1072
+ fireEvent.click(
1073
+ screen.getByRole("button", { name: "Open pod in infrastructure" }),
1074
+ );
1075
+ expect(screen.getByTestId("topology-infrastructure")).toBeVisible();
1076
+ expect(requestsTo(TopologyApiPath.ServiceMap)).toHaveLength(1);
1077
+ expect(requestsTo(TopologyApiPath.Infrastructure)).toHaveLength(1);
375
1078
  });
376
1079
 
377
1080
  test("keyboard navigation switches maps and updates the shareable tab", async () => {
378
1081
  renderPage();
379
1082
  await screen.findByTestId("topology-services");
380
- const serviceTab: HTMLElement = screen.getByRole("tab", {
381
- name: "Service Map",
382
- });
1083
+ const serviceTab: HTMLElement = tab("Service Map");
383
1084
  serviceTab.focus();
384
1085
  fireEvent.keyDown(serviceTab, { key: "ArrowRight" });
385
1086
  await waitFor(() => {
386
- expect(
387
- screen.getByRole("tab", { name: "Infrastructure" }),
388
- ).toHaveAttribute("aria-selected", "true");
1087
+ expect(tab("Infrastructure")).toHaveAttribute("aria-selected", "true");
389
1088
  });
390
1089
  expect(new URLSearchParams(window.location.search).get("tab")).toBe(
391
1090
  "Infrastructure",
392
1091
  );
393
- expect(screen.getByRole("tab", { name: "Infrastructure" })).toHaveFocus();
1092
+ expect(tab("Infrastructure")).toHaveFocus();
1093
+ await screen.findByTestId("topology-infrastructure");
1094
+ fireEvent.keyDown(tab("Infrastructure"), { key: "End" });
1095
+ await waitFor(() => {
1096
+ expect(tab("Network")).toHaveAttribute("aria-selected", "true");
1097
+ });
394
1098
  });
395
1099
  });