@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
@@ -8,6 +8,7 @@ import {
8
8
  test,
9
9
  } from "@jest/globals";
10
10
  import {
11
+ RenderResult,
11
12
  cleanup,
12
13
  fireEvent,
13
14
  render,
@@ -17,20 +18,42 @@ import {
17
18
  } from "@testing-library/react";
18
19
  import React from "react";
19
20
  import { MemoryRouter } from "react-router-dom";
20
- import InventoryItem from "../../../Models/DatabaseModels/InventoryItem";
21
- import InventoryItemRelationship from "../../../Models/DatabaseModels/InventoryItemRelationship";
21
+ import type { SpyInstance } from "jest-mock";
22
22
  import EntityType from "../../../Types/Telemetry/EntityType";
23
23
  import EntityRelationshipType from "../../../Types/Telemetry/EntityRelationshipType";
24
24
  import EntitySource from "../../../Types/Telemetry/EntitySource";
25
25
  import ObjectID from "../../../Types/ObjectID";
26
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
27
+ import Navigation from "../../../UI/Utils/Navigation";
28
+ import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
29
+ import TimeRange from "../../../Types/Time/TimeRange";
26
30
  import InfrastructureExplorer from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/InfrastructureExplorer";
31
+ import type {
32
+ CollectionPage,
33
+ CollectionPageRequest,
34
+ CollectionSearchRequest,
35
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/InfrastructureCollectionApi";
36
+ import {
37
+ EntityDetailTarget,
38
+ InfrastructureCollection,
39
+ InfrastructureTotals,
40
+ TopologyEntity,
41
+ TopologyRelationship,
42
+ TopologyTruncation,
43
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyData";
44
+ import { TopologyOutdatedError } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/TopologyApi";
27
45
  import getJestMockFunction, { MockFunction } from "../../MockType";
28
46
 
29
47
  /*
30
48
  * The Infrastructure explorer: a tree of things that contain things, a table
31
49
  * of what is inside the selected scope, a map of one level, and search. The
32
50
  * estate used here is the one that motivated the redesign — application pods
33
- * that reported themselves as hosts, most of them long gone.
51
+ * that reported themselves as hosts, most of them long gone — plus, where it
52
+ * matters, a collection too large to ship row by row and a tree too large to
53
+ * open fully.
54
+ *
55
+ * The map, the details drawer and the collection transport are the
56
+ * boundaries replaced here; each has its own tests.
34
57
  */
35
58
 
36
59
  jest.mock("react-i18next", () => {
@@ -60,13 +83,46 @@ jest.mock(
60
83
  return {
61
84
  __esModule: true,
62
85
  default: (props: {
86
+ model: unknown;
63
87
  nodeIds: Array<string>;
64
88
  onOpenNode: (id: string) => void;
65
89
  onOpenService?: (key: string) => void;
90
+ onOpenTraffic?: (link: { from: string; to: string }) => void;
66
91
  onShowAll?: () => void;
92
+ metricsWindowSeconds?: number;
67
93
  }): React.ReactElement => {
94
+ /* The real traffic between the cards the page asked for. */
95
+ const { computeInfrastructureTraffic } = jest.requireActual(
96
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/InfrastructureTopologyModel",
97
+ ) as {
98
+ computeInfrastructureTraffic: (
99
+ model: unknown,
100
+ cardIds: Array<string>,
101
+ ) => { links: Array<{ from: string; to: string }> };
102
+ };
103
+ const links: Array<{ from: string; to: string }> =
104
+ computeInfrastructureTraffic(props.model, props.nodeIds).links;
68
105
  return (
69
- <div data-testid="infrastructure-map-stub">
106
+ <div
107
+ data-testid="infrastructure-map-stub"
108
+ data-metrics-window={props.metricsWindowSeconds}
109
+ >
110
+ {props.onOpenTraffic &&
111
+ links.map(
112
+ (link: { from: string; to: string }): React.ReactElement => {
113
+ return (
114
+ <button
115
+ type="button"
116
+ key={`${link.from}->${link.to}`}
117
+ onClick={() => {
118
+ props.onOpenTraffic?.(link);
119
+ }}
120
+ >
121
+ {`Map traffic ${link.from} -> ${link.to}`}
122
+ </button>
123
+ );
124
+ },
125
+ )}
70
126
  {props.nodeIds.map((id: string): React.ReactElement => {
71
127
  return (
72
128
  <button
@@ -98,30 +154,106 @@ jest.mock(
98
154
  };
99
155
  },
100
156
  );
157
+
158
+ /*
159
+ * The drawer fetches its own details; here it only shows what it was handed
160
+ * (the preview target) and exposes its callbacks. Every mount and every
161
+ * render is recorded, so a test can see what the page asked it to show and
162
+ * whether it kept one drawer or built a new one.
163
+ */
164
+ interface DrawerProps {
165
+ entity: EntityDetailTarget;
166
+ rangeStart: Date | null | undefined;
167
+ metricsWindowSeconds: number;
168
+ onClose: () => void;
169
+ onFocus?: ((key: string) => void) | undefined;
170
+ focusButtonLabel?: string | undefined;
171
+ onSelectEntity?: ((target: EntityDetailTarget) => void) | undefined;
172
+ onOpenInfrastructure?: ((key: string) => void) | undefined;
173
+ }
174
+ const mockDrawerMounts: Array<DrawerProps> = [];
175
+ const mockDrawerRenders: Array<DrawerProps> = [];
101
176
  jest.mock(
102
177
  "../../../../App/FeatureSet/Dashboard/src/Components/Topology/EntityDetailPanel",
103
178
  () => {
104
- return {
105
- __esModule: true,
106
- default: (props: {
107
- entity: InventoryItem;
108
- onClose: () => void;
109
- onFocus: (key: string) => void;
110
- focusButtonLabel?: string;
111
- }): React.ReactElement => {
112
- return (
113
- <div role="dialog" aria-label={props.entity.displayName}>
114
- <button type="button" onClick={props.onClose}>
115
- Close resource details
116
- </button>
179
+ const MockDrawer: (props: DrawerProps) => React.ReactElement = (
180
+ props: DrawerProps,
181
+ ): React.ReactElement => {
182
+ mockDrawerRenders.push(props);
183
+ React.useEffect(() => {
184
+ mockDrawerMounts.push(props);
185
+ }, []);
186
+ return (
187
+ <div
188
+ role="dialog"
189
+ aria-label={props.entity.displayName || props.entity.entityKey}
190
+ data-entity-type={props.entity.entityType || ""}
191
+ data-range-start={props.rangeStart?.toISOString() || ""}
192
+ data-metrics-window={props.metricsWindowSeconds}
193
+ data-opens-infrastructure={String(
194
+ Boolean(props.onOpenInfrastructure),
195
+ )}
196
+ >
197
+ <button type="button" onClick={props.onClose}>
198
+ Close resource details
199
+ </button>
200
+ {props.onFocus && (
117
201
  <button
118
202
  type="button"
119
203
  onClick={() => {
120
- props.onFocus(props.entity.entityKey!);
204
+ props.onFocus?.(props.entity.entityKey);
121
205
  }}
122
206
  >
123
207
  {props.focusButtonLabel}
124
208
  </button>
209
+ )}
210
+ <button
211
+ type="button"
212
+ onClick={() => {
213
+ props.onSelectEntity?.({
214
+ entityKey: "api",
215
+ entityType: EntityType.Service,
216
+ displayName: "api",
217
+ });
218
+ }}
219
+ >
220
+ Select the api service
221
+ </button>
222
+ </div>
223
+ );
224
+ };
225
+ return { __esModule: true, default: MockDrawer };
226
+ },
227
+ );
228
+
229
+ /*
230
+ * A traffic line's history panel fetches its own charts; here it shows the
231
+ * call it was asked about and exposes its close button.
232
+ */
233
+ interface EdgePanelProps {
234
+ fromEntity: TopologyEntity;
235
+ toEntity: TopologyEntity;
236
+ relationship: TopologyRelationship;
237
+ timeRange: RangeStartAndEndDateTime;
238
+ metricsWindowSeconds: number;
239
+ onClose: () => void;
240
+ }
241
+ const mockEdgePanelRenders: Array<EdgePanelProps> = [];
242
+ jest.mock(
243
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/EdgeDetailPanel",
244
+ () => {
245
+ return {
246
+ __esModule: true,
247
+ default: (props: EdgePanelProps): React.ReactElement => {
248
+ mockEdgePanelRenders.push(props);
249
+ return (
250
+ <div
251
+ role="dialog"
252
+ aria-label={`${props.fromEntity.displayName} calls ${props.toEntity.displayName}`}
253
+ >
254
+ <button type="button" onClick={props.onClose}>
255
+ Close call history
256
+ </button>
125
257
  </div>
126
258
  );
127
259
  },
@@ -129,6 +261,32 @@ jest.mock(
129
261
  },
130
262
  );
131
263
 
264
+ /*
265
+ * Only the two requests are replaced: how a failure is classified (a busy
266
+ * server, a newer format) is the real module's, so the page is tested
267
+ * against what the transport really reports.
268
+ */
269
+ const mockFetchCollectionPage: MockFunction = getJestMockFunction();
270
+ const mockFetchCollectionSearchCounts: MockFunction = getJestMockFunction();
271
+ jest.mock(
272
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/InfrastructureCollectionApi",
273
+ () => {
274
+ const actual: Record<string, unknown> = jest.requireActual(
275
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/InfrastructureCollectionApi",
276
+ ) as Record<string, unknown>;
277
+ return {
278
+ ...actual,
279
+ __esModule: true,
280
+ fetchCollectionPage: (...args: Array<unknown>): unknown => {
281
+ return mockFetchCollectionPage(...args);
282
+ },
283
+ fetchCollectionSearchCounts: (...args: Array<unknown>): unknown => {
284
+ return mockFetchCollectionSearchCounts(...args);
285
+ },
286
+ };
287
+ },
288
+ );
289
+
132
290
  const NOW: Date = new Date();
133
291
  const LONG_AGO: Date = new Date(NOW.getTime() - 20 * 24 * 60 * 60 * 1000);
134
292
  const RANGE_START: Date = new Date(NOW.getTime() - 24 * 60 * 60 * 1000);
@@ -138,29 +296,38 @@ function entity(
138
296
  type: EntityType,
139
297
  name: string = key,
140
298
  lastSeenAt: Date = NOW,
141
- ): InventoryItem {
142
- const item: InventoryItem = new InventoryItem();
143
- item.entityKey = key;
144
- item.entityType = type;
145
- item.displayName = name;
146
- item.lastSeenAt = lastSeenAt;
147
- item.source = EntitySource.Discovered;
148
- return item;
299
+ ): TopologyEntity {
300
+ return {
301
+ entityKey: key,
302
+ entityType: type,
303
+ displayName: name,
304
+ lastSeenAt,
305
+ source: EntitySource.Discovered,
306
+ };
307
+ }
308
+ function relationship(
309
+ from: string,
310
+ to: string,
311
+ type: EntityRelationshipType,
312
+ ): TopologyRelationship {
313
+ return { fromEntityKey: from, toEntityKey: to, relationshipType: type };
149
314
  }
150
- function hostedOn(from: string, to: string): InventoryItemRelationship {
151
- const item: InventoryItemRelationship = new InventoryItemRelationship();
152
- item.fromEntityKey = from;
153
- item.toEntityKey = to;
154
- item.relationshipType = EntityRelationshipType.HostedOn;
155
- return item;
315
+ function hostedOn(from: string, to: string): TopologyRelationship {
316
+ return relationship(from, to, EntityRelationshipType.HostedOn);
156
317
  }
157
318
 
158
319
  const APP_GROUP: string = "group:category:compute:host|oneuptime-app";
320
+ const IOT: string = "collection:iot.device";
159
321
 
160
- function fixtures(): {
161
- entities: Array<InventoryItem>;
162
- relationships: Array<InventoryItemRelationship>;
163
- } {
322
+ interface Fixture {
323
+ entities: Array<TopologyEntity>;
324
+ relationships: Array<TopologyRelationship>;
325
+ collections?: Array<InfrastructureCollection>;
326
+ totals?: InfrastructureTotals;
327
+ truncation?: TopologyTruncation | null;
328
+ }
329
+
330
+ function fixtures(): Fixture {
164
331
  return {
165
332
  entities: [
166
333
  entity("api", EntityType.Service),
@@ -189,26 +356,109 @@ function fixtures(): {
189
356
  };
190
357
  }
191
358
 
192
- function renderExplorer(
193
- options: {
194
- data?: ReturnType<typeof fixtures>;
195
- includeInactive?: boolean;
196
- onOpenServiceMap?: (key: string) => void;
197
- } = {},
198
- ): void {
199
- render(
359
+ /* The same estate plus 5,000 IoT devices the server summarized. */
360
+ function withCollection(): Fixture {
361
+ const base: Fixture = fixtures();
362
+ return {
363
+ ...base,
364
+ entities: [
365
+ ...base.entities,
366
+ entity("switch", EntityType.NetworkDevice, "core-switch"),
367
+ ],
368
+ collections: [
369
+ {
370
+ entityType: EntityType.IoTDevice,
371
+ total: 5000,
372
+ active: 4200,
373
+ lastSeenAt: NOW,
374
+ activeLastSeenAt: NOW,
375
+ },
376
+ ],
377
+ };
378
+ }
379
+
380
+ /*
381
+ * More than 200 containers: 250 hosts that each hold a container, plus one
382
+ * Kubernetes cluster → namespace → deployment → pods. Too many to open the
383
+ * whole tree by default.
384
+ */
385
+ function largeFixture(): Fixture {
386
+ const entities: Array<TopologyEntity> = [
387
+ entity("cluster", EntityType.KubernetesCluster, "prod"),
388
+ entity("ns", EntityType.KubernetesNamespace, "shop"),
389
+ entity("deploy", EntityType.KubernetesDeployment, "checkout"),
390
+ entity("pod-1", EntityType.KubernetesPod, "checkout-6d4f8b9c7d-x2k9p"),
391
+ entity("pod-2", EntityType.KubernetesPod, "checkout-6d4f8b9c7d-q8zwm"),
392
+ ];
393
+ const relationships: Array<TopologyRelationship> = [
394
+ relationship("ns", "cluster", EntityRelationshipType.MemberOf),
395
+ relationship("deploy", "ns", EntityRelationshipType.MemberOf),
396
+ relationship("pod-1", "deploy", EntityRelationshipType.PartOf),
397
+ relationship("pod-2", "deploy", EntityRelationshipType.PartOf),
398
+ ];
399
+ for (let index: number = 0; index < 250; index++) {
400
+ const host: string = `host-${String(index).padStart(3, "0")}`;
401
+ entities.push(entity(host, EntityType.Host, `${host}.internal`));
402
+ entities.push(
403
+ entity(`${host}/c`, EntityType.Container, `${host}-container`),
404
+ );
405
+ relationships.push(
406
+ relationship(`${host}/c`, host, EntityRelationshipType.PartOf),
407
+ );
408
+ }
409
+ return { entities, relationships };
410
+ }
411
+
412
+ function page(
413
+ items: Array<TopologyEntity>,
414
+ total: number,
415
+ nextCursor: { name: string; key: string } | null,
416
+ ): CollectionPage {
417
+ return {
418
+ rangeStart: RANGE_START,
419
+ entityType: EntityType.IoTDevice,
420
+ total,
421
+ items,
422
+ nextCursor,
423
+ };
424
+ }
425
+
426
+ interface ExplorerOptions {
427
+ data?: Fixture;
428
+ includeInactive?: boolean;
429
+ onOpenServiceMap?: (key: string) => void;
430
+ timeRange?: RangeStartAndEndDateTime;
431
+ }
432
+
433
+ function explorer(options: ExplorerOptions = {}): React.ReactElement {
434
+ const data: Fixture = options.data || fixtures();
435
+ return (
200
436
  <MemoryRouter>
201
437
  <InfrastructureExplorer
202
- {...(options.data || fixtures())}
438
+ entities={data.entities}
439
+ relationships={data.relationships}
440
+ collections={data.collections}
441
+ totals={data.totals}
442
+ truncation={data.truncation}
203
443
  metricsWindowSeconds={900}
444
+ timeRange={options.timeRange}
204
445
  rangeStart={RANGE_START}
205
446
  includeInactive={options.includeInactive}
206
447
  onOpenServiceMap={options.onOpenServiceMap}
207
448
  />
208
- </MemoryRouter>,
449
+ </MemoryRouter>
209
450
  );
210
451
  }
211
452
 
453
+ /*
454
+ * Renders the explorer; rerender it through the result with `explorer(...)`
455
+ * and the SAME fixture object, as TopologyPage does when only "Show
456
+ * inactive" changes (the explorer is not remounted for that).
457
+ */
458
+ function renderExplorer(options: ExplorerOptions = {}): RenderResult {
459
+ return render(explorer(options));
460
+ }
461
+
212
462
  function search(value: string): void {
213
463
  fireEvent.change(
214
464
  screen.getByRole("searchbox", { name: "Search infrastructure" }),
@@ -220,12 +470,76 @@ function rows(): Array<HTMLElement> {
220
470
  return screen.queryAllByTestId("infrastructure-row");
221
471
  }
222
472
 
473
+ /*
474
+ * The large fixture's tree is queried by label rather than by role: jsdom
475
+ * computes styles for every candidate of a role query, which made those
476
+ * tests take seconds each.
477
+ */
478
+ function tree(): HTMLElement {
479
+ const aside: HTMLElement | null = document.querySelector(
480
+ 'aside[aria-label="Infrastructure tree"]',
481
+ );
482
+ if (!aside) {
483
+ throw new Error("the tree is not on the page");
484
+ }
485
+ return aside;
486
+ }
487
+
488
+ /* What the drawer was last asked to show. */
489
+ function lastDrawer(): DrawerProps {
490
+ const props: DrawerProps | undefined =
491
+ mockDrawerRenders[mockDrawerRenders.length - 1];
492
+ if (!props) {
493
+ throw new Error("the drawer never opened");
494
+ }
495
+ return props;
496
+ }
497
+
498
+ function pageRequest(index: number): CollectionPageRequest {
499
+ const call: Array<unknown> | undefined =
500
+ mockFetchCollectionPage.mock.calls[index];
501
+ if (!call) {
502
+ throw new Error(`no collection page request #${index + 1}`);
503
+ }
504
+ return call[1] as CollectionPageRequest;
505
+ }
506
+
507
+ function lastPageRequest(): CollectionPageRequest {
508
+ return pageRequest(mockFetchCollectionPage.mock.calls.length - 1);
509
+ }
510
+
511
+ /* A page of IoT devices "iot-<from>" .. "iot-<to>" that says where the next starts. */
512
+ function iotPage(
513
+ from: number,
514
+ to: number,
515
+ total: number,
516
+ hasMore: boolean = true,
517
+ ): CollectionPage {
518
+ const items: Array<TopologyEntity> = [];
519
+ for (let index: number = from; index <= to; index++) {
520
+ const name: string = `sensor-${String(index).padStart(3, "0")}`;
521
+ items.push(entity(`iot-${index}`, EntityType.IoTDevice, name));
522
+ }
523
+ const last: TopologyEntity | undefined = items[items.length - 1];
524
+ return page(
525
+ items,
526
+ total,
527
+ hasMore && last
528
+ ? { name: last.displayName || "", key: last.entityKey || "" }
529
+ : null,
530
+ );
531
+ }
532
+
223
533
  beforeEach(() => {
224
534
  window.history.replaceState(
225
535
  {},
226
536
  "",
227
537
  "/dashboard/project/topology/overview?tab=Infrastructure",
228
538
  );
539
+ mockDrawerMounts.length = 0;
540
+ mockDrawerRenders.length = 0;
541
+ mockFetchCollectionPage.mockReset();
542
+ mockFetchCollectionSearchCounts.mockReset();
229
543
  });
230
544
  afterEach(() => {
231
545
  cleanup();
@@ -254,6 +568,32 @@ describe("overview", () => {
254
568
  expect(cards).toHaveTextContent("Inactive not shown1");
255
569
  });
256
570
 
571
+ test("when a safety cap was hit the summary reports the server's exact totals", () => {
572
+ renderExplorer({
573
+ data: {
574
+ ...fixtures(),
575
+ totals: { resources: 250000, activeResources: 180000 },
576
+ truncation: { shown: 200000, total: 250000 },
577
+ },
578
+ });
579
+ const cards: HTMLElement = screen.getByTestId("infrastructure-explorer");
580
+ expect(cards).toHaveTextContent("Resources180,000");
581
+ expect(cards).toHaveTextContent("Inactive not shown70,000");
582
+ });
583
+
584
+ test("without a cap the totals do not replace what the map counted", () => {
585
+ renderExplorer({
586
+ data: {
587
+ ...fixtures(),
588
+ totals: { resources: 9, activeResources: 8 },
589
+ truncation: null,
590
+ },
591
+ });
592
+ expect(screen.getByTestId("infrastructure-explorer")).toHaveTextContent(
593
+ "Resources6",
594
+ );
595
+ });
596
+
257
597
  test("show inactive includes quiet resources, marked as inactive", () => {
258
598
  renderExplorer({ includeInactive: true });
259
599
  fireEvent.click(screen.getByTestId(`infrastructure-tree-${APP_GROUP}`));
@@ -309,28 +649,27 @@ describe("navigation", () => {
309
649
 
310
650
  test("the tree holds only containers and can be collapsed", () => {
311
651
  renderExplorer();
312
- const tree: HTMLElement = screen.getByRole("complementary", {
313
- name: "Infrastructure tree",
314
- });
315
652
  expect(
316
- within(tree).getByTestId(`infrastructure-tree-${APP_GROUP}`),
653
+ within(tree()).getByTestId(`infrastructure-tree-${APP_GROUP}`),
317
654
  ).toBeInTheDocument();
318
- expect(within(tree).queryByText("build-server")).not.toBeInTheDocument();
655
+ expect(within(tree()).queryByText("build-server")).not.toBeInTheDocument();
319
656
  fireEvent.click(
320
- within(tree).getByRole("button", { name: "Collapse Hosts & containers" }),
657
+ within(tree()).getByRole("button", {
658
+ name: "Collapse Hosts & containers",
659
+ }),
321
660
  );
322
661
  expect(
323
- within(tree).queryByTestId(`infrastructure-tree-${APP_GROUP}`),
662
+ within(tree()).queryByTestId(`infrastructure-tree-${APP_GROUP}`),
324
663
  ).not.toBeInTheDocument();
325
664
  fireEvent.click(
326
- within(tree).getByRole("button", { name: "Expand Hosts & containers" }),
665
+ within(tree()).getByRole("button", { name: "Expand Hosts & containers" }),
327
666
  );
328
667
  expect(
329
- within(tree).getByTestId(`infrastructure-tree-${APP_GROUP}`),
668
+ within(tree()).getByTestId(`infrastructure-tree-${APP_GROUP}`),
330
669
  ).toBeInTheDocument();
331
670
  });
332
671
 
333
- test("a plain resource opens its details", () => {
672
+ test("a plain resource opens its details from the preview it already has", () => {
334
673
  renderExplorer();
335
674
  fireEvent.click(
336
675
  screen.getByRole("button", { name: "View details for build-server" }),
@@ -338,6 +677,20 @@ describe("navigation", () => {
338
677
  expect(
339
678
  screen.getByRole("dialog", { name: "build-server" }),
340
679
  ).toBeInTheDocument();
680
+ expect(lastDrawer().entity).toEqual({
681
+ entityKey: "builder",
682
+ entityType: EntityType.Host,
683
+ displayName: "build-server",
684
+ });
685
+ // The drawer judges activity against the range the server used.
686
+ const drawer: HTMLElement = screen.getByRole("dialog");
687
+ expect(drawer).toHaveAttribute(
688
+ "data-range-start",
689
+ RANGE_START.toISOString(),
690
+ );
691
+ expect(drawer).toHaveAttribute("data-metrics-window", "900");
692
+ // Its connections stay in the drawer: this page is Infrastructure already.
693
+ expect(drawer).toHaveAttribute("data-opens-infrastructure", "false");
341
694
  fireEvent.click(screen.getByRole("button", { name: "Show where it is" }));
342
695
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
343
696
  expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
@@ -345,6 +698,51 @@ describe("navigation", () => {
345
698
  );
346
699
  });
347
700
 
701
+ test("choosing a connection in the drawer shows that entity instead", () => {
702
+ const onOpenServiceMap: MockFunction = getJestMockFunction();
703
+ renderExplorer({ onOpenServiceMap });
704
+ fireEvent.click(
705
+ screen.getByRole("button", { name: "View details for build-server" }),
706
+ );
707
+ fireEvent.click(
708
+ screen.getByRole("button", { name: "Select the api service" }),
709
+ );
710
+ expect(screen.getByRole("dialog", { name: "api" })).toHaveAttribute(
711
+ "data-entity-type",
712
+ EntityType.Service,
713
+ );
714
+ /*
715
+ * The same drawer shows the new entity: it is not torn down and built
716
+ * again (that would drop keyboard focus to the page and replay the
717
+ * slide-in); the drawer itself handles the switch.
718
+ */
719
+ expect(mockDrawerMounts).toHaveLength(1);
720
+ expect(lastDrawer().entity).toEqual({
721
+ entityKey: "api",
722
+ entityType: EntityType.Service,
723
+ displayName: "api",
724
+ });
725
+ fireEvent.click(
726
+ screen.getByRole("button", { name: "Show on the service map" }),
727
+ );
728
+ expect(onOpenServiceMap).toHaveBeenCalledWith("api");
729
+ });
730
+
731
+ test("a service has nowhere to be shown without the Service Map", () => {
732
+ renderExplorer();
733
+ fireEvent.click(
734
+ screen.getByRole("button", { name: "View details for build-server" }),
735
+ );
736
+ fireEvent.click(
737
+ screen.getByRole("button", { name: "Select the api service" }),
738
+ );
739
+ expect(screen.getByRole("dialog", { name: "api" })).toBeInTheDocument();
740
+ expect(lastDrawer().onFocus).toBeUndefined();
741
+ expect(
742
+ screen.queryByRole("button", { name: "Show on the service map" }),
743
+ ).not.toBeInTheDocument();
744
+ });
745
+
348
746
  test("a shared link to a resource opens its scope and its details", async () => {
349
747
  window.history.replaceState({}, "", "?tab=Infrastructure&infraFocus=h2");
350
748
  renderExplorer();
@@ -358,12 +756,33 @@ describe("navigation", () => {
358
756
  ).toBeInTheDocument();
359
757
  });
360
758
 
361
- test("a stale shared link falls back to the overview", () => {
759
+ test("a link to a resource the tree does not hold opens the drawer for that key", async () => {
362
760
  window.history.replaceState({}, "", "?infraFocus=missing");
363
761
  renderExplorer();
364
762
  expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
365
763
  "All infrastructure",
366
764
  );
765
+ await waitFor(() => {
766
+ expect(
767
+ screen.getByRole("dialog", { name: "missing" }),
768
+ ).toBeInTheDocument();
769
+ });
770
+ expect(lastDrawer().entity).toEqual({ entityKey: "missing" });
771
+ // Nothing on this page to show it on.
772
+ expect(lastDrawer().onFocus).toBeUndefined();
773
+ });
774
+
775
+ test("a stale link to a group falls back to the overview without a drawer", () => {
776
+ window.history.replaceState(
777
+ {},
778
+ "",
779
+ "?infraFocus=group:category:compute:host|renamed",
780
+ );
781
+ renderExplorer();
782
+ expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
783
+ "All infrastructure",
784
+ );
785
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
367
786
  });
368
787
 
369
788
  test("service chips open the service on the Service Map", () => {
@@ -382,6 +801,84 @@ describe("navigation", () => {
382
801
  });
383
802
  });
384
803
 
804
+ describe("a large tree", () => {
805
+ test("a small tree opens fully by default", () => {
806
+ renderExplorer({
807
+ data: {
808
+ entities: largeFixture().entities.slice(0, 5),
809
+ relationships: largeFixture().relationships.slice(0, 4),
810
+ },
811
+ });
812
+ // cluster → namespace → deployment, all listed without a click.
813
+ expect(
814
+ within(tree()).getByTestId("infrastructure-tree-deploy"),
815
+ ).toBeInTheDocument();
816
+ });
817
+
818
+ test("more than 200 containers open only the categories", () => {
819
+ renderExplorer({ data: largeFixture() });
820
+ expect(
821
+ within(tree()).getByTestId("infrastructure-tree-cluster"),
822
+ ).toBeInTheDocument();
823
+ expect(
824
+ within(tree()).queryByTestId("infrastructure-tree-ns"),
825
+ ).not.toBeInTheDocument();
826
+ fireEvent.click(within(tree()).getByLabelText("Expand prod"));
827
+ expect(
828
+ within(tree()).getByTestId("infrastructure-tree-ns"),
829
+ ).toBeInTheDocument();
830
+ });
831
+
832
+ test("a level with hundreds of containers lists the first ones and offers the rest", () => {
833
+ renderExplorer({ data: largeFixture() });
834
+ expect(
835
+ within(tree()).getByTestId("infrastructure-tree-host-099"),
836
+ ).toBeInTheDocument();
837
+ expect(
838
+ within(tree()).queryByTestId("infrastructure-tree-host-100"),
839
+ ).not.toBeInTheDocument();
840
+ fireEvent.click(
841
+ within(tree()).getByLabelText("Show all 250 in Hosts & containers"),
842
+ );
843
+ expect(
844
+ within(tree()).getByTestId("infrastructure-tree-host-249"),
845
+ ).toBeInTheDocument();
846
+ });
847
+
848
+ test("a deep link opens every ancestor and marks where it landed", async () => {
849
+ window.history.replaceState({}, "", "?infraFocus=pod-2");
850
+ renderExplorer({ data: largeFixture() });
851
+ await waitFor(() => {
852
+ expect(
853
+ within(tree()).getByTestId("infrastructure-tree-deploy"),
854
+ ).toHaveAttribute("aria-current", "true");
855
+ });
856
+ for (const name of ["prod", "shop"]) {
857
+ expect(within(tree()).getByLabelText(`Collapse ${name}`)).toHaveAttribute(
858
+ "aria-expanded",
859
+ "true",
860
+ );
861
+ }
862
+ expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
863
+ "checkout",
864
+ );
865
+ expect(lastDrawer().entity.entityKey).toBe("pod-2");
866
+ });
867
+
868
+ test("a deep link past the listed ones still shows the way to it", async () => {
869
+ window.history.replaceState({}, "", "?infraFocus=host-230/c");
870
+ renderExplorer({ data: largeFixture() });
871
+ await waitFor(() => {
872
+ expect(
873
+ within(tree()).getByTestId("infrastructure-tree-host-230"),
874
+ ).toHaveAttribute("aria-current", "true");
875
+ });
876
+ expect(
877
+ within(tree()).queryByTestId("infrastructure-tree-host-229"),
878
+ ).not.toBeInTheDocument();
879
+ });
880
+ });
881
+
385
882
  describe("search", () => {
386
883
  test("finds resources by name, type and the services running on them", () => {
387
884
  renderExplorer();
@@ -412,6 +909,347 @@ describe("search", () => {
412
909
  fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
413
910
  expect(rows()).toHaveLength(3);
414
911
  });
912
+
913
+ test("without collections the server is never asked", async () => {
914
+ renderExplorer();
915
+ search("home");
916
+ await new Promise((resolve: (value: unknown) => void) => {
917
+ setTimeout(resolve, 400);
918
+ });
919
+ expect(mockFetchCollectionSearchCounts).not.toHaveBeenCalled();
920
+ });
921
+ });
922
+
923
+ describe("collections", () => {
924
+ test("a collection is one row with its exact count, listed before plain resources", () => {
925
+ renderExplorer({ data: withCollection() });
926
+ const network: HTMLElement = screen.getByRole("region", {
927
+ name: "Network & devices",
928
+ });
929
+ const collectionRow: HTMLElement =
930
+ within(network).getAllByTestId("infrastructure-row")[0]!;
931
+ expect(collectionRow).toHaveTextContent("IoT Devices");
932
+ expect(collectionRow).toHaveTextContent("Collection");
933
+ expect(collectionRow).toHaveTextContent("4,200 IoT devices");
934
+ expect(screen.getByTestId("infrastructure-explorer")).toHaveTextContent(
935
+ "Resources4,207",
936
+ );
937
+ // 800 silent IoT devices and one silent host.
938
+ expect(screen.getByTestId("infrastructure-explorer")).toHaveTextContent(
939
+ "Inactive not shown801",
940
+ );
941
+ expect(
942
+ within(tree()).getByTestId(`infrastructure-tree-${IOT}`),
943
+ ).toBeInTheDocument();
944
+ });
945
+
946
+ test("opening a collection pages its items from the server", async () => {
947
+ mockFetchCollectionPage
948
+ .mockResolvedValueOnce(
949
+ page(
950
+ [
951
+ entity("iot-1", EntityType.IoTDevice, "sensor-001"),
952
+ entity("iot-2", EntityType.IoTDevice, "sensor-002", LONG_AGO),
953
+ ],
954
+ 4200,
955
+ { name: "sensor-002", key: "iot-2" },
956
+ ),
957
+ )
958
+ .mockResolvedValueOnce(
959
+ page([entity("iot-3", EntityType.IoTDevice, "sensor-003")], 4200, {
960
+ name: "sensor-003",
961
+ key: "iot-3",
962
+ }),
963
+ )
964
+ .mockResolvedValueOnce(
965
+ page([entity("iot-1", EntityType.IoTDevice, "sensor-001")], 4200, {
966
+ name: "sensor-001",
967
+ key: "iot-1",
968
+ }),
969
+ );
970
+ renderExplorer({ data: withCollection() });
971
+ fireEvent.click(screen.getByRole("button", { name: "Open IoT Devices" }));
972
+ expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
973
+ "IoT Devices",
974
+ );
975
+ expect(window.location.search).toContain(
976
+ `infraFocus=${encodeURIComponent(IOT)}`,
977
+ );
978
+ expect(screen.getByText("Loading IoT Devices…")).toHaveAttribute(
979
+ "aria-busy",
980
+ "true",
981
+ );
982
+ await waitFor(() => {
983
+ expect(
984
+ screen.getAllByTestId("infrastructure-collection-row"),
985
+ ).toHaveLength(2);
986
+ });
987
+ const [rangeStart, request, options] = mockFetchCollectionPage.mock
988
+ .calls[0] as [Date, CollectionPageRequest, { signal: AbortSignal }];
989
+ expect(rangeStart).toBe(RANGE_START);
990
+ expect(request).toEqual({
991
+ entityType: EntityType.IoTDevice,
992
+ includeInactive: false,
993
+ nameTerms: [],
994
+ cursor: null,
995
+ limit: 50,
996
+ });
997
+ expect(options.signal).toBeInstanceOf(AbortSignal);
998
+ expect(screen.getByText("Page 1 of 84 · 4,200 IoT devices")).toBeVisible();
999
+ // An item that went quiet says so.
1000
+ expect(
1001
+ screen.getAllByTestId("infrastructure-collection-row")[1],
1002
+ ).toHaveTextContent("Inactive");
1003
+
1004
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1005
+ await waitFor(() => {
1006
+ expect(
1007
+ screen.getByText("Page 2 of 84 · 4,200 IoT devices"),
1008
+ ).toBeVisible();
1009
+ });
1010
+ expect(
1011
+ (mockFetchCollectionPage.mock.calls[1]![1] as CollectionPageRequest)
1012
+ .cursor,
1013
+ ).toEqual({ name: "sensor-002", key: "iot-2" });
1014
+ expect(
1015
+ screen.getByRole("button", { name: "View details for sensor-003" }),
1016
+ ).toBeInTheDocument();
1017
+
1018
+ fireEvent.click(screen.getByRole("button", { name: "Previous" }));
1019
+ await waitFor(() => {
1020
+ expect(
1021
+ screen.getByText("Page 1 of 84 · 4,200 IoT devices"),
1022
+ ).toBeVisible();
1023
+ });
1024
+ expect(
1025
+ (mockFetchCollectionPage.mock.calls[2]![1] as CollectionPageRequest)
1026
+ .cursor,
1027
+ ).toBeNull();
1028
+ expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
1029
+
1030
+ fireEvent.click(
1031
+ screen.getByRole("button", { name: "View details for sensor-001" }),
1032
+ );
1033
+ expect(lastDrawer().entity).toEqual({
1034
+ entityKey: "iot-1",
1035
+ entityType: EntityType.IoTDevice,
1036
+ displayName: "sensor-001",
1037
+ });
1038
+ // Not a node of the tree: nothing to show it on.
1039
+ expect(lastDrawer().onFocus).toBeUndefined();
1040
+ });
1041
+
1042
+ test("the last page has no Next", async () => {
1043
+ mockFetchCollectionPage.mockResolvedValue(
1044
+ page([entity("iot-1", EntityType.IoTDevice, "sensor-001")], 1, null),
1045
+ );
1046
+ renderExplorer({ data: withCollection() });
1047
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1048
+ await waitFor(() => {
1049
+ expect(screen.getByText("Page 1 of 1 · 1 IoT device")).toBeVisible();
1050
+ });
1051
+ expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
1052
+ });
1053
+
1054
+ test("with inactive shown the whole collection is listed", async () => {
1055
+ mockFetchCollectionPage.mockResolvedValue(page([], 0, null));
1056
+ renderExplorer({ data: withCollection(), includeInactive: true });
1057
+ fireEvent.click(screen.getByRole("button", { name: "Open IoT Devices" }));
1058
+ await waitFor(() => {
1059
+ expect(mockFetchCollectionPage).toHaveBeenCalled();
1060
+ });
1061
+ expect(
1062
+ (mockFetchCollectionPage.mock.calls[0]![1] as CollectionPageRequest)
1063
+ .includeInactive,
1064
+ ).toBe(true);
1065
+ });
1066
+
1067
+ test("a failed page says why and can be retried", async () => {
1068
+ mockFetchCollectionPage
1069
+ .mockRejectedValueOnce(new Error("Server Error. Please try again"))
1070
+ .mockResolvedValueOnce(
1071
+ page([entity("iot-1", EntityType.IoTDevice, "sensor-001")], 1, null),
1072
+ );
1073
+ renderExplorer({ data: withCollection() });
1074
+ fireEvent.click(screen.getByRole("button", { name: "Open IoT Devices" }));
1075
+ const alert: HTMLElement = await screen.findByRole("alert");
1076
+ expect(alert).toHaveTextContent("Server Error. Please try again");
1077
+ fireEvent.click(within(alert).getByRole("button", { name: "Try again" }));
1078
+ await waitFor(() => {
1079
+ expect(
1080
+ screen.getAllByTestId("infrastructure-collection-row"),
1081
+ ).toHaveLength(1);
1082
+ });
1083
+ expect(mockFetchCollectionPage).toHaveBeenCalledTimes(2);
1084
+ });
1085
+
1086
+ test("the map view of a collection is its list", async () => {
1087
+ mockFetchCollectionPage.mockResolvedValue(
1088
+ page([entity("iot-1", EntityType.IoTDevice, "sensor-001")], 1, null),
1089
+ );
1090
+ window.history.replaceState({}, "", `?infraView=map&infraFocus=${IOT}`);
1091
+ renderExplorer({ data: withCollection() });
1092
+ await waitFor(() => {
1093
+ expect(
1094
+ screen.getAllByTestId("infrastructure-collection-row"),
1095
+ ).toHaveLength(1);
1096
+ });
1097
+ expect(
1098
+ screen.queryByTestId("infrastructure-map-stub"),
1099
+ ).not.toBeInTheDocument();
1100
+ });
1101
+
1102
+ test("the overview map draws a collection as a card that opens it", () => {
1103
+ mockFetchCollectionPage.mockReturnValue(new Promise(() => {}));
1104
+ renderExplorer({ data: withCollection() });
1105
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1106
+ fireEvent.click(screen.getByTestId(`map-card-${IOT}`));
1107
+ expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
1108
+ "IoT Devices",
1109
+ );
1110
+ });
1111
+
1112
+ test("search asks the server how many items of each collection match", async () => {
1113
+ mockFetchCollectionSearchCounts.mockResolvedValue(
1114
+ new Map<string, number>([[EntityType.IoTDevice, 1234]]),
1115
+ );
1116
+ mockFetchCollectionPage.mockResolvedValue(
1117
+ page([entity("iot-7", EntityType.IoTDevice, "sensor-007")], 1234, {
1118
+ name: "sensor-007",
1119
+ key: "iot-7",
1120
+ }),
1121
+ );
1122
+ renderExplorer({ data: withCollection() });
1123
+ search("Sensor");
1124
+ expect(screen.getByText("Searching large collections…")).toBeVisible();
1125
+ const match: HTMLElement = await screen.findByTestId(
1126
+ "infrastructure-collection-match",
1127
+ );
1128
+ expect(match).toHaveTextContent("1,234 matching IoT devices");
1129
+ expect(match).toHaveTextContent("in IoT Devices");
1130
+ // Nothing in the tree matches, but the collection does: not a miss.
1131
+ expect(
1132
+ screen.queryByText("No resources match your search"),
1133
+ ).not.toBeInTheDocument();
1134
+ expect(mockFetchCollectionSearchCounts).toHaveBeenCalledTimes(1);
1135
+ const [rangeStart, request] = mockFetchCollectionSearchCounts.mock
1136
+ .calls[0] as [Date, CollectionSearchRequest];
1137
+ expect(rangeStart).toBe(RANGE_START);
1138
+ expect(request).toEqual({
1139
+ includeInactive: false,
1140
+ types: [{ entityType: EntityType.IoTDevice, nameTerms: ["sensor"] }],
1141
+ });
1142
+
1143
+ fireEvent.click(match);
1144
+ expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
1145
+ "IoT Devices",
1146
+ );
1147
+ await waitFor(() => {
1148
+ expect(
1149
+ screen.getAllByTestId("infrastructure-collection-row"),
1150
+ ).toHaveLength(1);
1151
+ });
1152
+ expect(
1153
+ (mockFetchCollectionPage.mock.calls[0]![1] as CollectionPageRequest)
1154
+ .nameTerms,
1155
+ ).toEqual(["sensor"]);
1156
+ expect(screen.getByText("Names containing “sensor”")).toBeVisible();
1157
+
1158
+ // Clearing the filter lists the whole collection again.
1159
+ fireEvent.click(
1160
+ screen.getByRole("button", { name: "Show all IoT devices" }),
1161
+ );
1162
+ await waitFor(() => {
1163
+ expect(mockFetchCollectionPage).toHaveBeenCalledTimes(2);
1164
+ });
1165
+ expect(
1166
+ (mockFetchCollectionPage.mock.calls[1]![1] as CollectionPageRequest)
1167
+ .nameTerms,
1168
+ ).toEqual([]);
1169
+ });
1170
+
1171
+ test("typing is debounced into one count request", async () => {
1172
+ mockFetchCollectionSearchCounts.mockResolvedValue(
1173
+ new Map<string, number>([[EntityType.IoTDevice, 3]]),
1174
+ );
1175
+ renderExplorer({ data: withCollection() });
1176
+ search("s");
1177
+ search("se");
1178
+ search("sen");
1179
+ await screen.findByTestId("infrastructure-collection-match");
1180
+ expect(mockFetchCollectionSearchCounts).toHaveBeenCalledTimes(1);
1181
+ expect(
1182
+ (
1183
+ mockFetchCollectionSearchCounts.mock
1184
+ .calls[0]![1] as CollectionSearchRequest
1185
+ ).types[0]!.nameTerms,
1186
+ ).toEqual(["sen"]);
1187
+ });
1188
+
1189
+ test("a query the type label answers counts every item without asking", () => {
1190
+ renderExplorer({ data: withCollection() });
1191
+ search("iot");
1192
+ const match: HTMLElement = screen.getByTestId(
1193
+ "infrastructure-collection-match",
1194
+ );
1195
+ expect(match).toHaveTextContent("4,200 matching IoT devices");
1196
+ expect(mockFetchCollectionSearchCounts).not.toHaveBeenCalled();
1197
+ });
1198
+
1199
+ test("label terms are left out of the name terms the server matches", async () => {
1200
+ mockFetchCollectionSearchCounts.mockResolvedValue(
1201
+ new Map<string, number>(),
1202
+ );
1203
+ renderExplorer({ data: withCollection() });
1204
+ search("iot sensor");
1205
+ await waitFor(() => {
1206
+ expect(mockFetchCollectionSearchCounts).toHaveBeenCalled();
1207
+ });
1208
+ expect(
1209
+ (
1210
+ mockFetchCollectionSearchCounts.mock
1211
+ .calls[0]![1] as CollectionSearchRequest
1212
+ ).types,
1213
+ ).toEqual([{ entityType: EntityType.IoTDevice, nameTerms: ["sensor"] }]);
1214
+ await waitFor(() => {
1215
+ expect(
1216
+ screen.getByText("No resources match your search"),
1217
+ ).toBeInTheDocument();
1218
+ });
1219
+ expect(
1220
+ screen.queryByTestId("infrastructure-collection-match"),
1221
+ ).not.toBeInTheDocument();
1222
+ });
1223
+
1224
+ test("a failed count says so and can be retried", async () => {
1225
+ mockFetchCollectionSearchCounts
1226
+ .mockRejectedValueOnce(new Error("Server Error. Please try again"))
1227
+ .mockResolvedValueOnce(
1228
+ new Map<string, number>([[EntityType.IoTDevice, 9]]),
1229
+ );
1230
+ renderExplorer({ data: withCollection() });
1231
+ search("sensor");
1232
+ const alert: HTMLElement = await screen.findByRole("alert");
1233
+ expect(alert).toHaveTextContent("Could not search large collections.");
1234
+ expect(
1235
+ screen.queryByText("No resources match your search"),
1236
+ ).not.toBeInTheDocument();
1237
+ fireEvent.click(within(alert).getByRole("button", { name: "Try again" }));
1238
+ expect(
1239
+ await screen.findByTestId("infrastructure-collection-match"),
1240
+ ).toHaveTextContent("9 matching IoT devices");
1241
+ });
1242
+
1243
+ test("resources and collection matches are counted together", async () => {
1244
+ mockFetchCollectionSearchCounts.mockResolvedValue(
1245
+ new Map<string, number>([[EntityType.IoTDevice, 20]]),
1246
+ );
1247
+ renderExplorer({ data: withCollection() });
1248
+ search("core");
1249
+ await screen.findByTestId("infrastructure-collection-match");
1250
+ expect(rows()).toHaveLength(1);
1251
+ expect(screen.getByText("21 matches")).toBeVisible();
1252
+ });
415
1253
  });
416
1254
 
417
1255
  describe("map", () => {
@@ -435,6 +1273,26 @@ describe("map", () => {
435
1273
  expect(onOpenServiceMap).toHaveBeenCalledWith("api");
436
1274
  });
437
1275
 
1276
+ test("a service card opens its details when there is no Service Map", () => {
1277
+ renderExplorer();
1278
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1279
+ fireEvent.click(screen.getByRole("button", { name: "Map service api" }));
1280
+ expect(lastDrawer().entity).toEqual({
1281
+ entityKey: "api",
1282
+ entityType: EntityType.Service,
1283
+ displayName: "api",
1284
+ });
1285
+ });
1286
+
1287
+ test("the map is handed the page's rate window", () => {
1288
+ renderExplorer();
1289
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1290
+ expect(screen.getByTestId("infrastructure-map-stub")).toHaveAttribute(
1291
+ "data-metrics-window",
1292
+ "900",
1293
+ );
1294
+ });
1295
+
438
1296
  test("the overflow card switches to the complete list", () => {
439
1297
  window.history.replaceState({}, "", "?infraView=map");
440
1298
  renderExplorer();
@@ -446,3 +1304,453 @@ describe("map", () => {
446
1304
  expect(rows()).toHaveLength(3);
447
1305
  });
448
1306
  });
1307
+
1308
+ /*
1309
+ * Issue #3972: the map joins the cards whose services call each other, and a
1310
+ * line opens the history of the call it stands for — the Service Map's own
1311
+ * drill-down, since traffic is measured per service pair.
1312
+ */
1313
+ describe("traffic on the map", () => {
1314
+ const HOME_GROUP: string = "group:category:compute:host|oneuptime-home";
1315
+ const TIME_RANGE: RangeStartAndEndDateTime = {
1316
+ range: TimeRange.PAST_ONE_DAY,
1317
+ };
1318
+
1319
+ function tracedFixture(): Fixture {
1320
+ const base: Fixture = fixtures();
1321
+ return {
1322
+ ...base,
1323
+ relationships: [
1324
+ ...base.relationships,
1325
+ {
1326
+ ...relationship("api", "home", EntityRelationshipType.DependsOn),
1327
+ callCount: 120,
1328
+ errorCount: 1,
1329
+ avgDurationMs: 30,
1330
+ },
1331
+ {
1332
+ ...relationship("home", "api", EntityRelationshipType.DependsOn),
1333
+ callCount: 12,
1334
+ },
1335
+ ],
1336
+ };
1337
+ }
1338
+
1339
+ beforeEach(() => {
1340
+ mockEdgePanelRenders.length = 0;
1341
+ });
1342
+
1343
+ test("a line opens the history of the busiest call it stands for", () => {
1344
+ renderExplorer({ data: tracedFixture(), timeRange: TIME_RANGE });
1345
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1346
+ fireEvent.click(
1347
+ screen.getByRole("button", {
1348
+ name: `Map traffic ${APP_GROUP} -> ${HOME_GROUP}`,
1349
+ }),
1350
+ );
1351
+ expect(
1352
+ screen.getByRole("dialog", { name: "api calls home" }),
1353
+ ).toBeInTheDocument();
1354
+ const panel: EdgePanelProps =
1355
+ mockEdgePanelRenders[mockEdgePanelRenders.length - 1]!;
1356
+ expect(panel.fromEntity).toMatchObject({
1357
+ entityKey: "api",
1358
+ entityType: EntityType.Service,
1359
+ displayName: "api",
1360
+ });
1361
+ expect(panel.toEntity).toMatchObject({
1362
+ entityKey: "home",
1363
+ entityType: EntityType.Service,
1364
+ });
1365
+ expect(panel.relationship).toEqual({
1366
+ fromEntityKey: "api",
1367
+ toEntityKey: "home",
1368
+ relationshipType: EntityRelationshipType.DependsOn,
1369
+ callCount: 120,
1370
+ errorCount: 1,
1371
+ avgDurationMs: 30,
1372
+ });
1373
+ expect(panel.timeRange).toBe(TIME_RANGE);
1374
+ expect(panel.metricsWindowSeconds).toBe(900);
1375
+
1376
+ fireEvent.click(screen.getByRole("button", { name: "Close call history" }));
1377
+ expect(
1378
+ screen.queryByRole("dialog", { name: "api calls home" }),
1379
+ ).not.toBeInTheDocument();
1380
+ });
1381
+
1382
+ test("the other direction is its own line, with its own call", () => {
1383
+ renderExplorer({ data: tracedFixture(), timeRange: TIME_RANGE });
1384
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1385
+ fireEvent.click(
1386
+ screen.getByRole("button", {
1387
+ name: `Map traffic ${HOME_GROUP} -> ${APP_GROUP}`,
1388
+ }),
1389
+ );
1390
+ const panel: EdgePanelProps =
1391
+ mockEdgePanelRenders[mockEdgePanelRenders.length - 1]!;
1392
+ expect(panel.relationship).toEqual({
1393
+ fromEntityKey: "home",
1394
+ toEntityKey: "api",
1395
+ relationshipType: EntityRelationshipType.DependsOn,
1396
+ callCount: 12,
1397
+ errorCount: 0,
1398
+ });
1399
+ });
1400
+
1401
+ test("one drawer at a time: opening a resource closes the call's history", () => {
1402
+ renderExplorer({ data: tracedFixture(), timeRange: TIME_RANGE });
1403
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1404
+ fireEvent.click(
1405
+ screen.getByRole("button", {
1406
+ name: `Map traffic ${APP_GROUP} -> ${HOME_GROUP}`,
1407
+ }),
1408
+ );
1409
+ expect(
1410
+ screen.getByRole("dialog", { name: "api calls home" }),
1411
+ ).toBeInTheDocument();
1412
+ fireEvent.click(screen.getByRole("button", { name: "Map service api" }));
1413
+ expect(
1414
+ screen.queryByRole("dialog", { name: "api calls home" }),
1415
+ ).not.toBeInTheDocument();
1416
+ expect(lastDrawer().entity.entityKey).toBe("api");
1417
+ });
1418
+
1419
+ test("without the page's time range there is no history to open", () => {
1420
+ renderExplorer({ data: tracedFixture() });
1421
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1422
+ expect(
1423
+ screen.queryByRole("button", { name: /^Map traffic/ }),
1424
+ ).not.toBeInTheDocument();
1425
+ });
1426
+ });
1427
+
1428
+ /*
1429
+ * A fake collection server: two IoT devices a page, keyset-paged by the
1430
+ * last key, with 4,200 active devices and 5,000 with inactive ones included.
1431
+ */
1432
+ function serveIotPages(
1433
+ options: { emptyAfter?: number; emptyTotal?: number } = {},
1434
+ ): void {
1435
+ mockFetchCollectionPage.mockImplementation(
1436
+ (_rangeStart: unknown, request: unknown): Promise<CollectionPage> => {
1437
+ const pageRequest: CollectionPageRequest =
1438
+ request as CollectionPageRequest;
1439
+ const total: number = pageRequest.includeInactive ? 5000 : 4200;
1440
+ const start: number = pageRequest.cursor
1441
+ ? Number(pageRequest.cursor.key.replace("iot-", "")) + 1
1442
+ : 1;
1443
+ if (options.emptyAfter !== undefined && start > options.emptyAfter) {
1444
+ // Everything past the cursor went away between the two requests.
1445
+ return Promise.resolve(
1446
+ page([], options.emptyTotal ?? total - 50, null),
1447
+ );
1448
+ }
1449
+ return Promise.resolve(iotPage(start, start + 1, total));
1450
+ },
1451
+ );
1452
+ }
1453
+
1454
+ async function expectPageLabel(label: string): Promise<void> {
1455
+ await waitFor(() => {
1456
+ expect(screen.getByText(label)).toBeVisible();
1457
+ });
1458
+ }
1459
+
1460
+ describe("collection paging", () => {
1461
+ test("a collection always opens on its first page, however it is reopened", async () => {
1462
+ serveIotPages();
1463
+ renderExplorer({ data: withCollection() });
1464
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1465
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1466
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1467
+ await expectPageLabel("Page 2 of 84 · 4,200 IoT devices");
1468
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1469
+ await expectPageLabel("Page 3 of 84 · 4,200 IoT devices");
1470
+ expect(lastPageRequest().cursor).toEqual({
1471
+ name: "sensor-004",
1472
+ key: "iot-4",
1473
+ });
1474
+
1475
+ // Away to the overview and back from the tree.
1476
+ fireEvent.click(screen.getByTestId("infrastructure-tree-root"));
1477
+ expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
1478
+ "All infrastructure",
1479
+ );
1480
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1481
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1482
+ expect(lastPageRequest().cursor).toBeNull();
1483
+
1484
+ // The collection's own tree row, while it is open.
1485
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1486
+ await expectPageLabel("Page 2 of 84 · 4,200 IoT devices");
1487
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1488
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1489
+ expect(lastPageRequest().cursor).toBeNull();
1490
+
1491
+ // Its breadcrumb.
1492
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1493
+ await expectPageLabel("Page 2 of 84 · 4,200 IoT devices");
1494
+ fireEvent.click(
1495
+ within(
1496
+ screen.getByRole("navigation", { name: "Infrastructure location" }),
1497
+ ).getByRole("button", { name: "IoT Devices" }),
1498
+ );
1499
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1500
+ expect(lastPageRequest().cursor).toBeNull();
1501
+
1502
+ // A search match that lists the whole collection.
1503
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1504
+ await expectPageLabel("Page 2 of 84 · 4,200 IoT devices");
1505
+ search("iot");
1506
+ fireEvent.click(screen.getByTestId("infrastructure-collection-match"));
1507
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1508
+ expect(lastPageRequest().cursor).toBeNull();
1509
+
1510
+ // Its card on the overview map.
1511
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1512
+ await expectPageLabel("Page 2 of 84 · 4,200 IoT devices");
1513
+ fireEvent.click(screen.getByTestId("infrastructure-tree-root"));
1514
+ fireEvent.click(screen.getByTestId("infrastructure-view-map"));
1515
+ fireEvent.click(screen.getByTestId(`map-card-${IOT}`));
1516
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1517
+ expect(lastPageRequest().cursor).toBeNull();
1518
+ });
1519
+
1520
+ test("an empty page past the first says so and leads back to the first page", async () => {
1521
+ serveIotPages({ emptyAfter: 2 });
1522
+ renderExplorer({ data: withCollection() });
1523
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1524
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1525
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1526
+
1527
+ const empty: HTMLElement = await screen.findByTestId(
1528
+ "infrastructure-collection-page-empty",
1529
+ );
1530
+ expect(empty).toHaveTextContent("No more items on this page.");
1531
+ expect(
1532
+ screen.queryByText("Nothing inside this resource."),
1533
+ ).not.toBeInTheDocument();
1534
+ // The pager is still there, with the way back.
1535
+ expect(screen.getByText("Page 2 of 83 · 4,150 IoT devices")).toBeVisible();
1536
+ expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled();
1537
+ expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
1538
+
1539
+ fireEvent.click(
1540
+ within(empty).getByRole("button", { name: "Back to the first page" }),
1541
+ );
1542
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1543
+ expect(lastPageRequest().cursor).toBeNull();
1544
+ expect(screen.getAllByTestId("infrastructure-collection-row")).toHaveLength(
1545
+ 2,
1546
+ );
1547
+
1548
+ // Previous gets there too.
1549
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1550
+ await screen.findByTestId("infrastructure-collection-page-empty");
1551
+ fireEvent.click(screen.getByRole("button", { name: "Previous" }));
1552
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1553
+ });
1554
+
1555
+ test("an emptied page never claims to be past the last page", async () => {
1556
+ // Pages 1 and 2 were read; then most of the fleet was pruned (60 left).
1557
+ serveIotPages({ emptyAfter: 4, emptyTotal: 60 });
1558
+ renderExplorer({ data: withCollection() });
1559
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1560
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1561
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1562
+ await expectPageLabel("Page 2 of 84 · 4,200 IoT devices");
1563
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1564
+ await screen.findByTestId("infrastructure-collection-page-empty");
1565
+ expect(screen.getByText("Page 3 of 3 · 60 IoT devices")).toBeVisible();
1566
+ expect(screen.queryByText(/Page 3 of 2/)).not.toBeInTheDocument();
1567
+ });
1568
+
1569
+ test("an empty first page still reads as an empty collection", async () => {
1570
+ mockFetchCollectionPage.mockResolvedValue(page([], 0, null));
1571
+ renderExplorer({ data: withCollection() });
1572
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1573
+ expect(
1574
+ await screen.findByText("Nothing inside this resource."),
1575
+ ).toBeVisible();
1576
+ expect(
1577
+ screen.queryByTestId("infrastructure-collection-page-empty"),
1578
+ ).not.toBeInTheDocument();
1579
+ });
1580
+ });
1581
+
1582
+ describe("Show inactive with a collection open or searched", () => {
1583
+ test("toggling it lists the open collection again from its first page, with the new total", async () => {
1584
+ serveIotPages();
1585
+ const data: Fixture = withCollection();
1586
+ const view: RenderResult = renderExplorer({ data });
1587
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1588
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1589
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
1590
+ await expectPageLabel("Page 2 of 84 · 4,200 IoT devices");
1591
+
1592
+ view.rerender(explorer({ data, includeInactive: true }));
1593
+ await expectPageLabel("Page 1 of 100 · 5,000 IoT devices");
1594
+ expect(lastPageRequest()).toEqual({
1595
+ entityType: EntityType.IoTDevice,
1596
+ includeInactive: true,
1597
+ nameTerms: [],
1598
+ cursor: null,
1599
+ limit: 50,
1600
+ });
1601
+ expect(screen.getByTestId("infrastructure-scope-title")).toHaveTextContent(
1602
+ "IoT Devices",
1603
+ );
1604
+
1605
+ /*
1606
+ * And straight back to active only: page 1 again, never the page-2
1607
+ * cursor the active-only listing had before the toggle.
1608
+ */
1609
+ const before: number = mockFetchCollectionPage.mock.calls.length;
1610
+ view.rerender(explorer({ data, includeInactive: false }));
1611
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1612
+ const after: number = mockFetchCollectionPage.mock.calls.length;
1613
+ expect(after).toBeGreaterThan(before);
1614
+ for (let index: number = before; index < after; index++) {
1615
+ expect(pageRequest(index)).toEqual({
1616
+ entityType: EntityType.IoTDevice,
1617
+ includeInactive: false,
1618
+ nameTerms: [],
1619
+ cursor: null,
1620
+ limit: 50,
1621
+ });
1622
+ }
1623
+ expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
1624
+ });
1625
+
1626
+ test("toggling it asks the server again how many collection items match", async () => {
1627
+ mockFetchCollectionSearchCounts.mockImplementation(
1628
+ (
1629
+ _rangeStart: unknown,
1630
+ request: unknown,
1631
+ ): Promise<Map<string, number>> => {
1632
+ return Promise.resolve(
1633
+ new Map<string, number>([
1634
+ [
1635
+ EntityType.IoTDevice,
1636
+ (request as CollectionSearchRequest).includeInactive
1637
+ ? 1500
1638
+ : 1234,
1639
+ ],
1640
+ ]),
1641
+ );
1642
+ },
1643
+ );
1644
+ const data: Fixture = withCollection();
1645
+ const view: RenderResult = renderExplorer({ data });
1646
+ search("sensor");
1647
+ expect(
1648
+ await screen.findByTestId("infrastructure-collection-match"),
1649
+ ).toHaveTextContent("1,234 matching IoT devices");
1650
+ expect(mockFetchCollectionSearchCounts).toHaveBeenCalledTimes(1);
1651
+
1652
+ view.rerender(explorer({ data, includeInactive: true }));
1653
+ await waitFor(() => {
1654
+ expect(
1655
+ screen.getByTestId("infrastructure-collection-match"),
1656
+ ).toHaveTextContent("1,500 matching IoT devices");
1657
+ });
1658
+ expect(mockFetchCollectionSearchCounts).toHaveBeenCalledTimes(2);
1659
+ expect(mockFetchCollectionSearchCounts.mock.calls[1]![1]).toEqual({
1660
+ includeInactive: true,
1661
+ types: [{ entityType: EntityType.IoTDevice, nameTerms: ["sensor"] }],
1662
+ });
1663
+ expect(screen.getByText("1,500 matches")).toBeVisible();
1664
+ });
1665
+ });
1666
+
1667
+ describe("collection request failures", () => {
1668
+ const BUSY: string = "The topology service is busy. Try again in a moment.";
1669
+
1670
+ function tooManyRequests(): HTTPErrorResponse {
1671
+ return new HTTPErrorResponse(
1672
+ 429,
1673
+ { message: "Too many topology requests are running. Try again shortly." },
1674
+ {},
1675
+ );
1676
+ }
1677
+
1678
+ test("a busy server (429) on a page says so and offers another try", async () => {
1679
+ mockFetchCollectionPage
1680
+ .mockRejectedValueOnce(tooManyRequests())
1681
+ .mockResolvedValueOnce(iotPage(1, 2, 4200));
1682
+ renderExplorer({ data: withCollection() });
1683
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1684
+ const alert: HTMLElement = await screen.findByRole("alert");
1685
+ // The page's own (translated) copy, not the server's wording.
1686
+ expect(alert).toHaveTextContent(BUSY);
1687
+ expect(alert).not.toHaveTextContent("Too many topology requests");
1688
+ expect(alert).not.toHaveTextContent("Topology was updated");
1689
+ expect(
1690
+ within(alert).queryByRole("button", { name: "Reload page" }),
1691
+ ).not.toBeInTheDocument();
1692
+ fireEvent.click(within(alert).getByRole("button", { name: "Try again" }));
1693
+ await expectPageLabel("Page 1 of 84 · 4,200 IoT devices");
1694
+ expect(mockFetchCollectionPage).toHaveBeenCalledTimes(2);
1695
+ });
1696
+
1697
+ test("a busy server (429) on a search says so and offers another try", async () => {
1698
+ mockFetchCollectionSearchCounts
1699
+ .mockRejectedValueOnce(tooManyRequests())
1700
+ .mockResolvedValueOnce(
1701
+ new Map<string, number>([[EntityType.IoTDevice, 9]]),
1702
+ );
1703
+ renderExplorer({ data: withCollection() });
1704
+ search("sensor");
1705
+ const alert: HTMLElement = await screen.findByRole("alert");
1706
+ expect(alert).toHaveTextContent(
1707
+ `Could not search large collections. ${BUSY}`,
1708
+ );
1709
+ expect(alert).not.toHaveTextContent("Too many topology requests");
1710
+ expect(
1711
+ within(alert).queryByRole("button", { name: "Reload page" }),
1712
+ ).not.toBeInTheDocument();
1713
+ fireEvent.click(within(alert).getByRole("button", { name: "Try again" }));
1714
+ expect(
1715
+ await screen.findByTestId("infrastructure-collection-match"),
1716
+ ).toHaveTextContent("9 matching IoT devices");
1717
+ });
1718
+
1719
+ test("a newer server format offers a reload, since trying again cannot help", async () => {
1720
+ const reload: SpyInstance<() => void> = jest
1721
+ .spyOn(Navigation, "reload")
1722
+ .mockImplementation(() => {
1723
+ return undefined;
1724
+ });
1725
+ mockFetchCollectionPage.mockRejectedValue(new TopologyOutdatedError(99));
1726
+ renderExplorer({ data: withCollection() });
1727
+ fireEvent.click(screen.getByTestId(`infrastructure-tree-${IOT}`));
1728
+ const alert: HTMLElement = await screen.findByRole("alert");
1729
+ expect(alert).toHaveTextContent("Topology was updated. Reload the page.");
1730
+ expect(
1731
+ within(alert).queryByRole("button", { name: "Try again" }),
1732
+ ).not.toBeInTheDocument();
1733
+ fireEvent.click(within(alert).getByRole("button", { name: "Reload page" }));
1734
+ expect(reload).toHaveBeenCalledTimes(1);
1735
+ expect(mockFetchCollectionPage).toHaveBeenCalledTimes(1);
1736
+ });
1737
+
1738
+ test("a newer server format on a search offers a reload too", async () => {
1739
+ const reload: SpyInstance<() => void> = jest
1740
+ .spyOn(Navigation, "reload")
1741
+ .mockImplementation(() => {
1742
+ return undefined;
1743
+ });
1744
+ mockFetchCollectionSearchCounts.mockRejectedValue(
1745
+ new TopologyOutdatedError(99),
1746
+ );
1747
+ renderExplorer({ data: withCollection() });
1748
+ search("sensor");
1749
+ const alert: HTMLElement = await screen.findByRole("alert");
1750
+ expect(alert).toHaveTextContent(
1751
+ "Could not search large collections. Topology was updated. Reload the page.",
1752
+ );
1753
+ fireEvent.click(within(alert).getByRole("button", { name: "Reload page" }));
1754
+ expect(reload).toHaveBeenCalledTimes(1);
1755
+ });
1756
+ });