@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.
- package/Models/DatabaseModels/InventoryItem.ts +23 -12
- package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/AlertService.ts +32 -36
- package/Server/Services/IncidentAlertService.ts +609 -2
- package/Server/Services/IncidentService.ts +134 -44
- package/Server/Services/InventoryItemService.ts +38 -0
- package/Server/Services/IoTDeviceCredentialService.ts +13 -4
- package/Server/Services/MetricService.ts +1 -1
- package/Server/Services/ScheduledMaintenanceService.ts +50 -46
- package/Server/Services/UserNotificationRuleService.ts +55 -53
- package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
- package/Server/Types/Database/QueryHelper.ts +54 -1
- package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
- package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
- package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
- package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
- package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
- package/Server/Utils/Response.ts +31 -0
- package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
- package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
- package/Server/Utils/Topology/TopologyRequest.ts +463 -0
- package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
- package/Server/Utils/Topology/TopologySql.ts +1024 -0
- package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
- package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
- package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
- package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
- package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
- package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
- package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
- package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
- package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
- package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
- package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
- package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
- package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
- package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
- package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
- package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
- package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
- package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
- package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
- package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
- package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
- package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
- package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
- package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
- package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
- package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
- package/Tests/Server/Services/DomainService.test.ts +525 -0
- package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
- package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
- package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
- package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
- package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
- package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
- package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
- package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
- package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
- package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
- package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
- package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
- package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
- package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
- package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
- package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
- package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
- package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
- package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
- package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
- package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
- package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
- package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
- package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
- package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
- package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
- package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
- package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
- package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
- package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
- package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
- package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
- package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
- package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
- package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
- package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
- package/Types/BaseDatabase/AggregationType.ts +7 -0
- package/Types/Dashboard/DashboardTemplates.ts +20 -7
- package/Types/Incident/IncidentAlertLink.ts +19 -0
- package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
- package/Types/Rum/WebVitals.ts +13 -0
- package/Types/Telemetry/InventoryLiveness.ts +26 -0
- package/Types/Topology/TopologyApi.ts +428 -0
- package/Types/Topology/TopologyTypeRules.ts +191 -0
- package/UI/Components/Input/Input.tsx +11 -0
- package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
- package/Utils/Incident/IncidentFromAlerts.ts +78 -0
- package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AlertService.js +21 -33
- package/build/dist/Server/Services/AlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +457 -3
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +96 -42
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemService.js +31 -0
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
- package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
- package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
- package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
- package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
- package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
- package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +30 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
- package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
- package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
- package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
- package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
- package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +12 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
- package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
- package/build/dist/Types/Topology/TopologyApi.js +55 -0
- package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
- package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
- package/build/dist/UI/Components/Input/Input.js +1 -1
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
):
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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):
|
|
151
|
-
|
|
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
|
-
|
|
161
|
-
entities: Array<
|
|
162
|
-
relationships: Array<
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
{
|
|
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", {
|
|
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
|
|
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
|
+
});
|