@oneuptime/common 14.0.5 → 14.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -3,8 +3,6 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals";
3
3
  import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
4
  import React from "react";
5
5
  import { Edge, Node } from "reactflow";
6
- import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
7
- import InventoryItemRelationship from "../../../Models/DatabaseModels/InventoryItemRelationship";
8
6
  import EntityType from "../../../Types/Telemetry/EntityType";
9
7
  import EntityRelationshipType from "../../../Types/Telemetry/EntityRelationshipType";
10
8
  import EntitySource from "../../../Types/Telemetry/EntitySource";
@@ -12,14 +10,28 @@ import getJestMockFunction, { MockFunction } from "../../MockType";
12
10
  import InfrastructureGraph, {
13
11
  InfrastructureMapLayout,
14
12
  MAX_MAP_CARDS,
13
+ TRAFFIC_EDGE_PREFIX,
14
+ TRAFFIC_ROUTE_EDGE_TYPE,
15
+ TrafficEdgeData,
15
16
  cardForNode,
17
+ describeTrafficLink,
16
18
  layoutInfrastructureMap,
19
+ trafficEdges,
20
+ trafficLanePath,
21
+ trafficMetricLabel,
22
+ trafficRoutePath,
17
23
  } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/InfrastructureGraph";
18
24
  import {
19
25
  InfrastructureTopologyModel,
26
+ InfrastructureTrafficLink,
20
27
  buildInfrastructureTopologyModel,
21
28
  collectMapCards,
22
29
  } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/InfrastructureTopologyModel";
30
+ import { HEALTH_COLORS } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyMeta";
31
+ import {
32
+ TopologyEntity,
33
+ TopologyRelationship,
34
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyData";
23
35
 
24
36
  jest.mock("react-i18next", () => {
25
37
  return {
@@ -46,6 +58,9 @@ jest.mock("reactflow", () => {
46
58
  nodes: Array<Node>;
47
59
  edges: Array<Edge>;
48
60
  onNodeClick: (event: React.MouseEvent, node: Node) => void;
61
+ onEdgeClick?: (event: React.MouseEvent, edge: Edge) => void;
62
+ onEdgeMouseEnter?: (event: React.MouseEvent, edge: Edge) => void;
63
+ onEdgeMouseLeave?: (event: React.MouseEvent, edge: Edge) => void;
49
64
  }): React.ReactElement => {
50
65
  return (
51
66
  <div data-testid="infrastructure-canvas">
@@ -63,6 +78,27 @@ jest.mock("reactflow", () => {
63
78
  </button>
64
79
  );
65
80
  })}
81
+ {props.edges.map((edge: Edge): React.ReactElement => {
82
+ return (
83
+ <button
84
+ type="button"
85
+ key={edge.id}
86
+ data-testid={`edge-${edge.id}`}
87
+ aria-label={edge.ariaLabel}
88
+ onClick={(event: React.MouseEvent): void => {
89
+ props.onEdgeClick?.(event, edge);
90
+ }}
91
+ onMouseEnter={(event: React.MouseEvent): void => {
92
+ props.onEdgeMouseEnter?.(event, edge);
93
+ }}
94
+ onMouseLeave={(event: React.MouseEvent): void => {
95
+ props.onEdgeMouseLeave?.(event, edge);
96
+ }}
97
+ >
98
+ {typeof edge.label === "string" ? edge.label : ""}
99
+ </button>
100
+ );
101
+ })}
66
102
  <span data-testid="edge-count">{props.edges.length}</span>
67
103
  </div>
68
104
  );
@@ -84,30 +120,27 @@ jest.mock("reactflow", () => {
84
120
 
85
121
  const NOW: Date = new Date("2026-09-07T10:00:00Z");
86
122
 
123
+ /* The lean rows the Topology API sends. */
87
124
  function entity(
88
125
  key: string,
89
126
  type: EntityType,
90
127
  name: string = key,
91
128
  lastSeenAt: Date = NOW,
92
- ): InventoryItem {
93
- const item: InventoryItem = new InventoryItem();
94
- item.entityKey = key;
95
- item.displayName = name;
96
- item.entityType = type;
97
- item.lastSeenAt = lastSeenAt;
98
- item.source = EntitySource.Discovered;
99
- return item;
129
+ ): TopologyEntity {
130
+ return {
131
+ entityKey: key,
132
+ displayName: name,
133
+ entityType: type,
134
+ lastSeenAt,
135
+ source: EntitySource.Discovered,
136
+ };
100
137
  }
101
138
  function edge(
102
139
  from: string,
103
140
  to: string,
104
141
  type: EntityRelationshipType,
105
- ): InventoryItemRelationship {
106
- const item: InventoryItemRelationship = new InventoryItemRelationship();
107
- item.fromEntityKey = from;
108
- item.toEntityKey = to;
109
- item.relationshipType = type;
110
- return item;
142
+ ): TopologyRelationship {
143
+ return { fromEntityKey: from, toEntityKey: to, relationshipType: type };
111
144
  }
112
145
 
113
146
  function kubernetesModel(): InfrastructureTopologyModel {
@@ -208,7 +241,7 @@ describe("layoutInfrastructureMap", () => {
208
241
  });
209
242
 
210
243
  test("a huge scope is capped with an overflow card", () => {
211
- const hosts: Array<InventoryItem> = Array.from(
244
+ const hosts: Array<TopologyEntity> = Array.from(
212
245
  { length: MAX_MAP_CARDS + 10 },
213
246
  (_value: unknown, index: number) => {
214
247
  return entity(`h${index}`, EntityType.Host, `server-${index}x`);
@@ -220,7 +253,7 @@ describe("layoutInfrastructureMap", () => {
220
253
  );
221
254
  const layout: InfrastructureMapLayout = layoutInfrastructureMap({
222
255
  model,
223
- nodeIds: hosts.map((host: InventoryItem) => {
256
+ nodeIds: hosts.map((host: TopologyEntity) => {
224
257
  return host.entityKey!;
225
258
  }),
226
259
  now: NOW,
@@ -266,7 +299,7 @@ describe("layoutInfrastructureMap", () => {
266
299
  });
267
300
 
268
301
  test("a long column of running cards wraps instead of shrinking the map", () => {
269
- const hosts: Array<InventoryItem> = Array.from(
302
+ const hosts: Array<TopologyEntity> = Array.from(
270
303
  { length: 12 },
271
304
  (_value: unknown, index: number) => {
272
305
  return entity(`h${index}`, EntityType.Host, `machine-${index}x`);
@@ -274,13 +307,13 @@ describe("layoutInfrastructureMap", () => {
274
307
  );
275
308
  const model: InfrastructureTopologyModel = buildInfrastructureTopologyModel(
276
309
  [entity("svc", EntityType.Service), ...hosts],
277
- hosts.map((host: InventoryItem) => {
310
+ hosts.map((host: TopologyEntity) => {
278
311
  return edge("svc", host.entityKey!, EntityRelationshipType.HostedOn);
279
312
  }),
280
313
  );
281
314
  const layout: InfrastructureMapLayout = layoutInfrastructureMap({
282
315
  model,
283
- nodeIds: hosts.map((host: InventoryItem) => {
316
+ nodeIds: hosts.map((host: TopologyEntity) => {
284
317
  return host.entityKey!;
285
318
  }),
286
319
  now: NOW,
@@ -348,9 +381,117 @@ describe("cardForNode", () => {
348
381
  });
349
382
  });
350
383
 
384
+ /*
385
+ * A collection — a flat type with more items than the map ships, like
386
+ * thousands of IoT devices — is one card with its count, never a card per
387
+ * item, and opening it opens the collection.
388
+ */
389
+ function collectionModel(): InfrastructureTopologyModel {
390
+ return buildInfrastructureTopologyModel(
391
+ [
392
+ entity("svc", EntityType.Service),
393
+ entity("switch", EntityType.NetworkDevice, "core-switch"),
394
+ ],
395
+ [edge("svc", "switch", EntityRelationshipType.RunsOn)],
396
+ {
397
+ rangeStart: new Date("2026-09-06T10:00:00Z"),
398
+ collections: [
399
+ {
400
+ entityType: EntityType.IoTDevice,
401
+ total: 5000,
402
+ active: 4200,
403
+ lastSeenAt: NOW,
404
+ activeLastSeenAt: NOW,
405
+ },
406
+ ],
407
+ },
408
+ );
409
+ }
410
+
411
+ describe("collections on the map", () => {
412
+ test("a collection is one stacked card that carries its count", () => {
413
+ const model: InfrastructureTopologyModel = collectionModel();
414
+ const card: ReturnType<typeof cardForNode> = cardForNode(
415
+ model,
416
+ model.nodes.get("collection:iot.device")!,
417
+ NOW,
418
+ );
419
+ expect(card.title).toBe("IoT Devices");
420
+ expect(card.subtitle).toBe("Collection");
421
+ expect(card.statusLabel).toBe("Active · 4,200 IoT devices");
422
+ expect(card.stacked).toBe(true);
423
+ expect(card.dimmed).toBe(false);
424
+ expect(card.footer).toBeUndefined();
425
+ });
426
+
427
+ test("the overview draws the collection as a single card beside the devices", () => {
428
+ const model: InfrastructureTopologyModel = collectionModel();
429
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
430
+ model,
431
+ nodeIds: collectMapCards(model, null),
432
+ now: NOW,
433
+ });
434
+ expect(
435
+ layout.nodes.map((node: Node): string => {
436
+ return node.id;
437
+ }),
438
+ ).toEqual(["service:svc", "switch", "collection:iot.device"]);
439
+ expect(
440
+ layout.edges.map((item: Edge): string => {
441
+ return item.target;
442
+ }),
443
+ ).toEqual(["switch"]);
444
+ });
445
+
446
+ test("opening the card opens the collection", () => {
447
+ const model: InfrastructureTopologyModel = collectionModel();
448
+ const onOpenNode: MockFunction = getJestMockFunction();
449
+ render(
450
+ <InfrastructureGraph
451
+ model={model}
452
+ nodeIds={collectMapCards(model, null)}
453
+ onOpenNode={onOpenNode}
454
+ now={NOW}
455
+ />,
456
+ );
457
+ expect(screen.getByTestId("node-collection:iot.device")).toHaveTextContent(
458
+ "IoT Devices",
459
+ );
460
+ fireEvent.click(screen.getByTestId("node-collection:iot.device"));
461
+ expect(onOpenNode).toHaveBeenCalledWith("collection:iot.device");
462
+ });
463
+
464
+ test("a collection whose items all went quiet is dimmed", () => {
465
+ const model: InfrastructureTopologyModel = buildInfrastructureTopologyModel(
466
+ [],
467
+ [],
468
+ {
469
+ rangeStart: new Date("2026-09-06T10:00:00Z"),
470
+ includeInactive: true,
471
+ collections: [
472
+ {
473
+ entityType: EntityType.CloudResource,
474
+ total: 1500,
475
+ active: 0,
476
+ lastSeenAt: new Date("2026-08-10T10:00:00Z"),
477
+ activeLastSeenAt: null,
478
+ },
479
+ ],
480
+ },
481
+ );
482
+ const card: ReturnType<typeof cardForNode> = cardForNode(
483
+ model,
484
+ model.nodes.get("collection:cloud.resource")!,
485
+ NOW,
486
+ );
487
+ expect(card.dimmed).toBe(true);
488
+ expect(card.statusLabel).toBe("Inactive · 1,500 cloud resources");
489
+ });
490
+ });
491
+
351
492
  describe("InfrastructureGraph", () => {
352
493
  test("opens cards, services and the overflow through their own callbacks", () => {
353
- const hosts: Array<InventoryItem> = Array.from(
494
+ const hosts: Array<TopologyEntity> = Array.from(
354
495
  { length: MAX_MAP_CARDS + 1 },
355
496
  (_value: unknown, index: number) => {
356
497
  return entity(`h${index}`, EntityType.Host, `server-${index}x`);
@@ -366,7 +507,7 @@ describe("InfrastructureGraph", () => {
366
507
  render(
367
508
  <InfrastructureGraph
368
509
  model={model}
369
- nodeIds={hosts.map((host: InventoryItem) => {
510
+ nodeIds={hosts.map((host: TopologyEntity) => {
370
511
  return host.entityKey!;
371
512
  })}
372
513
  onOpenNode={onOpenNode}
@@ -398,3 +539,803 @@ describe("InfrastructureGraph", () => {
398
539
  );
399
540
  });
400
541
  });
542
+
543
+ /*
544
+ * Issue #3972: the map drew a node's pods as standalone cards, because
545
+ * nothing on it ever joined two resources. When services on one card call
546
+ * services on another, a line now joins the two cards and carries the calls'
547
+ * traffic, like the Service Map's connections.
548
+ */
549
+ function call(
550
+ from: string,
551
+ to: string,
552
+ callCount: number,
553
+ errorCount: number,
554
+ avgDurationMs: number,
555
+ ): TopologyRelationship {
556
+ return {
557
+ ...edge(from, to, EntityRelationshipType.DependsOn),
558
+ callCount,
559
+ errorCount,
560
+ avgDurationMs,
561
+ };
562
+ }
563
+
564
+ /* The node in the issue's screenshot: four pods of four workloads. */
565
+ function aksNodeModel(
566
+ calls: Array<TopologyRelationship> = [
567
+ call("svc-mcp", "svc-backend", 90, 0, 15),
568
+ call("svc-backend", "svc-blob", 1200, 6, 45),
569
+ call("svc-backend", "svc-edh", 300, 30, 120),
570
+ call("svc-edh", "svc-blob", 60, 0, 20),
571
+ ],
572
+ ): InfrastructureTopologyModel {
573
+ return buildInfrastructureTopologyModel(
574
+ [
575
+ entity("svc-backend", EntityType.Service, "wb-ims-backend"),
576
+ entity("svc-blob", EntityType.Service, "wb-ims-blob"),
577
+ entity("svc-edh", EntityType.Service, "wb-ims-integration-edh"),
578
+ entity("svc-mcp", EntityType.Service, "wb-ims-mcp-remote"),
579
+ entity(
580
+ "node-k",
581
+ EntityType.KubernetesNode,
582
+ "aks-agentpool-14451756-vmss00001k",
583
+ ),
584
+ entity(
585
+ "pod-backend",
586
+ EntityType.KubernetesPod,
587
+ "wb-ims-backend-76c6f6c8d9-x2k9p",
588
+ ),
589
+ entity(
590
+ "pod-blob",
591
+ EntityType.KubernetesPod,
592
+ "wb-ims-blob-56d969fb98-q8zwm",
593
+ ),
594
+ entity(
595
+ "pod-edh",
596
+ EntityType.KubernetesPod,
597
+ "wb-ims-integration-edh-6b7c8d9f5-zz2wm",
598
+ ),
599
+ entity(
600
+ "pod-mcp",
601
+ EntityType.KubernetesPod,
602
+ "wb-ims-mcp-remote-567bc8d9f4-bq2zt",
603
+ ),
604
+ entity("pod-quiet", EntityType.KubernetesPod, "cilium-x2k9p"),
605
+ ],
606
+ [
607
+ edge("pod-backend", "node-k", EntityRelationshipType.RunsOn),
608
+ edge("pod-blob", "node-k", EntityRelationshipType.RunsOn),
609
+ edge("pod-edh", "node-k", EntityRelationshipType.RunsOn),
610
+ edge("pod-mcp", "node-k", EntityRelationshipType.RunsOn),
611
+ edge("pod-quiet", "node-k", EntityRelationshipType.RunsOn),
612
+ edge("svc-backend", "pod-backend", EntityRelationshipType.RunsOn),
613
+ edge("svc-blob", "pod-blob", EntityRelationshipType.RunsOn),
614
+ edge("svc-edh", "pod-edh", EntityRelationshipType.RunsOn),
615
+ edge("svc-mcp", "pod-mcp", EntityRelationshipType.RunsOn),
616
+ ...calls,
617
+ ],
618
+ { rangeStart: new Date("2026-09-06T10:00:00Z") },
619
+ );
620
+ }
621
+
622
+ const WINDOW_SECONDS: number = 15 * 60;
623
+
624
+ function positionOf(
625
+ layout: InfrastructureMapLayout,
626
+ id: string,
627
+ ): {
628
+ x: number;
629
+ y: number;
630
+ } {
631
+ return layout.nodes.find((node: Node) => {
632
+ return node.id === id;
633
+ })!.position;
634
+ }
635
+
636
+ function linkFor(
637
+ layout: InfrastructureMapLayout,
638
+ from: string,
639
+ to: string,
640
+ ): InfrastructureTrafficLink {
641
+ return layout.traffic.links.find((link: InfrastructureTrafficLink) => {
642
+ return link.from === from && link.to === to;
643
+ })!;
644
+ }
645
+
646
+ /* One service per host, "a" on h-a and so on, with the given calls. */
647
+ function hostsModel(
648
+ calls: Array<[string, string, number]>,
649
+ ): InfrastructureTopologyModel {
650
+ const names: Set<string> = new Set<string>();
651
+ for (const [from, to] of calls) {
652
+ names.add(from);
653
+ names.add(to);
654
+ }
655
+ const entities: Array<TopologyEntity> = [];
656
+ const relationships: Array<TopologyRelationship> = [];
657
+ for (const name of names) {
658
+ entities.push(entity(name, EntityType.Service, name));
659
+ entities.push(entity(`h-${name}`, EntityType.Host, `${name}-box`));
660
+ relationships.push(
661
+ edge(name, `h-${name}`, EntityRelationshipType.HostedOn),
662
+ );
663
+ }
664
+ for (const [from, to, count] of calls) {
665
+ relationships.push(call(from, to, count, 0, 10));
666
+ }
667
+ return buildInfrastructureTopologyModel(entities, relationships);
668
+ }
669
+
670
+ describe("traffic between the cards (issue #3972)", () => {
671
+ test("a node's pods are joined by the calls between the services they run", () => {
672
+ const model: InfrastructureTopologyModel = aksNodeModel();
673
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
674
+ model,
675
+ nodeIds: collectMapCards(model, "node-k"),
676
+ now: NOW,
677
+ });
678
+ expect(
679
+ layout.traffic.links
680
+ .map((link: InfrastructureTrafficLink): string => {
681
+ return `${link.from}->${link.to}`;
682
+ })
683
+ .sort(),
684
+ ).toEqual([
685
+ "pod-backend->pod-blob",
686
+ "pod-backend->pod-edh",
687
+ "pod-edh->pod-blob",
688
+ "pod-mcp->pod-backend",
689
+ ]);
690
+ /*
691
+ * The traffic is what this map is about now: no service column and no
692
+ * arrows into every card — each card names what it runs.
693
+ */
694
+ expect(layout.edges).toEqual([]);
695
+ expect(
696
+ layout.nodes.filter((node: Node): boolean => {
697
+ return node.id.startsWith("service:");
698
+ }),
699
+ ).toEqual([]);
700
+ expect(
701
+ layout.nodes.find((node: Node): boolean => {
702
+ return node.id === "pod-backend";
703
+ })!.data.footer,
704
+ ).toBe("Runs wb-ims-backend");
705
+ });
706
+
707
+ test("cards that talk are laid out in call order, callers first", () => {
708
+ const model: InfrastructureTopologyModel = aksNodeModel();
709
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
710
+ model,
711
+ nodeIds: collectMapCards(model, "node-k"),
712
+ now: NOW,
713
+ });
714
+ const x: (id: string) => number = (id: string): number => {
715
+ return positionOf(layout, id).x;
716
+ };
717
+ expect(x("pod-mcp")).toBeLessThan(x("pod-backend"));
718
+ expect(x("pod-backend")).toBeLessThan(x("pod-edh"));
719
+ expect(x("pod-edh")).toBeLessThan(x("pod-blob"));
720
+ // The first callers start the drawing.
721
+ expect(x("pod-mcp")).toBe(0);
722
+ // A card nothing talks to follows the ones that do.
723
+ expect(x("pod-quiet")).toBeGreaterThan(x("pod-blob"));
724
+ });
725
+
726
+ test("cards never overlap when traffic places them", () => {
727
+ const model: InfrastructureTopologyModel = aksNodeModel([
728
+ call("svc-mcp", "svc-backend", 90, 0, 15),
729
+ call("svc-mcp", "svc-blob", 90, 0, 15),
730
+ call("svc-mcp", "svc-edh", 90, 0, 15),
731
+ ]);
732
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
733
+ model,
734
+ nodeIds: collectMapCards(model, "node-k"),
735
+ now: NOW,
736
+ });
737
+ const seen: Set<string> = new Set<string>();
738
+ for (const node of layout.nodes) {
739
+ const key: string = `${node.position.x},${node.position.y}`;
740
+ expect(seen.has(key)).toBe(false);
741
+ seen.add(key);
742
+ }
743
+ // One caller, three callees stacked in the next column.
744
+ const callees: Set<number> = new Set<number>(
745
+ ["pod-backend", "pod-blob", "pod-edh"].map((id: string): number => {
746
+ return positionOf(layout, id).x;
747
+ }),
748
+ );
749
+ expect(callees.size).toBe(1);
750
+ });
751
+
752
+ test("a line that skips a layer is routed through a slot of its own, clear of the cards", () => {
753
+ const model: InfrastructureTopologyModel = aksNodeModel();
754
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
755
+ model,
756
+ nodeIds: collectMapCards(model, "node-k"),
757
+ now: NOW,
758
+ });
759
+ /*
760
+ * backend -> blob crosses the integration's layer; the other three
761
+ * lines join neighbouring layers and need no route.
762
+ */
763
+ const backendToBlob: InfrastructureTrafficLink = linkFor(
764
+ layout,
765
+ "pod-backend",
766
+ "pod-blob",
767
+ );
768
+ expect(Array.from(layout.routes.keys())).toEqual([backendToBlob.id]);
769
+ const [slot] = layout.routes.get(backendToBlob.id)!;
770
+ // In the integration's layer, but not where the integration's card is.
771
+ expect(slot!.x).toBe(positionOf(layout, "pod-edh").x);
772
+ expect(slot!.y).not.toBe(positionOf(layout, "pod-edh").y);
773
+ for (const node of layout.nodes) {
774
+ expect(`${node.position.x},${node.position.y}`).not.toBe(
775
+ `${slot!.x},${slot!.y}`,
776
+ );
777
+ }
778
+
779
+ const edges: Array<Edge<TrafficEdgeData>> = trafficEdges({
780
+ model,
781
+ links: layout.traffic.links,
782
+ label: "calls",
783
+ metricsWindowSeconds: WINDOW_SECONDS,
784
+ routes: layout.routes,
785
+ cardTops: new Map<string, number>([["pod-backend", 40]]),
786
+ });
787
+ const routed: Array<Edge<TrafficEdgeData>> = edges.filter(
788
+ (item: Edge<TrafficEdgeData>): boolean => {
789
+ return item.type === TRAFFIC_ROUTE_EDGE_TYPE;
790
+ },
791
+ );
792
+ expect(routed).toHaveLength(1);
793
+ expect(routed[0]!.data).toEqual({
794
+ link: backendToBlob,
795
+ waypoints: [slot],
796
+ sourceTop: 40,
797
+ });
798
+ expect(
799
+ edges
800
+ .filter((item: Edge<TrafficEdgeData>): boolean => {
801
+ return item.type === "default";
802
+ })
803
+ .map((item: Edge<TrafficEdgeData>): string => {
804
+ return `${item.source}->${item.target}`;
805
+ })
806
+ .sort(),
807
+ ).toEqual([
808
+ "pod-backend->pod-edh",
809
+ "pod-edh->pod-blob",
810
+ "pod-mcp->pod-backend",
811
+ ]);
812
+ });
813
+
814
+ test("a routed line runs straight through each slot and curves between them", () => {
815
+ const route: { path: string; labelX: number; labelY: number } =
816
+ trafficRoutePath({
817
+ sourceX: 256,
818
+ sourceY: 50,
819
+ targetX: 1000,
820
+ targetY: 50,
821
+ waypoints: [
822
+ { x: 356, y: 152 },
823
+ { x: 712, y: 152 },
824
+ ],
825
+ handleOffset: 50,
826
+ });
827
+ expect(route.path).toBe(
828
+ "M 256,50 C 306,50 306,202 356,202 L 612,202 L 712,202 L 968,202 C 984,202 984,50 1000,50",
829
+ );
830
+ // The label sits in the first slot, where no card is.
831
+ expect(route.labelX).toBe(356 + 128);
832
+ expect(route.labelY).toBe(202);
833
+ });
834
+
835
+ test("a chain of calls routes a long line through every layer it crosses", () => {
836
+ const model: InfrastructureTopologyModel = aksNodeModel([
837
+ call("svc-mcp", "svc-backend", 90, 0, 15),
838
+ call("svc-backend", "svc-edh", 300, 0, 120),
839
+ call("svc-edh", "svc-blob", 60, 0, 20),
840
+ call("svc-mcp", "svc-blob", 10, 0, 5),
841
+ ]);
842
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
843
+ model,
844
+ nodeIds: collectMapCards(model, "node-k"),
845
+ now: NOW,
846
+ });
847
+ const long: InfrastructureTrafficLink = linkFor(
848
+ layout,
849
+ "pod-mcp",
850
+ "pod-blob",
851
+ );
852
+ const slots: Array<{ x: number; y: number }> = layout.routes.get(long.id)!;
853
+ expect(
854
+ slots.map((slot: { x: number; y: number }): number => {
855
+ return slot.x;
856
+ }),
857
+ ).toEqual([
858
+ positionOf(layout, "pod-backend").x,
859
+ positionOf(layout, "pod-edh").x,
860
+ ]);
861
+ });
862
+
863
+ test("a call against the flow takes a lane above the cards, the forward call stays direct", () => {
864
+ const model: InfrastructureTopologyModel = hostsModel([
865
+ ["a", "b", 100],
866
+ ["b", "a", 10],
867
+ ]);
868
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
869
+ model,
870
+ nodeIds: ["h-a", "h-b"],
871
+ now: NOW,
872
+ });
873
+ const back: InfrastructureTrafficLink = linkFor(layout, "h-b", "h-a");
874
+ expect(Array.from(layout.lanes.keys())).toEqual([back.id]);
875
+ const laneY: number = layout.lanes.get(back.id)!;
876
+ expect(laneY).toBeLessThan(
877
+ Math.min(positionOf(layout, "h-a").y, positionOf(layout, "h-b").y),
878
+ );
879
+ const edges: Array<Edge<TrafficEdgeData>> = trafficEdges({
880
+ model,
881
+ links: layout.traffic.links,
882
+ label: "calls",
883
+ metricsWindowSeconds: WINDOW_SECONDS,
884
+ routes: layout.routes,
885
+ lanes: layout.lanes,
886
+ });
887
+ const byLink: (link: InfrastructureTrafficLink) => Edge<TrafficEdgeData> = (
888
+ link: InfrastructureTrafficLink,
889
+ ): Edge<TrafficEdgeData> => {
890
+ return edges.find((item: Edge<TrafficEdgeData>) => {
891
+ return item.data!.link === link;
892
+ })!;
893
+ };
894
+ expect(byLink(back).type).toBe(TRAFFIC_ROUTE_EDGE_TYPE);
895
+ expect(byLink(back).data).toEqual({ link: back, laneY });
896
+ expect(byLink(linkFor(layout, "h-a", "h-b")).type).toBe("default");
897
+ });
898
+
899
+ test("each line against the flow has a lane of its own", () => {
900
+ const model: InfrastructureTopologyModel = hostsModel([
901
+ ["a", "b", 100],
902
+ ["b", "c", 100],
903
+ ["c", "b", 10],
904
+ ["b", "a", 10],
905
+ ["c", "a", 10],
906
+ ]);
907
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
908
+ model,
909
+ nodeIds: ["h-a", "h-b", "h-c"],
910
+ now: NOW,
911
+ });
912
+ expect(layout.lanes.size).toBe(3);
913
+ expect(new Set<number>(layout.lanes.values()).size).toBe(3);
914
+ });
915
+
916
+ test("a line against the flow leaves right, runs over the cards and enters its target from the left", () => {
917
+ const route: { path: string; labelX: number; labelY: number } =
918
+ trafficLanePath({
919
+ sourceX: 868,
920
+ sourceY: 50,
921
+ targetX: 256,
922
+ targetY: 60,
923
+ laneY: -40,
924
+ });
925
+ expect(route.path).toBe(
926
+ "M 868,50 C 916,50 916,-40 868,-40 L 256,-40 C 208,-40 208,60 256,60",
927
+ );
928
+ // Its label sits above the middle of the source card.
929
+ expect(route.labelX).toBe(868 - 128);
930
+ expect(route.labelY).toBe(-40);
931
+ });
932
+
933
+ test("calls running both ways between two cards need no route", () => {
934
+ const model: InfrastructureTopologyModel = aksNodeModel([
935
+ call("svc-blob", "svc-edh", 40, 0, 5),
936
+ call("svc-edh", "svc-blob", 60, 0, 20),
937
+ ]);
938
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
939
+ model,
940
+ nodeIds: collectMapCards(model, "node-k"),
941
+ now: NOW,
942
+ });
943
+ expect(layout.traffic.links).toHaveLength(2);
944
+ expect(layout.routes.size).toBe(0);
945
+ });
946
+
947
+ test("with no calls the map is laid out exactly as before", () => {
948
+ const withCalls: InfrastructureTopologyModel = aksNodeModel([]);
949
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
950
+ model: withCalls,
951
+ nodeIds: collectMapCards(withCalls, "node-k"),
952
+ now: NOW,
953
+ });
954
+ expect(layout.traffic).toEqual({ links: [], isPartial: false });
955
+ expect(layout.routes.size).toBe(0);
956
+ // Without traffic, the service column and its arrows are back.
957
+ expect(layout.edges).toHaveLength(4);
958
+ // Cards that run something share the column next to the services.
959
+ const runningX: Set<number> = new Set<number>(
960
+ ["pod-backend", "pod-blob", "pod-edh", "pod-mcp"].map(
961
+ (id: string): number => {
962
+ return positionOf(layout, id).x;
963
+ },
964
+ ),
965
+ );
966
+ expect(runningX.size).toBe(1);
967
+ expect(positionOf(layout, "pod-quiet").x).toBeGreaterThan(
968
+ [...runningX][0]!,
969
+ );
970
+ });
971
+
972
+ test("lines are labeled with the chosen metric, colored by error rate", () => {
973
+ const model: InfrastructureTopologyModel = aksNodeModel();
974
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
975
+ model,
976
+ nodeIds: collectMapCards(model, "node-k"),
977
+ now: NOW,
978
+ });
979
+ const backendToBlob: InfrastructureTrafficLink = linkFor(
980
+ layout,
981
+ "pod-backend",
982
+ "pod-blob",
983
+ );
984
+ expect(trafficMetricLabel(backendToBlob, "calls", WINDOW_SECONDS)).toBe(
985
+ "80/min",
986
+ );
987
+ expect(trafficMetricLabel(backendToBlob, "errors", WINDOW_SECONDS)).toBe(
988
+ "0.5% errors",
989
+ );
990
+ expect(trafficMetricLabel(backendToBlob, "latency", WINDOW_SECONDS)).toBe(
991
+ "45ms",
992
+ );
993
+
994
+ for (const [label, expected] of [
995
+ ["calls", "80/min"],
996
+ ["errors", "0.5% errors"],
997
+ ["latency", "45ms"],
998
+ ] as const) {
999
+ const edges: Array<Edge<TrafficEdgeData>> = trafficEdges({
1000
+ model,
1001
+ links: layout.traffic.links,
1002
+ label,
1003
+ metricsWindowSeconds: WINDOW_SECONDS,
1004
+ });
1005
+ const drawn: Edge<TrafficEdgeData> = edges.find(
1006
+ (item: Edge<TrafficEdgeData>) => {
1007
+ return item.data!.link === backendToBlob;
1008
+ },
1009
+ )!;
1010
+ expect(drawn.id.startsWith(TRAFFIC_EDGE_PREFIX)).toBe(true);
1011
+ expect(drawn.source).toBe("pod-backend");
1012
+ expect(drawn.target).toBe("pod-blob");
1013
+ expect(drawn.label).toBe(expected);
1014
+ }
1015
+
1016
+ const edges: Array<Edge<TrafficEdgeData>> = trafficEdges({
1017
+ model,
1018
+ links: layout.traffic.links,
1019
+ label: "calls",
1020
+ metricsWindowSeconds: WINDOW_SECONDS,
1021
+ });
1022
+ const colorOf: (link: InfrastructureTrafficLink) => unknown = (
1023
+ link: InfrastructureTrafficLink,
1024
+ ): unknown => {
1025
+ return edges.find((item: Edge<TrafficEdgeData>) => {
1026
+ return item.data!.link === link;
1027
+ })!.style!.stroke;
1028
+ };
1029
+ expect(colorOf(backendToBlob)).toBe(HEALTH_COLORS.healthy);
1030
+ expect(colorOf(linkFor(layout, "pod-backend", "pod-edh"))).toBe(
1031
+ HEALTH_COLORS.critical,
1032
+ );
1033
+ });
1034
+
1035
+ test("on hover, only the hovered line is labeled", () => {
1036
+ const model: InfrastructureTopologyModel = aksNodeModel();
1037
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
1038
+ model,
1039
+ nodeIds: collectMapCards(model, "node-k"),
1040
+ now: NOW,
1041
+ });
1042
+ const quiet: Array<Edge<TrafficEdgeData>> = trafficEdges({
1043
+ model,
1044
+ links: layout.traffic.links,
1045
+ label: "hover",
1046
+ metricsWindowSeconds: WINDOW_SECONDS,
1047
+ });
1048
+ expect(
1049
+ quiet.every((item: Edge<TrafficEdgeData>): boolean => {
1050
+ return item.label === undefined;
1051
+ }),
1052
+ ).toBe(true);
1053
+ const hoveredId: string = quiet[0]!.id;
1054
+ const hovered: Array<Edge<TrafficEdgeData>> = trafficEdges({
1055
+ model,
1056
+ links: layout.traffic.links,
1057
+ label: "hover",
1058
+ hoveredId,
1059
+ metricsWindowSeconds: WINDOW_SECONDS,
1060
+ });
1061
+ expect(
1062
+ hovered
1063
+ .filter((item: Edge<TrafficEdgeData>): boolean => {
1064
+ return item.label !== undefined;
1065
+ })
1066
+ .map((item: Edge<TrafficEdgeData>): string => {
1067
+ return item.id;
1068
+ }),
1069
+ ).toEqual([hoveredId]);
1070
+ });
1071
+
1072
+ test("a line whose calls reported no count is drawn gray and unlabeled", () => {
1073
+ const model: InfrastructureTopologyModel = aksNodeModel([
1074
+ { ...edge("svc-backend", "svc-blob", EntityRelationshipType.DependsOn) },
1075
+ ]);
1076
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
1077
+ model,
1078
+ nodeIds: collectMapCards(model, "node-k"),
1079
+ now: NOW,
1080
+ });
1081
+ const [drawn] = trafficEdges({
1082
+ model,
1083
+ links: layout.traffic.links,
1084
+ label: "calls",
1085
+ metricsWindowSeconds: WINDOW_SECONDS,
1086
+ });
1087
+ expect(drawn!.label).toBeUndefined();
1088
+ expect(drawn!.style!.stroke).toBe(HEALTH_COLORS.unknown);
1089
+ expect(drawn!.ariaLabel).toBe(
1090
+ "wb-ims-backend → wb-ims-blob: no calls counted",
1091
+ );
1092
+ });
1093
+
1094
+ test("a line says which calls it stands for", () => {
1095
+ const model: InfrastructureTopologyModel = aksNodeModel();
1096
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
1097
+ model,
1098
+ nodeIds: collectMapCards(model, "node-k"),
1099
+ now: NOW,
1100
+ });
1101
+ expect(
1102
+ describeTrafficLink(
1103
+ model,
1104
+ linkFor(layout, "pod-backend", "pod-blob"),
1105
+ WINDOW_SECONDS,
1106
+ ),
1107
+ ).toBe("wb-ims-backend → wb-ims-blob: 80/min · 0.5% errors · 45ms avg");
1108
+ });
1109
+
1110
+ test("a line standing for many calls names the busiest three", () => {
1111
+ const model: InfrastructureTopologyModel = buildInfrastructureTopologyModel(
1112
+ [
1113
+ entity("a", EntityType.Service, "alpha"),
1114
+ entity("b", EntityType.Service, "bravo"),
1115
+ entity("c", EntityType.Service, "charlie"),
1116
+ entity("d", EntityType.Service, "delta"),
1117
+ entity("e", EntityType.Service, "echo"),
1118
+ entity("left", EntityType.Host, "left-box"),
1119
+ entity("right", EntityType.Host, "right-box"),
1120
+ ],
1121
+ [
1122
+ edge("a", "left", EntityRelationshipType.HostedOn),
1123
+ edge("b", "right", EntityRelationshipType.HostedOn),
1124
+ edge("c", "right", EntityRelationshipType.HostedOn),
1125
+ edge("d", "right", EntityRelationshipType.HostedOn),
1126
+ edge("e", "right", EntityRelationshipType.HostedOn),
1127
+ call("a", "b", 10, 0, 1),
1128
+ call("a", "c", 40, 0, 1),
1129
+ call("a", "d", 30, 0, 1),
1130
+ call("a", "e", 20, 0, 1),
1131
+ ],
1132
+ );
1133
+ const layout: InfrastructureMapLayout = layoutInfrastructureMap({
1134
+ model,
1135
+ nodeIds: ["left", "right"],
1136
+ now: NOW,
1137
+ });
1138
+ expect(
1139
+ describeTrafficLink(
1140
+ model,
1141
+ linkFor(layout, "left", "right"),
1142
+ WINDOW_SECONDS,
1143
+ ),
1144
+ ).toBe(
1145
+ "alpha → charlie, alpha → delta, alpha → echo +1: 6.7/min · 0% errors · 1ms avg",
1146
+ );
1147
+ });
1148
+ });
1149
+
1150
+ describe("InfrastructureGraph traffic", () => {
1151
+ function renderNode(
1152
+ model: InfrastructureTopologyModel,
1153
+ onOpenTraffic?: (link: InfrastructureTrafficLink) => void,
1154
+ ): void {
1155
+ render(
1156
+ <InfrastructureGraph
1157
+ model={model}
1158
+ nodeIds={collectMapCards(model, "node-k")}
1159
+ onOpenNode={() => {}}
1160
+ onOpenTraffic={onOpenTraffic}
1161
+ metricsWindowSeconds={WINDOW_SECONDS}
1162
+ now={NOW}
1163
+ />,
1164
+ );
1165
+ }
1166
+
1167
+ function trafficButtons(): Array<HTMLElement> {
1168
+ return screen.getAllByTestId(/^edge-traffic:/);
1169
+ }
1170
+
1171
+ test("draws the lines with request rates, and says what they are", () => {
1172
+ renderNode(aksNodeModel());
1173
+ expect(trafficButtons()).toHaveLength(4);
1174
+ expect(
1175
+ trafficButtons()
1176
+ .map((button: HTMLElement): string => {
1177
+ return button.textContent || "";
1178
+ })
1179
+ .sort(),
1180
+ ).toEqual(["20/min", "4.0/min", "6.0/min", "80/min"]);
1181
+ expect(
1182
+ screen.getByTestId("infrastructure-traffic-status"),
1183
+ ).toHaveTextContent(
1184
+ "4 connections · Lines are calls between the services on these cards, measured per service.",
1185
+ );
1186
+ // Only the traffic: no placement arrows beside it.
1187
+ expect(screen.getByTestId("edge-count")).toHaveTextContent("4");
1188
+ expect(
1189
+ screen.getByRole("button", {
1190
+ name: "wb-ims-backend → wb-ims-blob: 80/min · 0.5% errors · 45ms avg",
1191
+ }),
1192
+ ).toBeInTheDocument();
1193
+ });
1194
+
1195
+ test("the label picker switches every line to another metric", () => {
1196
+ renderNode(aksNodeModel());
1197
+ fireEvent.change(screen.getByLabelText("Line labels"), {
1198
+ target: { value: "errors" },
1199
+ });
1200
+ expect(
1201
+ trafficButtons()
1202
+ .map((button: HTMLElement): string => {
1203
+ return button.textContent || "";
1204
+ })
1205
+ .sort(),
1206
+ ).toEqual(["0% errors", "0% errors", "0.5% errors", "10.0% errors"]);
1207
+ fireEvent.change(screen.getByLabelText("Line labels"), {
1208
+ target: { value: "hover" },
1209
+ });
1210
+ expect(
1211
+ trafficButtons().every((button: HTMLElement): boolean => {
1212
+ return button.textContent === "";
1213
+ }),
1214
+ ).toBe(true);
1215
+ });
1216
+
1217
+ test("hovering a line says which calls it carries", () => {
1218
+ renderNode(aksNodeModel());
1219
+ expect(
1220
+ screen.queryByTestId("infrastructure-traffic-hover"),
1221
+ ).not.toBeInTheDocument();
1222
+ const line: HTMLElement = screen.getByRole("button", {
1223
+ name: "wb-ims-backend → wb-ims-integration-edh: 20/min · 10.0% errors · 120ms avg",
1224
+ });
1225
+ fireEvent.mouseEnter(line);
1226
+ expect(
1227
+ screen.getByTestId("infrastructure-traffic-hover"),
1228
+ ).toHaveTextContent(
1229
+ "wb-ims-backend → wb-ims-integration-edh: 20/min · 10.0% errors · 120ms avg",
1230
+ );
1231
+ fireEvent.mouseLeave(line);
1232
+ expect(
1233
+ screen.queryByTestId("infrastructure-traffic-hover"),
1234
+ ).not.toBeInTheDocument();
1235
+ });
1236
+
1237
+ test("clicking a line opens it; clicking a card opens the card", () => {
1238
+ const onOpenTraffic: MockFunction = getJestMockFunction();
1239
+ renderNode(aksNodeModel(), onOpenTraffic);
1240
+ fireEvent.click(
1241
+ screen.getByRole("button", {
1242
+ name: "wb-ims-backend → wb-ims-blob: 80/min · 0.5% errors · 45ms avg",
1243
+ }),
1244
+ );
1245
+ expect(onOpenTraffic).toHaveBeenCalledTimes(1);
1246
+ const link: InfrastructureTrafficLink = onOpenTraffic.mock
1247
+ .calls[0]![0] as InfrastructureTrafficLink;
1248
+ expect(link.from).toBe("pod-backend");
1249
+ expect(link.to).toBe("pod-blob");
1250
+ expect(link.serviceCalls[0]!.from).toBe("svc-backend");
1251
+ fireEvent.click(screen.getByTestId("node-pod-blob"));
1252
+ expect(onOpenTraffic).toHaveBeenCalledTimes(1);
1253
+ });
1254
+
1255
+ test("the invisible slots a routed line runs through open nothing", () => {
1256
+ const onOpenTraffic: MockFunction = getJestMockFunction();
1257
+ const onOpenNode: MockFunction = getJestMockFunction();
1258
+ const model: InfrastructureTopologyModel = aksNodeModel();
1259
+ render(
1260
+ <InfrastructureGraph
1261
+ model={model}
1262
+ nodeIds={collectMapCards(model, "node-k")}
1263
+ onOpenNode={onOpenNode}
1264
+ onOpenTraffic={onOpenTraffic}
1265
+ metricsWindowSeconds={WINDOW_SECONDS}
1266
+ now={NOW}
1267
+ />,
1268
+ );
1269
+ const slots: Array<HTMLElement> =
1270
+ screen.getAllByTestId(/^node-route-slot:/);
1271
+ expect(slots).toHaveLength(1);
1272
+ fireEvent.click(slots[0]!);
1273
+ expect(onOpenNode).not.toHaveBeenCalled();
1274
+ expect(onOpenTraffic).not.toHaveBeenCalled();
1275
+ });
1276
+
1277
+ test("past a few lines, labels wait for the pointer until the user picks a metric", () => {
1278
+ const calls: Array<[string, string, number]> = [];
1279
+ for (const caller of ["a", "b", "c", "d"]) {
1280
+ for (const callee of ["x", "y"]) {
1281
+ calls.push([caller, callee, 900]);
1282
+ }
1283
+ }
1284
+ const model: InfrastructureTopologyModel = hostsModel(calls);
1285
+ render(
1286
+ <InfrastructureGraph
1287
+ model={model}
1288
+ nodeIds={collectMapCards(model, null)}
1289
+ onOpenNode={() => {}}
1290
+ metricsWindowSeconds={WINDOW_SECONDS}
1291
+ now={NOW}
1292
+ />,
1293
+ );
1294
+ expect(trafficButtons()).toHaveLength(8);
1295
+ expect(screen.getByLabelText("Line labels")).toHaveValue("hover");
1296
+ expect(
1297
+ trafficButtons().every((button: HTMLElement): boolean => {
1298
+ return button.textContent === "";
1299
+ }),
1300
+ ).toBe(true);
1301
+ fireEvent.change(screen.getByLabelText("Line labels"), {
1302
+ target: { value: "calls" },
1303
+ });
1304
+ expect(
1305
+ trafficButtons().every((button: HTMLElement): boolean => {
1306
+ return button.textContent === "60/min";
1307
+ }),
1308
+ ).toBe(true);
1309
+ });
1310
+
1311
+ test("services that never called each other say so instead of drawing nothing silently", () => {
1312
+ renderNode(aksNodeModel([]));
1313
+ expect(screen.queryAllByTestId(/^edge-traffic:/)).toHaveLength(0);
1314
+ expect(
1315
+ screen.getByTestId("infrastructure-traffic-status"),
1316
+ ).toHaveTextContent(
1317
+ "The services on these cards were not seen calling each other in this time range.",
1318
+ );
1319
+ expect(screen.queryByLabelText("Line labels")).not.toBeInTheDocument();
1320
+ });
1321
+
1322
+ test("a scope where no service is known to run explains why nothing is joined", () => {
1323
+ const model: InfrastructureTopologyModel = buildInfrastructureTopologyModel(
1324
+ [entity("a", EntityType.Host), entity("b", EntityType.Host)],
1325
+ [],
1326
+ );
1327
+ render(
1328
+ <InfrastructureGraph
1329
+ model={model}
1330
+ nodeIds={["a", "b"]}
1331
+ onOpenNode={() => {}}
1332
+ now={NOW}
1333
+ />,
1334
+ );
1335
+ expect(
1336
+ screen.getByTestId("infrastructure-traffic-status"),
1337
+ ).toHaveTextContent(
1338
+ "No services are known to run on these cards, so there are no calls to draw between them.",
1339
+ );
1340
+ });
1341
+ });