@oneuptime/common 14.0.2 → 14.0.3
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/AlertFeed.ts +2 -0
- package/Models/DatabaseModels/IncidentAlert.ts +540 -0
- package/Models/DatabaseModels/IncidentFeed.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Models/DatabaseModels/RumApplication.ts +52 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/IncidentAlertService.ts +1811 -0
- package/Server/Services/IncidentService.ts +210 -10
- package/Server/Services/IncidentStateTimelineService.ts +24 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
- package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
- package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
- package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
- package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
- package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
- package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
- package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
- package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
- package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
- package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
- package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
- package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
- package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
- package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
- package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
- package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
- package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
- package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
- package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
- package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
- package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
- package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
- package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
- package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
- package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
- package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
- package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
- package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
- package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
- package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
- package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
- package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
- package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
- package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
- package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
- package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
- package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
- package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
- package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
- package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
- package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
- package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
- package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
- package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
- package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
- package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
- package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
- package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
- package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
- package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
- package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
- package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
- package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
- package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
- package/Tests/Types/Rum/WebVitals.test.ts +173 -0
- package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
- package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
- package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
- package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
- package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
- package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
- package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
- package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
- package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
- package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
- package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
- package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
- package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
- package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
- package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
- package/Types/Incident/IncidentAlertLink.ts +38 -0
- package/Types/Permission.ts +48 -0
- package/Types/Rum/SessionReplay.ts +25 -6
- package/Types/Rum/WebVitals.ts +52 -0
- package/UI/Components/Detail/FieldLabel.tsx +8 -0
- package/UI/Components/InfoCard/InfoCard.tsx +47 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
- package/UI/Components/ModelTable/Column.ts +5 -0
- package/UI/Components/Table/TableHeader.tsx +47 -2
- package/UI/Components/Table/Types/Column.ts +7 -0
- package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
- package/UI/Utils/PermissionGate.ts +11 -2
- package/Utils/Incident/IncidentFromAlerts.ts +516 -0
- package/Utils/Rum/SessionTraceState.ts +212 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
- package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +62 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
- package/build/dist/Server/Services/IncidentService.js +164 -12
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +25 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
- package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +18 -2
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
- package/build/dist/UI/Utils/PermissionGate.js +4 -2
- package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
- package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
- package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import {
|
|
11
|
+
act,
|
|
12
|
+
cleanup,
|
|
13
|
+
fireEvent,
|
|
14
|
+
render,
|
|
15
|
+
screen,
|
|
16
|
+
within,
|
|
17
|
+
} from "@testing-library/react";
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The (i) tooltips on the Proxmox cluster overview, rendered for real with
|
|
23
|
+
* only the network, the line charts and the self-fetching shared cards
|
|
24
|
+
* replaced.
|
|
25
|
+
*
|
|
26
|
+
* Every metric title - golden tiles, chart cards, the HA chips, the summary
|
|
27
|
+
* strip, the replication columns, the Ceph card and the top-consumer lists -
|
|
28
|
+
* is checked for an (i) whose tooltip is the matching
|
|
29
|
+
* PROXMOX_METRIC_DESCRIPTIONS entry, with nothing interactive nested around
|
|
30
|
+
* it. Then the page is fed data that tells the difference, and the claims
|
|
31
|
+
* the descriptions make ("the last 5 minutes", "a big node counts more",
|
|
32
|
+
* "in bytes", "counted once for each node", "the fullest volume", "left out
|
|
33
|
+
* after 15 minutes") are checked against what it shows.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const CLUSTER_ID: string = "0193c0de-7777-4aaa-8bbb-000000000007";
|
|
37
|
+
const CEPH_ID: string = "0193c0de-7777-4aaa-8bbb-00000000ceph";
|
|
38
|
+
const NOW: Date = new Date("2026-09-24T12:00:00.000Z");
|
|
39
|
+
const MINUTE: number = 60 * 1000;
|
|
40
|
+
const GIB: number = 1024 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
const modelGetItemMock: MockFunction = getJestMockFunction();
|
|
43
|
+
const modelGetListMock: MockFunction = getJestMockFunction();
|
|
44
|
+
const analyticsAggregateMock: MockFunction = getJestMockFunction();
|
|
45
|
+
const navigateMock: MockFunction = getJestMockFunction();
|
|
46
|
+
|
|
47
|
+
jest.mock("react-i18next", () => {
|
|
48
|
+
return {
|
|
49
|
+
useTranslation: () => {
|
|
50
|
+
return {
|
|
51
|
+
t: (key: string, opts?: { defaultValue?: string }): string => {
|
|
52
|
+
return opts?.defaultValue ?? key;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
60
|
+
return {
|
|
61
|
+
__esModule: true,
|
|
62
|
+
default: {
|
|
63
|
+
getItem: (...args: Array<unknown>): unknown => {
|
|
64
|
+
return modelGetItemMock(...args);
|
|
65
|
+
},
|
|
66
|
+
getList: (...args: Array<unknown>): unknown => {
|
|
67
|
+
return modelGetListMock(...args);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
|
|
74
|
+
return {
|
|
75
|
+
__esModule: true,
|
|
76
|
+
default: {
|
|
77
|
+
aggregate: (...args: Array<unknown>): unknown => {
|
|
78
|
+
return analyticsAggregateMock(...args);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
jest.mock("../../../UI/Utils/Navigation", () => {
|
|
85
|
+
return {
|
|
86
|
+
__esModule: true,
|
|
87
|
+
default: {
|
|
88
|
+
getLastParamAsObjectID: (): unknown => {
|
|
89
|
+
const { default: ObjectIDType } = jest.requireActual(
|
|
90
|
+
"../../../Types/ObjectID",
|
|
91
|
+
) as { default: new (id: string) => unknown };
|
|
92
|
+
return new ObjectIDType("0193c0de-7777-4aaa-8bbb-000000000007");
|
|
93
|
+
},
|
|
94
|
+
getLastParamAsString: (): string => {
|
|
95
|
+
return "0193c0de-7777-4aaa-8bbb-000000000007";
|
|
96
|
+
},
|
|
97
|
+
navigate: (...args: Array<unknown>): void => {
|
|
98
|
+
navigateMock(...args);
|
|
99
|
+
},
|
|
100
|
+
isOnThisPage: (): boolean => {
|
|
101
|
+
return false;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
jest.mock("../../../UI/Utils/Project", () => {
|
|
108
|
+
return {
|
|
109
|
+
__esModule: true,
|
|
110
|
+
default: {
|
|
111
|
+
getCurrentProjectId: (): unknown => {
|
|
112
|
+
const { default: ObjectIDType } = jest.requireActual(
|
|
113
|
+
"../../../Types/ObjectID",
|
|
114
|
+
) as { default: new (id: string) => unknown };
|
|
115
|
+
return new ObjectIDType("10000000-0000-4000-8000-000000000001");
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/*
|
|
122
|
+
* The line chart is replaced by a recorder that writes the series it was
|
|
123
|
+
* given onto the DOM, so a test can read what a chart card plots.
|
|
124
|
+
*/
|
|
125
|
+
jest.mock("../../../UI/Components/Charts/Line/LineChart", () => {
|
|
126
|
+
return {
|
|
127
|
+
__esModule: true,
|
|
128
|
+
default: (props: {
|
|
129
|
+
data: Array<{ seriesName: string; data: Array<{ x: Date; y: number }> }>;
|
|
130
|
+
}) => {
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
data-testid="line-chart"
|
|
134
|
+
data-series={JSON.stringify(
|
|
135
|
+
props.data.map(
|
|
136
|
+
(s: {
|
|
137
|
+
seriesName: string;
|
|
138
|
+
data: Array<{ x: Date; y: number }>;
|
|
139
|
+
}) => {
|
|
140
|
+
return {
|
|
141
|
+
name: s.seriesName,
|
|
142
|
+
points: s.data.map((p: { x: Date; y: number }) => {
|
|
143
|
+
return [new Date(p.x).toISOString(), p.y];
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
),
|
|
148
|
+
)}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
jest.mock("../../../UI/Components/ModelDetail/CardModelDetail", () => {
|
|
156
|
+
return {
|
|
157
|
+
__esModule: true,
|
|
158
|
+
default: () => {
|
|
159
|
+
return <div data-testid="card-model-detail" />;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
jest.mock(
|
|
165
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/ResourceActivity/ResourceActivityCards",
|
|
166
|
+
() => {
|
|
167
|
+
return {
|
|
168
|
+
__esModule: true,
|
|
169
|
+
default: () => {
|
|
170
|
+
return <div data-testid="resource-activity-cards" />;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
jest.mock(
|
|
177
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/AutoRefreshControl",
|
|
178
|
+
() => {
|
|
179
|
+
return {
|
|
180
|
+
__esModule: true,
|
|
181
|
+
default: () => {
|
|
182
|
+
return <div data-testid="auto-refresh-control" />;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
jest.mock(
|
|
189
|
+
"../../../UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker",
|
|
190
|
+
() => {
|
|
191
|
+
return {
|
|
192
|
+
__esModule: true,
|
|
193
|
+
default: () => {
|
|
194
|
+
return <div data-testid="time-range-picker" />;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
import ProxmoxClusterOverview from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/Index";
|
|
201
|
+
import {
|
|
202
|
+
PROXMOX_METRIC_DESCRIPTIONS,
|
|
203
|
+
ProxmoxMetric,
|
|
204
|
+
} from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/ProxmoxMetricDescriptions";
|
|
205
|
+
import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
|
|
206
|
+
import CephCluster from "../../../Models/DatabaseModels/CephCluster";
|
|
207
|
+
import ProxmoxResource from "../../../Models/DatabaseModels/ProxmoxResource";
|
|
208
|
+
import ValueFormatter from "../../../Utils/ValueFormatter";
|
|
209
|
+
|
|
210
|
+
const PAGE_PROPS: PageComponentProps = {} as PageComponentProps;
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------- fixtures
|
|
213
|
+
|
|
214
|
+
function minutesAgo(minutes: number): Date {
|
|
215
|
+
return new Date(NOW.getTime() - minutes * MINUTE);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type Row = Record<string, unknown>;
|
|
219
|
+
type Point = { timestamp: Date; value: number; attributes: Row };
|
|
220
|
+
|
|
221
|
+
let cluster: Row;
|
|
222
|
+
let cephCluster: Row;
|
|
223
|
+
let inventory: Array<Row>;
|
|
224
|
+
let series: Record<string, Array<Point>>;
|
|
225
|
+
// Aggregate calls held back until the test releases them.
|
|
226
|
+
let holdAggregates: boolean;
|
|
227
|
+
let releaseAggregates: () => void;
|
|
228
|
+
|
|
229
|
+
function point(minutes: number, id: string, value: number): Point {
|
|
230
|
+
return { timestamp: minutesAgo(minutes), value, attributes: { id } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function arrange(): void {
|
|
234
|
+
cluster = {
|
|
235
|
+
_id: CLUSTER_ID,
|
|
236
|
+
name: "pve-prod",
|
|
237
|
+
description: "",
|
|
238
|
+
otelCollectorStatus: "connected",
|
|
239
|
+
lastSeenAt: minutesAgo(1),
|
|
240
|
+
pveVersion: "8.2.4",
|
|
241
|
+
agentVersion: "1.4.0",
|
|
242
|
+
nodeCount: 3,
|
|
243
|
+
onlineNodeCount: 2,
|
|
244
|
+
guestCount: 3,
|
|
245
|
+
storageCount: 3,
|
|
246
|
+
guestsWithoutBackupCount: 1,
|
|
247
|
+
cephClusterId: CEPH_ID,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
cephCluster = {
|
|
251
|
+
_id: CEPH_ID,
|
|
252
|
+
name: "ceph-prod",
|
|
253
|
+
healthStatus: 1,
|
|
254
|
+
capacityUsedPercent: 81.5,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
inventory = [
|
|
258
|
+
{
|
|
259
|
+
kind: "Node",
|
|
260
|
+
externalId: "node/pve1",
|
|
261
|
+
name: "pve1",
|
|
262
|
+
isUp: true,
|
|
263
|
+
haState: "online",
|
|
264
|
+
latestCpuPercent: 10,
|
|
265
|
+
metricsUpdatedAt: minutesAgo(1),
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
kind: "Node",
|
|
269
|
+
externalId: "node/pve2",
|
|
270
|
+
name: "pve2",
|
|
271
|
+
isUp: true,
|
|
272
|
+
haState: "online",
|
|
273
|
+
metricsUpdatedAt: minutesAgo(1),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
kind: "Node",
|
|
277
|
+
externalId: "node/pve3",
|
|
278
|
+
name: "pve3",
|
|
279
|
+
isUp: false,
|
|
280
|
+
metricsUpdatedAt: minutesAgo(1),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
kind: "Guest",
|
|
284
|
+
externalId: "qemu/100",
|
|
285
|
+
name: "web",
|
|
286
|
+
vmid: 100,
|
|
287
|
+
isUp: true,
|
|
288
|
+
haState: "started",
|
|
289
|
+
isBackedUp: true,
|
|
290
|
+
latestCpuPercent: 75,
|
|
291
|
+
latestMemoryBytes: 3 * GIB,
|
|
292
|
+
latestMemoryPercent: 75,
|
|
293
|
+
metricsUpdatedAt: minutesAgo(1),
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
kind: "Guest",
|
|
297
|
+
externalId: "lxc/101",
|
|
298
|
+
name: "db",
|
|
299
|
+
vmid: 101,
|
|
300
|
+
isUp: true,
|
|
301
|
+
isBackedUp: false,
|
|
302
|
+
latestCpuPercent: 20,
|
|
303
|
+
latestMemoryBytes: GIB,
|
|
304
|
+
latestMemoryPercent: 50,
|
|
305
|
+
metricsUpdatedAt: minutesAgo(1),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
// The busiest guest - but its figures are 20 minutes old.
|
|
309
|
+
kind: "Guest",
|
|
310
|
+
externalId: "qemu/102",
|
|
311
|
+
name: "stale-guest",
|
|
312
|
+
vmid: 102,
|
|
313
|
+
isUp: true,
|
|
314
|
+
isBackedUp: true,
|
|
315
|
+
latestCpuPercent: 99,
|
|
316
|
+
latestMemoryBytes: 30 * GIB,
|
|
317
|
+
latestMemoryPercent: 99,
|
|
318
|
+
metricsUpdatedAt: minutesAgo(20),
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
kind: "Storage",
|
|
322
|
+
externalId: "storage/pve1/local",
|
|
323
|
+
name: "local",
|
|
324
|
+
isUp: true,
|
|
325
|
+
latestDiskBytes: 90 * GIB,
|
|
326
|
+
maxDiskBytes: 100 * GIB,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
// One NFS share, reported by both nodes that mount it.
|
|
330
|
+
kind: "Storage",
|
|
331
|
+
externalId: "storage/pve1/nfs",
|
|
332
|
+
name: "nfs",
|
|
333
|
+
isUp: true,
|
|
334
|
+
latestDiskBytes: 100 * GIB,
|
|
335
|
+
maxDiskBytes: 1000 * GIB,
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
kind: "Storage",
|
|
339
|
+
externalId: "storage/pve2/nfs",
|
|
340
|
+
name: "nfs",
|
|
341
|
+
isUp: true,
|
|
342
|
+
latestDiskBytes: 100 * GIB,
|
|
343
|
+
maxDiskBytes: 1000 * GIB,
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
series = {
|
|
348
|
+
/*
|
|
349
|
+
* 20 minutes ago only pve1 (4 cores) reports, at 90%. 2 minutes ago
|
|
350
|
+
* pve1 is at 10% and pve2 (12 cores) at 50%: capacity-weighted that is
|
|
351
|
+
* (0.1 x 4 + 0.5 x 12) / 16 = 40%, a plain mean would be 30%, and the
|
|
352
|
+
* whole range would average to 65%.
|
|
353
|
+
*/
|
|
354
|
+
pve_cpu_usage_ratio: [
|
|
355
|
+
point(20, "node/pve1", 0.9),
|
|
356
|
+
point(2, "node/pve1", 0.1),
|
|
357
|
+
point(2, "node/pve2", 0.5),
|
|
358
|
+
point(2, "qemu/100", 0.75),
|
|
359
|
+
],
|
|
360
|
+
pve_cpu_usage_limit: [
|
|
361
|
+
point(20, "node/pve1", 4),
|
|
362
|
+
point(2, "node/pve1", 4),
|
|
363
|
+
point(2, "node/pve2", 12),
|
|
364
|
+
],
|
|
365
|
+
// 16 of 16 GiB used 20 minutes ago, 8 of 16 GiB 2 minutes ago.
|
|
366
|
+
pve_memory_usage_bytes: [
|
|
367
|
+
point(20, "node/pve1", 8 * GIB),
|
|
368
|
+
point(20, "node/pve2", 8 * GIB),
|
|
369
|
+
point(2, "node/pve1", 2 * GIB),
|
|
370
|
+
point(2, "node/pve2", 6 * GIB),
|
|
371
|
+
point(2, "qemu/100", 3 * GIB),
|
|
372
|
+
],
|
|
373
|
+
pve_memory_size_bytes: [
|
|
374
|
+
point(20, "node/pve1", 8 * GIB),
|
|
375
|
+
point(20, "node/pve2", 8 * GIB),
|
|
376
|
+
point(2, "node/pve1", 8 * GIB),
|
|
377
|
+
point(2, "node/pve2", 8 * GIB),
|
|
378
|
+
],
|
|
379
|
+
// The shared NFS share appears twice; the node root disk is not storage.
|
|
380
|
+
pve_disk_usage_bytes: [
|
|
381
|
+
point(2, "storage/pve1/local", 90 * GIB),
|
|
382
|
+
point(2, "storage/pve1/nfs", 100 * GIB),
|
|
383
|
+
point(2, "storage/pve2/nfs", 100 * GIB),
|
|
384
|
+
point(2, "node/pve1", 5 * GIB),
|
|
385
|
+
],
|
|
386
|
+
// 1000 B/s in and 2000 B/s out between the two samples.
|
|
387
|
+
pve_network_receive_bytes: [
|
|
388
|
+
point(20, "qemu/100", 0),
|
|
389
|
+
point(2, "qemu/100", 1080000),
|
|
390
|
+
],
|
|
391
|
+
pve_network_transmit_bytes: [
|
|
392
|
+
point(20, "qemu/100", 0),
|
|
393
|
+
point(2, "qemu/100", 2160000),
|
|
394
|
+
],
|
|
395
|
+
pve_replication_info: [
|
|
396
|
+
{
|
|
397
|
+
timestamp: minutesAgo(1),
|
|
398
|
+
value: 1,
|
|
399
|
+
attributes: {
|
|
400
|
+
id: "100-0",
|
|
401
|
+
guest: "100",
|
|
402
|
+
source: "pve1",
|
|
403
|
+
target: "pve2",
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
pve_replication_last_sync_timestamp_seconds: [
|
|
408
|
+
point(1, "100-0", (NOW.getTime() - 2 * 60 * MINUTE) / 1000),
|
|
409
|
+
],
|
|
410
|
+
pve_replication_duration_seconds: [point(1, "100-0", 42)],
|
|
411
|
+
pve_replication_failed_syncs: [point(1, "100-0", 2)],
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
holdAggregates = false;
|
|
415
|
+
releaseAggregates = (): void => {};
|
|
416
|
+
|
|
417
|
+
modelGetItemMock.mockImplementation((request: unknown) => {
|
|
418
|
+
const modelType: unknown = (request as { modelType: unknown }).modelType;
|
|
419
|
+
return Promise.resolve(modelType === CephCluster ? cephCluster : cluster);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
modelGetListMock.mockImplementation((request: unknown) => {
|
|
423
|
+
const modelType: unknown = (request as { modelType: unknown }).modelType;
|
|
424
|
+
return Promise.resolve({
|
|
425
|
+
data: modelType === ProxmoxResource ? inventory : [],
|
|
426
|
+
count: modelType === ProxmoxResource ? inventory.length : 0,
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
let pending: Array<() => void> = [];
|
|
431
|
+
|
|
432
|
+
releaseAggregates = (): void => {
|
|
433
|
+
const waiting: Array<() => void> = pending;
|
|
434
|
+
pending = [];
|
|
435
|
+
holdAggregates = false;
|
|
436
|
+
waiting.forEach((resume: () => void) => {
|
|
437
|
+
resume();
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
analyticsAggregateMock.mockImplementation((request: unknown) => {
|
|
442
|
+
const name: string = (
|
|
443
|
+
request as { aggregateBy: { query: { name: string } } }
|
|
444
|
+
).aggregateBy.query.name;
|
|
445
|
+
const answer: () => { data: Array<Point> } = () => {
|
|
446
|
+
return { data: series[name] || [] };
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
if (holdAggregates) {
|
|
450
|
+
return new Promise((resolve: (value: unknown) => void) => {
|
|
451
|
+
pending.push(() => {
|
|
452
|
+
resolve(answer());
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return Promise.resolve(answer());
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ----------------------------------------------------------------- helpers
|
|
462
|
+
|
|
463
|
+
async function flush(): Promise<void> {
|
|
464
|
+
for (let i: number = 0; i < 10; i++) {
|
|
465
|
+
await act(async () => {
|
|
466
|
+
await Promise.resolve();
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function renderOverview(): Promise<void> {
|
|
472
|
+
render(<ProxmoxClusterOverview {...PAGE_PROPS} />);
|
|
473
|
+
await screen.findByText("pve-prod");
|
|
474
|
+
await flush();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function infoButtons(title: string): Array<HTMLElement> {
|
|
478
|
+
return screen.queryAllByRole("button", { name: `About ${title}` });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function infoButton(title: string, index: number = 0): HTMLElement {
|
|
482
|
+
const found: Array<HTMLElement> = infoButtons(title);
|
|
483
|
+
expect(found.length).toBeGreaterThan(index);
|
|
484
|
+
return found[index]!;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function allInfoLabels(): Array<string> {
|
|
488
|
+
return screen
|
|
489
|
+
.queryAllByRole("button", { name: /^About / })
|
|
490
|
+
.map((button: HTMLElement) => {
|
|
491
|
+
return (button.getAttribute("aria-label") || "").replace("About ", "");
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function tooltipTextOf(button: HTMLElement): Promise<string> {
|
|
496
|
+
fireEvent.mouseEnter(button);
|
|
497
|
+
await act(async () => {
|
|
498
|
+
jest.advanceTimersByTime(200);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const describedBy: string | null = button.getAttribute("aria-describedby");
|
|
502
|
+
expect(describedBy).toBeTruthy();
|
|
503
|
+
|
|
504
|
+
const text: string =
|
|
505
|
+
document.getElementById(describedBy as string)?.textContent || "";
|
|
506
|
+
|
|
507
|
+
fireEvent.mouseLeave(button);
|
|
508
|
+
await act(async () => {
|
|
509
|
+
jest.advanceTimersByTime(400);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
return text;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function cardOf(button: HTMLElement): HTMLElement {
|
|
516
|
+
return button.closest("div.rounded-xl") as HTMLElement;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
type ChartSeries = Array<{ name: string; points: Array<[string, number]> }>;
|
|
520
|
+
|
|
521
|
+
function chartSeriesOf(button: HTMLElement): ChartSeries {
|
|
522
|
+
const chart: HTMLElement | null = cardOf(button).querySelector(
|
|
523
|
+
"[data-testid='line-chart']",
|
|
524
|
+
);
|
|
525
|
+
expect(chart).not.toBeNull();
|
|
526
|
+
return JSON.parse(chart!.getAttribute("data-series") || "[]") as ChartSeries;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
beforeEach(() => {
|
|
530
|
+
jest.useFakeTimers();
|
|
531
|
+
jest.setSystemTime(NOW);
|
|
532
|
+
for (const mock of [
|
|
533
|
+
modelGetItemMock,
|
|
534
|
+
modelGetListMock,
|
|
535
|
+
analyticsAggregateMock,
|
|
536
|
+
navigateMock,
|
|
537
|
+
]) {
|
|
538
|
+
mock.mockReset();
|
|
539
|
+
}
|
|
540
|
+
arrange();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
afterEach(() => {
|
|
544
|
+
cleanup();
|
|
545
|
+
jest.useRealTimers();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
/*
|
|
549
|
+
* Every (i) on the overview, in page order. A title that appears twice
|
|
550
|
+
* (the CPU tile and the CPU chart) is matched by position.
|
|
551
|
+
*/
|
|
552
|
+
const OVERVIEW_INFO: Array<[string, ProxmoxMetric]> = [
|
|
553
|
+
["Cluster inventory counts", "clusterInventoryCounts"],
|
|
554
|
+
["High-availability states", "haStates"],
|
|
555
|
+
["Node Availability", "nodeAvailability"],
|
|
556
|
+
["CPU", "clusterCpu"],
|
|
557
|
+
["Memory", "clusterMemory"],
|
|
558
|
+
["Storage", "fullestStorage"],
|
|
559
|
+
["Guests", "guestsRunning"],
|
|
560
|
+
["Backup Coverage", "backupCoverage"],
|
|
561
|
+
["CPU", "cpuChart"],
|
|
562
|
+
["Memory", "memoryChart"],
|
|
563
|
+
["Storage", "storageChart"],
|
|
564
|
+
["Network", "networkChart"],
|
|
565
|
+
["Last Sync", "replicationLastSync"],
|
|
566
|
+
["Duration", "replicationDuration"],
|
|
567
|
+
["Failed Syncs", "replicationFailedSyncs"],
|
|
568
|
+
["Capacity used", "cephCapacityUsed"],
|
|
569
|
+
["Cluster Health", "clusterHealth"],
|
|
570
|
+
["Quorum", "quorum"],
|
|
571
|
+
["Nodes", "nodeCount"],
|
|
572
|
+
["Guests", "guestCount"],
|
|
573
|
+
["Storage", "storageCount"],
|
|
574
|
+
["Agent Status", "agentStatus"],
|
|
575
|
+
["CPU Usage", "topGuestsByCpu"],
|
|
576
|
+
["Memory Usage", "topGuestsByMemory"],
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
// Position of each entry among the (i)s that share its title.
|
|
580
|
+
const OVERVIEW_INFO_WITH_INDEX: Array<[string, ProxmoxMetric, number]> =
|
|
581
|
+
OVERVIEW_INFO.map(
|
|
582
|
+
(
|
|
583
|
+
[title, key]: [string, ProxmoxMetric],
|
|
584
|
+
position: number,
|
|
585
|
+
): [string, ProxmoxMetric, number] => {
|
|
586
|
+
const index: number = OVERVIEW_INFO.slice(0, position).filter(
|
|
587
|
+
([earlier]: [string, ProxmoxMetric]) => {
|
|
588
|
+
return earlier === title;
|
|
589
|
+
},
|
|
590
|
+
).length;
|
|
591
|
+
return [title, key, index];
|
|
592
|
+
},
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// ------------------------------------------------------------------- tests
|
|
596
|
+
|
|
597
|
+
describe("Proxmox cluster overview: every metric has an (i)", () => {
|
|
598
|
+
test("the page shows exactly these (i)s, in this order", async () => {
|
|
599
|
+
await renderOverview();
|
|
600
|
+
|
|
601
|
+
expect(allInfoLabels()).toEqual(
|
|
602
|
+
OVERVIEW_INFO.map(([title]: [string, ProxmoxMetric]) => {
|
|
603
|
+
return title;
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test.each(OVERVIEW_INFO_WITH_INDEX)(
|
|
609
|
+
"%s explains itself with %s",
|
|
610
|
+
async (title: string, key: ProxmoxMetric, index: number) => {
|
|
611
|
+
await renderOverview();
|
|
612
|
+
|
|
613
|
+
expect(await tooltipTextOf(infoButton(title, index))).toBe(
|
|
614
|
+
PROXMOX_METRIC_DESCRIPTIONS[key],
|
|
615
|
+
);
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
test("no (i) sits inside a link, a button or a card that acts as one", async () => {
|
|
620
|
+
await renderOverview();
|
|
621
|
+
|
|
622
|
+
const buttons: Array<HTMLElement> = screen.queryAllByRole("button", {
|
|
623
|
+
name: /^About /,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
expect(buttons.length).toBe(OVERVIEW_INFO.length);
|
|
627
|
+
|
|
628
|
+
for (const button of buttons) {
|
|
629
|
+
expect(button.tagName).toBe("BUTTON");
|
|
630
|
+
expect(
|
|
631
|
+
button.parentElement?.closest("a, button, [role='button']"),
|
|
632
|
+
).toBeNull();
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("the metadata in the hero and the details card gets no (i)", async () => {
|
|
637
|
+
await renderOverview();
|
|
638
|
+
|
|
639
|
+
for (const title of [
|
|
640
|
+
"Last seen",
|
|
641
|
+
"Cluster Details",
|
|
642
|
+
"Resources",
|
|
643
|
+
"Nodes online",
|
|
644
|
+
]) {
|
|
645
|
+
expect(infoButtons(title)).toHaveLength(0);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
describe("Proxmox cluster overview: the hero's count chips", () => {
|
|
651
|
+
test("the count chips carry one (i), after the chips it explains", async () => {
|
|
652
|
+
await renderOverview();
|
|
653
|
+
|
|
654
|
+
const button: HTMLElement = infoButton("Cluster inventory counts");
|
|
655
|
+
const row: HTMLElement = button.parentElement?.closest(
|
|
656
|
+
"div.flex-wrap",
|
|
657
|
+
) as HTMLElement;
|
|
658
|
+
|
|
659
|
+
expect(row).toHaveTextContent("2/3 nodes online");
|
|
660
|
+
expect(row).toHaveTextContent("3/3 guests running");
|
|
661
|
+
/*
|
|
662
|
+
* local plus one NFS share that two nodes report: 3, not 2 - shared
|
|
663
|
+
* storage is counted once per node, as the (i) says.
|
|
664
|
+
*/
|
|
665
|
+
expect(row).toHaveTextContent("3 storage volumes");
|
|
666
|
+
// The chips it explains come first; the (i) closes the row.
|
|
667
|
+
expect(row.lastElementChild?.contains(button)).toBe(true);
|
|
668
|
+
expect(await tooltipTextOf(button)).toBe(
|
|
669
|
+
PROXMOX_METRIC_DESCRIPTIONS.clusterInventoryCounts,
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("a hero with only version chips gets no counts (i)", async () => {
|
|
674
|
+
inventory = [];
|
|
675
|
+
cluster["nodeCount"] = 0;
|
|
676
|
+
cluster["onlineNodeCount"] = 0;
|
|
677
|
+
cluster["guestCount"] = 0;
|
|
678
|
+
cluster["storageCount"] = 0;
|
|
679
|
+
|
|
680
|
+
await renderOverview();
|
|
681
|
+
|
|
682
|
+
expect(screen.getByText("PVE 8.2.4")).toBeInTheDocument();
|
|
683
|
+
expect(infoButtons("Cluster inventory counts")).toHaveLength(0);
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
describe("Proxmox cluster overview: linked summary cards", () => {
|
|
688
|
+
test.each([
|
|
689
|
+
["Nodes", "Open the node list", "/nodes"],
|
|
690
|
+
["Guests", "Open the guest list", "/guests"],
|
|
691
|
+
["Storage", "Open the storage list", "/storage"],
|
|
692
|
+
])(
|
|
693
|
+
"the %s card still opens its list through a real link",
|
|
694
|
+
async (title: string, linkName: string, suffix: string) => {
|
|
695
|
+
await renderOverview();
|
|
696
|
+
|
|
697
|
+
const link: HTMLElement = screen.getByRole("link", { name: linkName });
|
|
698
|
+
|
|
699
|
+
expect(link.getAttribute("href")).toBe(
|
|
700
|
+
`/dashboard/10000000-0000-4000-8000-000000000001/proxmox/${CLUSTER_ID}${suffix}`,
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
fireEvent.click(link);
|
|
704
|
+
|
|
705
|
+
expect(navigateMock).toHaveBeenCalledTimes(1);
|
|
706
|
+
expect(String(navigateMock.mock.calls[0]![0])).toBe(
|
|
707
|
+
link.getAttribute("href"),
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
/*
|
|
711
|
+
* The card's own (i) sits beside the link, not inside it, and asking
|
|
712
|
+
* what the number means does not navigate.
|
|
713
|
+
*/
|
|
714
|
+
const summaryInfo: HTMLElement = infoButtons(title).slice(-1)[0]!;
|
|
715
|
+
expect(link.contains(summaryInfo)).toBe(false);
|
|
716
|
+
expect(link.parentElement?.contains(summaryInfo)).toBe(true);
|
|
717
|
+
|
|
718
|
+
navigateMock.mockClear();
|
|
719
|
+
fireEvent.click(summaryInfo);
|
|
720
|
+
fireEvent.keyDown(summaryInfo, { key: "Enter" });
|
|
721
|
+
expect(navigateMock).not.toHaveBeenCalled();
|
|
722
|
+
},
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
test("the linked cards show their counts and the offline call-out", async () => {
|
|
726
|
+
await renderOverview();
|
|
727
|
+
|
|
728
|
+
const nodesCard: HTMLElement = cardOf(infoButtons("Nodes")[0]!);
|
|
729
|
+
|
|
730
|
+
expect(nodesCard).toHaveTextContent("3");
|
|
731
|
+
expect(nodesCard).toHaveTextContent("(1 offline)");
|
|
732
|
+
expect(cardOf(infoButtons("Guests")[1]!)).toHaveTextContent("3");
|
|
733
|
+
expect(cardOf(infoButtons("Storage")[2]!)).toHaveTextContent("3");
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("no summary card is a role=button any more", async () => {
|
|
737
|
+
await renderOverview();
|
|
738
|
+
|
|
739
|
+
for (const title of [
|
|
740
|
+
"Cluster Health",
|
|
741
|
+
"Quorum",
|
|
742
|
+
"Nodes",
|
|
743
|
+
"Guests",
|
|
744
|
+
"Storage",
|
|
745
|
+
"Agent Status",
|
|
746
|
+
]) {
|
|
747
|
+
const card: HTMLElement = cardOf(infoButtons(title).slice(-1)[0]!);
|
|
748
|
+
expect(card).not.toHaveAttribute("role");
|
|
749
|
+
expect(card).not.toHaveAttribute("tabindex");
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("the Quorum value carries no second, native tooltip", async () => {
|
|
754
|
+
await renderOverview();
|
|
755
|
+
|
|
756
|
+
const card: HTMLElement = cardOf(infoButton("Quorum"));
|
|
757
|
+
const value: HTMLElement = within(card).getByText("2/3");
|
|
758
|
+
|
|
759
|
+
expect(value).not.toHaveAttribute("title");
|
|
760
|
+
expect(card.querySelector("[title]")).toBeNull();
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
describe("Proxmox cluster overview: chart cards before and after loading", () => {
|
|
765
|
+
test("the chart skeletons already carry their (i)", async () => {
|
|
766
|
+
holdAggregates = true;
|
|
767
|
+
render(<ProxmoxClusterOverview {...PAGE_PROPS} />);
|
|
768
|
+
await screen.findByText("pve-prod");
|
|
769
|
+
await flush();
|
|
770
|
+
|
|
771
|
+
// Tiles are still grey blocks; the four chart cards show their titles.
|
|
772
|
+
expect(screen.queryAllByTestId("line-chart")).toHaveLength(0);
|
|
773
|
+
expect(infoButtons("CPU")).toHaveLength(1);
|
|
774
|
+
expect(await tooltipTextOf(infoButton("CPU"))).toBe(
|
|
775
|
+
PROXMOX_METRIC_DESCRIPTIONS.cpuChart,
|
|
776
|
+
);
|
|
777
|
+
expect(await tooltipTextOf(infoButton("Network"))).toBe(
|
|
778
|
+
PROXMOX_METRIC_DESCRIPTIONS.networkChart,
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
await act(async () => {
|
|
782
|
+
releaseAggregates();
|
|
783
|
+
});
|
|
784
|
+
await flush();
|
|
785
|
+
|
|
786
|
+
expect(screen.queryAllByTestId("line-chart")).toHaveLength(4);
|
|
787
|
+
expect(infoButtons("CPU")).toHaveLength(2);
|
|
788
|
+
expect(await tooltipTextOf(infoButton("CPU", 1))).toBe(
|
|
789
|
+
PROXMOX_METRIC_DESCRIPTIONS.cpuChart,
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
describe("Proxmox cluster overview: the descriptions match what the page computes", () => {
|
|
795
|
+
test("the CPU tile averages the last 5 minutes, weighted by cores", async () => {
|
|
796
|
+
await renderOverview();
|
|
797
|
+
|
|
798
|
+
// 40% weighted; a plain mean would be 30%, the whole range 65%.
|
|
799
|
+
const tile: HTMLElement = cardOf(infoButton("CPU", 0));
|
|
800
|
+
expect(tile).toHaveTextContent("40.0%");
|
|
801
|
+
expect(tile).not.toHaveTextContent("30.0%");
|
|
802
|
+
expect(tile).not.toHaveTextContent("65.0%");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("with no data in the last 5 minutes the tile falls back to the whole range", async () => {
|
|
806
|
+
series["pve_cpu_usage_ratio"] = [
|
|
807
|
+
point(20, "node/pve1", 0.9),
|
|
808
|
+
point(10, "node/pve1", 0.3),
|
|
809
|
+
];
|
|
810
|
+
series["pve_cpu_usage_limit"] = [
|
|
811
|
+
point(20, "node/pve1", 4),
|
|
812
|
+
point(10, "node/pve1", 4),
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
await renderOverview();
|
|
816
|
+
|
|
817
|
+
expect(cardOf(infoButton("CPU", 0))).toHaveTextContent("60.0%");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("the CPU chart covers the whole range, not just the tile window", async () => {
|
|
821
|
+
await renderOverview();
|
|
822
|
+
|
|
823
|
+
const cpu: ChartSeries = chartSeriesOf(infoButton("CPU", 1));
|
|
824
|
+
expect(
|
|
825
|
+
cpu[0]!.points.map((p: [string, number]) => {
|
|
826
|
+
return p[1];
|
|
827
|
+
}),
|
|
828
|
+
).toEqual([90, 40]);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("the Memory tile is a percentage of combined memory, the chart is bytes", async () => {
|
|
832
|
+
await renderOverview();
|
|
833
|
+
|
|
834
|
+
const tile: HTMLElement = cardOf(infoButton("Memory", 0));
|
|
835
|
+
expect(tile).toHaveTextContent("50.0%");
|
|
836
|
+
// The sub-line is the latest point, not the average.
|
|
837
|
+
expect(tile).toHaveTextContent("8.0 GiB of 16.0 GiB");
|
|
838
|
+
|
|
839
|
+
const memory: ChartSeries = chartSeriesOf(infoButton("Memory", 1));
|
|
840
|
+
expect(
|
|
841
|
+
memory[0]!.points.map((p: [string, number]) => {
|
|
842
|
+
return p[1];
|
|
843
|
+
}),
|
|
844
|
+
).toEqual([16 * GIB, 8 * GIB]);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("the Storage chart counts shared storage once per node and skips node disks", async () => {
|
|
848
|
+
await renderOverview();
|
|
849
|
+
|
|
850
|
+
const storage: ChartSeries = chartSeriesOf(infoButton("Storage", 1));
|
|
851
|
+
// 90 (local) + 100 + 100 (the same NFS share, twice) = 290 GiB.
|
|
852
|
+
expect(
|
|
853
|
+
storage[0]!.points.map((p: [string, number]) => {
|
|
854
|
+
return p[1];
|
|
855
|
+
}),
|
|
856
|
+
).toEqual([290 * GIB]);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("the Storage tile is the single fullest volume, not an average", async () => {
|
|
860
|
+
await renderOverview();
|
|
861
|
+
|
|
862
|
+
const tile: HTMLElement = cardOf(infoButton("Storage", 0));
|
|
863
|
+
expect(tile).toHaveTextContent("90.0%");
|
|
864
|
+
expect(tile).toHaveTextContent("fullest: local");
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test("the Network chart and the Guests tile's net figure are per-second rates", async () => {
|
|
868
|
+
await renderOverview();
|
|
869
|
+
|
|
870
|
+
const network: ChartSeries = chartSeriesOf(infoButton("Network"));
|
|
871
|
+
expect(
|
|
872
|
+
network.map((s: { name: string; points: Array<[string, number]> }) => {
|
|
873
|
+
return [
|
|
874
|
+
s.name,
|
|
875
|
+
s.points.map((p: [string, number]) => {
|
|
876
|
+
return p[1];
|
|
877
|
+
}),
|
|
878
|
+
];
|
|
879
|
+
}),
|
|
880
|
+
).toEqual([
|
|
881
|
+
["In", [1000]],
|
|
882
|
+
["Out", [2000]],
|
|
883
|
+
]);
|
|
884
|
+
|
|
885
|
+
const guests: HTMLElement = cardOf(infoButton("Guests", 0));
|
|
886
|
+
expect(guests).toHaveTextContent("3/3");
|
|
887
|
+
expect(guests).toHaveTextContent(
|
|
888
|
+
`running · net ${ValueFormatter.formatValue(3000, "By/s")}`,
|
|
889
|
+
);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("backup coverage counts job coverage per guest", async () => {
|
|
893
|
+
await renderOverview();
|
|
894
|
+
|
|
895
|
+
const tile: HTMLElement = cardOf(infoButton("Backup Coverage"));
|
|
896
|
+
expect(tile).toHaveTextContent("2/3");
|
|
897
|
+
expect(tile).toHaveTextContent("1 guest not in any backup job");
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("top consumers leave out a guest with no update in 15 minutes", async () => {
|
|
901
|
+
await renderOverview();
|
|
902
|
+
|
|
903
|
+
const cpuSection: HTMLElement = infoButton("CPU Usage").closest(
|
|
904
|
+
"div.p-5",
|
|
905
|
+
) as HTMLElement;
|
|
906
|
+
expect(cpuSection).toHaveTextContent("web");
|
|
907
|
+
expect(cpuSection).toHaveTextContent("75.0%");
|
|
908
|
+
expect(cpuSection).not.toHaveTextContent("stale-guest");
|
|
909
|
+
|
|
910
|
+
const memorySection: HTMLElement = infoButton("Memory Usage").closest(
|
|
911
|
+
"div.p-5",
|
|
912
|
+
) as HTMLElement;
|
|
913
|
+
expect(memorySection).toHaveTextContent("3.0 GiB");
|
|
914
|
+
expect(memorySection).not.toHaveTextContent("stale-guest");
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
test("quorum and node availability count online nodes out of all nodes", async () => {
|
|
918
|
+
await renderOverview();
|
|
919
|
+
|
|
920
|
+
expect(cardOf(infoButton("Quorum"))).toHaveTextContent("2/3");
|
|
921
|
+
expect(cardOf(infoButton("Node Availability"))).toHaveTextContent("2/3");
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test("an offline node makes the cluster Unhealthy", async () => {
|
|
925
|
+
await renderOverview();
|
|
926
|
+
|
|
927
|
+
expect(cardOf(infoButton("Cluster Health"))).toHaveTextContent("Unhealthy");
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("with every node online, a failing replication job alone makes it Degraded", async () => {
|
|
931
|
+
inventory = inventory
|
|
932
|
+
.filter((row: Row) => {
|
|
933
|
+
return row["externalId"] !== "node/pve3";
|
|
934
|
+
})
|
|
935
|
+
.map((row: Row) => {
|
|
936
|
+
return {
|
|
937
|
+
...row,
|
|
938
|
+
isBackedUp: row["kind"] === "Guest" ? true : undefined,
|
|
939
|
+
};
|
|
940
|
+
})
|
|
941
|
+
.map((row: Row) => {
|
|
942
|
+
// Keep every storage volume under 85%.
|
|
943
|
+
return row["externalId"] === "storage/pve1/local"
|
|
944
|
+
? { ...row, latestDiskBytes: 50 * GIB }
|
|
945
|
+
: row;
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
await renderOverview();
|
|
949
|
+
|
|
950
|
+
expect(cardOf(infoButton("Cluster Health"))).toHaveTextContent("Degraded");
|
|
951
|
+
expect(
|
|
952
|
+
screen.getByText("Why is this cluster degraded?"),
|
|
953
|
+
).toBeInTheDocument();
|
|
954
|
+
expect(screen.getByText("2 failed syncs")).toBeInTheDocument();
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("a disconnected agent turns Cluster Health into Unknown", async () => {
|
|
958
|
+
cluster["otelCollectorStatus"] = "disconnected";
|
|
959
|
+
|
|
960
|
+
await renderOverview();
|
|
961
|
+
|
|
962
|
+
expect(cardOf(infoButton("Cluster Health"))).toHaveTextContent("Unknown");
|
|
963
|
+
expect(cardOf(infoButton("Agent Status"))).toHaveTextContent(
|
|
964
|
+
"Disconnected",
|
|
965
|
+
);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("replication shows the sync age, duration and failures the columns explain", async () => {
|
|
969
|
+
await renderOverview();
|
|
970
|
+
|
|
971
|
+
const table: HTMLElement = infoButton("Last Sync").closest(
|
|
972
|
+
"table",
|
|
973
|
+
) as HTMLElement;
|
|
974
|
+
const row: HTMLElement = within(table)
|
|
975
|
+
.getByText("100-0")
|
|
976
|
+
.closest("tr") as HTMLElement;
|
|
977
|
+
|
|
978
|
+
// Two hours without a sync is past the 1-hour amber step.
|
|
979
|
+
const age: HTMLElement = within(row).getByText("2h ago");
|
|
980
|
+
expect(age.className).toContain("text-amber-700");
|
|
981
|
+
expect(row).toHaveTextContent("42s");
|
|
982
|
+
expect(within(row).getByText("2").className).toContain("text-red-700");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("the Ceph card shows the linked cluster's capacity snapshot", async () => {
|
|
986
|
+
await renderOverview();
|
|
987
|
+
|
|
988
|
+
const capacity: HTMLElement = infoButton("Capacity used").closest(
|
|
989
|
+
"div.flex-1",
|
|
990
|
+
) as HTMLElement;
|
|
991
|
+
expect(capacity).toHaveTextContent("81.5%");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("the HA chips count each state, and their (i) explains them", async () => {
|
|
995
|
+
await renderOverview();
|
|
996
|
+
|
|
997
|
+
const row: HTMLElement = infoButton("High-availability states")
|
|
998
|
+
.parentElement as HTMLElement;
|
|
999
|
+
expect(row).toHaveTextContent("2HA online");
|
|
1000
|
+
expect(row).toHaveTextContent("1HA started");
|
|
1001
|
+
});
|
|
1002
|
+
});
|