@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.
Files changed (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -0,0 +1,923 @@
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 node, guest and storage pages - their
23
+ * detail pages (summary fields and the throughput chart headers), their
24
+ * lists (column headers) and the Insights disk-throughput chart.
25
+ *
26
+ * Each page is rendered for real with the network, the charts and the
27
+ * metric-view tab replaced. Every metric field or column is checked for an
28
+ * (i) whose tooltip is its PROXMOX_METRIC_DESCRIPTIONS entry, metadata
29
+ * (names, IDs, "Last Seen") for having none, and the claims the texts make
30
+ * ("N/A after 15 minutes", "the last value the agent sent", "uptime",
31
+ * "a straight-line estimate over 24 hours") against what the page shows.
32
+ */
33
+
34
+ const CLUSTER_ID: string = "0193c0de-8888-4aaa-8bbb-000000000008";
35
+ const NOW: Date = new Date("2026-09-24T12:00:00.000Z");
36
+ const MINUTE: number = 60 * 1000;
37
+ const HOUR: number = 60 * MINUTE;
38
+ const GIB: number = 1024 * 1024 * 1024;
39
+
40
+ const modelGetItemMock: MockFunction = getJestMockFunction();
41
+ const modelGetListMock: MockFunction = getJestMockFunction();
42
+ const analyticsAggregateMock: MockFunction = getJestMockFunction();
43
+ const rateChartMock: MockFunction = getJestMockFunction();
44
+
45
+ let mockLastParam: string = "node%2Fpve1";
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-8888-4aaa-8bbb-000000000008");
93
+ },
94
+ getLastParamAsString: (): string => {
95
+ return mockLastParam;
96
+ },
97
+ navigate: (): void => {},
98
+ isOnThisPage: (): boolean => {
99
+ return false;
100
+ },
101
+ },
102
+ };
103
+ });
104
+
105
+ jest.mock("../../../UI/Utils/Project", () => {
106
+ return {
107
+ __esModule: true,
108
+ default: {
109
+ getCurrentProjectId: (): unknown => {
110
+ const { default: ObjectIDType } = jest.requireActual(
111
+ "../../../Types/ObjectID",
112
+ ) as { default: new (id: string) => unknown };
113
+ return new ObjectIDType("10000000-0000-4000-8000-000000000001");
114
+ },
115
+ },
116
+ };
117
+ });
118
+
119
+ /*
120
+ * The metric-view tab draws ChartGroup charts, which carry their own visible
121
+ * descriptions; only the extra rate charts below them are this suite's
122
+ * business, so the tab just renders those.
123
+ */
124
+ jest.mock(
125
+ "../../../../App/FeatureSet/Dashboard/src/Components/Infrastructure/ResourceMetricsTab",
126
+ () => {
127
+ return {
128
+ __esModule: true,
129
+ default: (props: {
130
+ renderExtraCharts?: (range: {
131
+ startValue: Date;
132
+ endValue: Date;
133
+ }) => React.ReactNode;
134
+ }) => {
135
+ return (
136
+ <div data-testid="resource-metrics-tab">
137
+ {props.renderExtraCharts
138
+ ? props.renderExtraCharts({
139
+ startValue: new Date("2026-09-24T11:00:00.000Z"),
140
+ endValue: new Date("2026-09-24T12:00:00.000Z"),
141
+ })
142
+ : null}
143
+ </div>
144
+ );
145
+ },
146
+ };
147
+ },
148
+ );
149
+
150
+ jest.mock(
151
+ "../../../../App/FeatureSet/Dashboard/src/Components/Proxmox/ProxmoxRateChart",
152
+ () => {
153
+ return {
154
+ __esModule: true,
155
+ default: (props: { series: Array<{ metricName: string }> }) => {
156
+ rateChartMock(props);
157
+ return (
158
+ <div data-testid="proxmox-rate-chart">
159
+ {props.series
160
+ .map((s: { metricName: string }) => {
161
+ return s.metricName;
162
+ })
163
+ .join(",")}
164
+ </div>
165
+ );
166
+ },
167
+ };
168
+ },
169
+ );
170
+
171
+ jest.mock(
172
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/EmbeddedMetricCard",
173
+ () => {
174
+ return {
175
+ __esModule: true,
176
+ default: (props: {
177
+ title?: React.ReactNode;
178
+ description?: string;
179
+ children?: React.ReactNode;
180
+ renderExtraCharts?: (range: {
181
+ startValue: Date;
182
+ endValue: Date;
183
+ }) => React.ReactNode;
184
+ }) => {
185
+ return (
186
+ <section data-testid="embedded-metric-card">
187
+ <div>{props.title}</div>
188
+ <p>{props.description}</p>
189
+ {props.children}
190
+ {props.renderExtraCharts
191
+ ? props.renderExtraCharts({
192
+ startValue: new Date("2026-09-24T11:00:00.000Z"),
193
+ endValue: new Date("2026-09-24T12:00:00.000Z"),
194
+ })
195
+ : null}
196
+ </section>
197
+ );
198
+ },
199
+ };
200
+ },
201
+ );
202
+
203
+ import ProxmoxClusterNodeDetail from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/NodeDetail";
204
+ import ProxmoxClusterGuestDetail from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/GuestDetail";
205
+ import ProxmoxClusterStorageDetail from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/StorageDetail";
206
+ import ProxmoxClusterNodes from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/Nodes";
207
+ import ProxmoxClusterGuests from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/Guests";
208
+ import ProxmoxClusterStorage from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/Storage";
209
+ import ProxmoxClusterInsights from "../../../../App/FeatureSet/Dashboard/src/Pages/Proxmox/View/Insights";
210
+ import {
211
+ PROXMOX_METRIC_DESCRIPTIONS,
212
+ ProxmoxMetric,
213
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/ProxmoxMetricDescriptions";
214
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
215
+ import ProxmoxResource from "../../../Models/DatabaseModels/ProxmoxResource";
216
+
217
+ const PAGE_PROPS: PageComponentProps = {} as PageComponentProps;
218
+
219
+ // ---------------------------------------------------------------- fixtures
220
+
221
+ function minutesAgo(minutes: number): Date {
222
+ return new Date(NOW.getTime() - minutes * MINUTE);
223
+ }
224
+
225
+ type Row = Record<string, unknown>;
226
+
227
+ let inventory: Array<Row>;
228
+ let diskSamples: Array<{ timestamp: Date; value: number }>;
229
+
230
+ function arrange(): void {
231
+ inventory = [
232
+ {
233
+ kind: "Node",
234
+ externalId: "node/pve1",
235
+ name: "pve1",
236
+ isUp: true,
237
+ haState: "online",
238
+ uptimeSeconds: 26 * 3600,
239
+ latestCpuPercent: 10,
240
+ latestMemoryBytes: 4 * GIB,
241
+ maxMemoryBytes: 16 * GIB,
242
+ metricsUpdatedAt: minutesAgo(1),
243
+ lastSeenAt: minutesAgo(1),
244
+ },
245
+ {
246
+ // Has not reported for 20 minutes: the list hides its numbers.
247
+ kind: "Node",
248
+ externalId: "node/pve2",
249
+ name: "pve2",
250
+ isUp: true,
251
+ uptimeSeconds: 3 * 3600,
252
+ latestCpuPercent: 42,
253
+ latestMemoryBytes: 8 * GIB,
254
+ maxMemoryBytes: 16 * GIB,
255
+ metricsUpdatedAt: minutesAgo(20),
256
+ lastSeenAt: minutesAgo(20),
257
+ },
258
+ {
259
+ kind: "Guest",
260
+ externalId: "qemu/100",
261
+ name: "web",
262
+ vmid: 100,
263
+ guestType: "qemu",
264
+ parentNodeName: "pve1",
265
+ isUp: true,
266
+ haState: "started",
267
+ onboot: true,
268
+ isBackedUp: true,
269
+ uptimeSeconds: 90 * 60,
270
+ latestCpuPercent: 75,
271
+ latestMemoryBytes: 3 * GIB,
272
+ maxMemoryBytes: 4 * GIB,
273
+ maxDiskBytes: 32 * GIB,
274
+ metricsUpdatedAt: minutesAgo(1),
275
+ lastSeenAt: minutesAgo(1),
276
+ },
277
+ {
278
+ kind: "Guest",
279
+ externalId: "lxc/101",
280
+ name: "db",
281
+ vmid: 101,
282
+ guestType: "lxc",
283
+ parentNodeName: "pve2",
284
+ isUp: false,
285
+ isBackedUp: false,
286
+ uptimeSeconds: 0,
287
+ latestCpuPercent: 0,
288
+ latestMemoryBytes: 0,
289
+ maxMemoryBytes: 2 * GIB,
290
+ metricsUpdatedAt: minutesAgo(1),
291
+ lastSeenAt: minutesAgo(1),
292
+ },
293
+ {
294
+ kind: "Guest",
295
+ externalId: "lxc/102",
296
+ name: "cache",
297
+ vmid: 102,
298
+ guestType: "lxc",
299
+ parentNodeName: "pve2",
300
+ isUp: true,
301
+ latestCpuPercent: 5,
302
+ latestMemoryBytes: GIB,
303
+ maxMemoryBytes: 2 * GIB,
304
+ metricsUpdatedAt: minutesAgo(1),
305
+ lastSeenAt: minutesAgo(1),
306
+ },
307
+ {
308
+ kind: "Storage",
309
+ externalId: "storage/pve1/local",
310
+ name: "local",
311
+ parentNodeName: "pve1",
312
+ isUp: true,
313
+ latestDiskBytes: 50 * GIB,
314
+ maxDiskBytes: 100 * GIB,
315
+ lastSeenAt: minutesAgo(1),
316
+ },
317
+ {
318
+ kind: "Storage",
319
+ externalId: "storage/pve2/backup",
320
+ name: "backup",
321
+ parentNodeName: "pve2",
322
+ isUp: false,
323
+ lastSeenAt: minutesAgo(1),
324
+ },
325
+ ];
326
+
327
+ // 40 -> 45 -> 50 GiB over 24 hours: 10 GiB a day, 50 GiB left = 5 days.
328
+ diskSamples = [
329
+ { timestamp: new Date(NOW.getTime() - 24 * HOUR), value: 40 * GIB },
330
+ { timestamp: new Date(NOW.getTime() - 12 * HOUR), value: 45 * GIB },
331
+ { timestamp: NOW, value: 50 * GIB },
332
+ ];
333
+
334
+ modelGetItemMock.mockImplementation(() => {
335
+ return Promise.resolve({ _id: CLUSTER_ID, name: "pve-prod" });
336
+ });
337
+
338
+ modelGetListMock.mockImplementation((request: unknown) => {
339
+ const { modelType, query } = request as {
340
+ modelType: unknown;
341
+ query: Record<string, unknown>;
342
+ };
343
+
344
+ if (modelType !== ProxmoxResource) {
345
+ return Promise.resolve({ data: [], count: 0 });
346
+ }
347
+
348
+ const rows: Array<Row> = inventory.filter((row: Row) => {
349
+ return (
350
+ (!query["kind"] || row["kind"] === query["kind"]) &&
351
+ (!query["externalId"] || row["externalId"] === query["externalId"])
352
+ );
353
+ });
354
+
355
+ return Promise.resolve({ data: rows, count: rows.length });
356
+ });
357
+
358
+ analyticsAggregateMock.mockImplementation((request: unknown) => {
359
+ const name: string = (
360
+ request as { aggregateBy: { query: { name: string } } }
361
+ ).aggregateBy.query.name;
362
+
363
+ return Promise.resolve({
364
+ data: name === "pve_disk_usage_bytes" ? diskSamples : [],
365
+ });
366
+ });
367
+ }
368
+
369
+ // ----------------------------------------------------------------- helpers
370
+
371
+ async function flush(): Promise<void> {
372
+ for (let i: number = 0; i < 10; i++) {
373
+ await act(async () => {
374
+ await Promise.resolve();
375
+ });
376
+ }
377
+ }
378
+
379
+ async function renderPage(
380
+ Page: React.FunctionComponent<PageComponentProps>,
381
+ lastParam: string,
382
+ waitFor: string,
383
+ ): Promise<void> {
384
+ mockLastParam = lastParam;
385
+ render(<Page {...PAGE_PROPS} />);
386
+ await screen.findAllByText(waitFor);
387
+ await flush();
388
+ }
389
+
390
+ function infoLabels(scope?: HTMLElement): Array<string> {
391
+ return (scope ? within(scope) : screen)
392
+ .queryAllByRole("button", { name: /^About / })
393
+ .map((button: HTMLElement) => {
394
+ return (button.getAttribute("aria-label") || "").replace("About ", "");
395
+ });
396
+ }
397
+
398
+ function infoButton(title: string): HTMLElement {
399
+ const found: Array<HTMLElement> = screen.getAllByRole("button", {
400
+ name: `About ${title}`,
401
+ });
402
+ expect(found).toHaveLength(1);
403
+ return found[0]!;
404
+ }
405
+
406
+ async function tooltipTextOf(button: HTMLElement): Promise<string> {
407
+ fireEvent.mouseEnter(button);
408
+ await act(async () => {
409
+ jest.advanceTimersByTime(200);
410
+ });
411
+
412
+ const describedBy: string | null = button.getAttribute("aria-describedby");
413
+ expect(describedBy).toBeTruthy();
414
+
415
+ const text: string =
416
+ document.getElementById(describedBy as string)?.textContent || "";
417
+
418
+ fireEvent.mouseLeave(button);
419
+ await act(async () => {
420
+ jest.advanceTimersByTime(400);
421
+ });
422
+
423
+ return text;
424
+ }
425
+
426
+ async function expectExplained(
427
+ pairs: Array<[string, ProxmoxMetric]>,
428
+ ): Promise<void> {
429
+ for (const [title, key] of pairs) {
430
+ expect({ title, text: await tooltipTextOf(infoButton(title)) }).toEqual({
431
+ title,
432
+ text: PROXMOX_METRIC_DESCRIPTIONS[key],
433
+ });
434
+ }
435
+ }
436
+
437
+ // A summary field's card, found by its title.
438
+ function fieldCard(title: string): HTMLElement {
439
+ const label: HTMLElement = screen
440
+ .getAllByText(title, { selector: "label span" })
441
+ .find((el: HTMLElement) => {
442
+ return el.textContent?.trim().startsWith(title);
443
+ }) as HTMLElement;
444
+ expect(label).toBeTruthy();
445
+ return label.closest("div.rounded-xl") as HTMLElement;
446
+ }
447
+
448
+ function expectNoNestedInfoButtons(): void {
449
+ for (const button of screen.queryAllByRole("button", {
450
+ name: /^About /,
451
+ })) {
452
+ expect(
453
+ button.parentElement?.closest("a, button, [role='button']"),
454
+ ).toBeNull();
455
+ }
456
+ }
457
+
458
+ async function openMetricsTab(): Promise<void> {
459
+ fireEvent.click(screen.getByRole("tab", { name: "Metrics" }));
460
+ await flush();
461
+ }
462
+
463
+ /*
464
+ * The (i) sits in the header immediately above its rate chart, so a
465
+ * reader can tell which chart it explains.
466
+ */
467
+ function expectHeaderAboveChart(title: string, metricName: string): void {
468
+ const header: HTMLElement = infoButton(title).parentElement as HTMLElement;
469
+ expect(header).toHaveTextContent(title);
470
+ const chart: Element | null = header.nextElementSibling;
471
+ expect(chart).not.toBeNull();
472
+ expect(chart!.getAttribute("data-testid")).toBe("proxmox-rate-chart");
473
+ expect(chart!.textContent).toContain(metricName);
474
+ }
475
+
476
+ beforeEach(() => {
477
+ jest.useFakeTimers();
478
+ jest.setSystemTime(NOW);
479
+ for (const mock of [
480
+ modelGetItemMock,
481
+ modelGetListMock,
482
+ analyticsAggregateMock,
483
+ rateChartMock,
484
+ ]) {
485
+ mock.mockReset();
486
+ }
487
+ arrange();
488
+ });
489
+
490
+ afterEach(() => {
491
+ cleanup();
492
+ jest.useRealTimers();
493
+ });
494
+
495
+ // ------------------------------------------------------------ node detail
496
+
497
+ const NODE_FIELDS: Array<[string, ProxmoxMetric]> = [
498
+ ["Status", "nodeStatus"],
499
+ ["Uptime", "nodeUptime"],
500
+ ["CPU", "nodeCpu"],
501
+ ["Memory (Used / Total)", "nodeMemory"],
502
+ ["HA State", "nodeHaState"],
503
+ ];
504
+
505
+ describe("Proxmox node detail", () => {
506
+ test("every metric field has an (i), in order, and metadata has none", async () => {
507
+ await renderPage(ProxmoxClusterNodeDetail, "node%2Fpve1", "pve1");
508
+
509
+ expect(infoLabels()).toEqual(
510
+ NODE_FIELDS.map(([title]: [string, ProxmoxMetric]) => {
511
+ return title;
512
+ }),
513
+ );
514
+ for (const title of ["Node Name", "Cluster", "External ID", "Last Seen"]) {
515
+ expect(fieldCard(title)).toBeTruthy();
516
+ expect(infoLabels(fieldCard(title))).toEqual([]);
517
+ }
518
+ });
519
+
520
+ test("each field's (i) shows its own description", async () => {
521
+ await renderPage(ProxmoxClusterNodeDetail, "node%2Fpve1", "pve1");
522
+ await expectExplained(NODE_FIELDS);
523
+ expectNoNestedInfoButtons();
524
+ });
525
+
526
+ test("the fields show the latest report: uptime, CPU share and used / total memory", async () => {
527
+ await renderPage(ProxmoxClusterNodeDetail, "node%2Fpve1", "pve1");
528
+
529
+ expect(fieldCard("Uptime")).toHaveTextContent("1d 2h");
530
+ expect(fieldCard("CPU")).toHaveTextContent("10.0%");
531
+ expect(fieldCard("Memory (Used / Total)")).toHaveTextContent(
532
+ "4.0 GiB / 16.0 GiB",
533
+ );
534
+ expect(fieldCard("HA State")).toHaveTextContent("online");
535
+ });
536
+
537
+ test("a detail page keeps showing the last value the agent sent, however old", async () => {
538
+ // pve2 last reported 20 minutes ago; the list shows N/A, this does not.
539
+ await renderPage(ProxmoxClusterNodeDetail, "node%2Fpve2", "pve2");
540
+
541
+ expect(fieldCard("CPU")).toHaveTextContent("42.0%");
542
+ expect(await tooltipTextOf(infoButton("CPU"))).toContain(
543
+ "last value the agent sent",
544
+ );
545
+ });
546
+
547
+ test("the throughput charts on the Metrics tab each carry an (i) in their header", async () => {
548
+ await renderPage(ProxmoxClusterNodeDetail, "node%2Fpve1", "pve1");
549
+ await openMetricsTab();
550
+
551
+ expect(infoLabels()).toEqual(["Network Throughput", "Disk Throughput"]);
552
+ await expectExplained([
553
+ ["Network Throughput", "nodeNetworkThroughput"],
554
+ ["Disk Throughput", "nodeDiskThroughput"],
555
+ ]);
556
+ expectHeaderAboveChart("Network Throughput", "pve_network_receive_bytes");
557
+ expectHeaderAboveChart("Disk Throughput", "pve_disk_read_bytes");
558
+ });
559
+ });
560
+
561
+ // ----------------------------------------------------------- guest detail
562
+
563
+ const GUEST_FIELDS: Array<[string, ProxmoxMetric]> = [
564
+ ["Status", "guestStatus"],
565
+ ["HA State", "guestHaState"],
566
+ ["Start on Boot", "guestStartOnBoot"],
567
+ ["Backup Job Coverage", "guestBackupCoverage"],
568
+ ["Uptime", "guestUptime"],
569
+ ["CPU", "guestCpu"],
570
+ ["Memory (Used / Max)", "guestMemory"],
571
+ ["Disk (Used / Max)", "guestDisk"],
572
+ ];
573
+
574
+ describe("Proxmox guest detail", () => {
575
+ test("every metric field has an (i), in order, and metadata has none", async () => {
576
+ await renderPage(ProxmoxClusterGuestDetail, "qemu%2F100", "web");
577
+
578
+ expect(infoLabels()).toEqual(
579
+ GUEST_FIELDS.map(([title]: [string, ProxmoxMetric]) => {
580
+ return title;
581
+ }),
582
+ );
583
+ for (const title of [
584
+ "Guest Name",
585
+ "Cluster",
586
+ "VMID",
587
+ "Type",
588
+ "Node",
589
+ "External ID",
590
+ "Last Seen",
591
+ ]) {
592
+ expect(infoLabels(fieldCard(title))).toEqual([]);
593
+ }
594
+ });
595
+
596
+ test("each field's (i) shows its own description", async () => {
597
+ await renderPage(ProxmoxClusterGuestDetail, "qemu%2F100", "web");
598
+ await expectExplained(GUEST_FIELDS);
599
+ expectNoNestedInfoButtons();
600
+ });
601
+
602
+ test("a QEMU VM with no disk figure explains why it reads N/A", async () => {
603
+ await renderPage(ProxmoxClusterGuestDetail, "qemu%2F100", "web");
604
+
605
+ expect(fieldCard("Disk (Used / Max)")).toHaveTextContent(
606
+ "N/A — install the QEMU guest agent for disk usage",
607
+ );
608
+ expect(await tooltipTextOf(infoButton("Disk (Used / Max)"))).toContain(
609
+ "QEMU guest agent",
610
+ );
611
+ });
612
+
613
+ test("a measured disk figure carries the same (i)", async () => {
614
+ inventory = inventory.map((row: Row) => {
615
+ return row["externalId"] === "qemu/100"
616
+ ? { ...row, latestDiskBytes: 8 * GIB }
617
+ : row;
618
+ });
619
+
620
+ await renderPage(ProxmoxClusterGuestDetail, "qemu%2F100", "web");
621
+
622
+ expect(fieldCard("Disk (Used / Max)")).toHaveTextContent(
623
+ "8.0 GiB / 32.0 GiB",
624
+ );
625
+ expect(await tooltipTextOf(infoButton("Disk (Used / Max)"))).toBe(
626
+ PROXMOX_METRIC_DESCRIPTIONS.guestDisk,
627
+ );
628
+ });
629
+
630
+ test("CPU and memory are shares of what the guest was assigned", async () => {
631
+ await renderPage(ProxmoxClusterGuestDetail, "qemu%2F100", "web");
632
+
633
+ expect(fieldCard("CPU")).toHaveTextContent("75.0%");
634
+ expect(fieldCard("Memory (Used / Max)")).toHaveTextContent(
635
+ "3.0 GiB / 4.0 GiB",
636
+ );
637
+ expect(fieldCard("Backup Job Coverage")).toHaveTextContent(
638
+ "In a backup job",
639
+ );
640
+ });
641
+
642
+ test("the throughput charts on the Metrics tab each carry an (i) in their header", async () => {
643
+ await renderPage(ProxmoxClusterGuestDetail, "qemu%2F100", "web");
644
+ await openMetricsTab();
645
+
646
+ expect(infoLabels()).toEqual(["Network Throughput", "Disk Throughput"]);
647
+ await expectExplained([
648
+ ["Network Throughput", "guestNetworkThroughput"],
649
+ ["Disk Throughput", "guestDiskThroughput"],
650
+ ]);
651
+ expectHeaderAboveChart("Network Throughput", "pve_network_transmit_bytes");
652
+ expectHeaderAboveChart("Disk Throughput", "pve_disk_write_bytes");
653
+ });
654
+ });
655
+
656
+ // --------------------------------------------------------- storage detail
657
+
658
+ const STORAGE_FIELDS: Array<[string, ProxmoxMetric]> = [
659
+ ["Status", "storageStatus"],
660
+ ["Used", "storageUsed"],
661
+ ["Total", "storageTotal"],
662
+ ["Used %", "storageUsedPercent"],
663
+ ["Growth Forecast", "storageGrowthForecast"],
664
+ ];
665
+
666
+ describe("Proxmox storage detail", () => {
667
+ test("every metric field has an (i), in order, and metadata has none", async () => {
668
+ await renderPage(
669
+ ProxmoxClusterStorageDetail,
670
+ "storage%2Fpve1%2Flocal",
671
+ "local",
672
+ );
673
+
674
+ expect(infoLabels()).toEqual(
675
+ STORAGE_FIELDS.map(([title]: [string, ProxmoxMetric]) => {
676
+ return title;
677
+ }),
678
+ );
679
+ for (const title of [
680
+ "Storage Name",
681
+ "Cluster",
682
+ "Node",
683
+ "External ID",
684
+ "Last Seen",
685
+ ]) {
686
+ expect(infoLabels(fieldCard(title))).toEqual([]);
687
+ }
688
+ });
689
+
690
+ test("each field's (i) shows its own description", async () => {
691
+ await renderPage(
692
+ ProxmoxClusterStorageDetail,
693
+ "storage%2Fpve1%2Flocal",
694
+ "local",
695
+ );
696
+ await expectExplained(STORAGE_FIELDS);
697
+ });
698
+
699
+ test("the forecast is a straight line through the last 24 hours", async () => {
700
+ await renderPage(
701
+ ProxmoxClusterStorageDetail,
702
+ "storage%2Fpve1%2Flocal",
703
+ "local",
704
+ );
705
+
706
+ expect(fieldCard("Used %")).toHaveTextContent("50.0%");
707
+ // 10 GiB a day and 50 GiB free: five days.
708
+ expect(fieldCard("Growth Forecast")).toHaveTextContent(
709
+ "will be full in ~5 days (linear fit over the last 24 h)",
710
+ );
711
+
712
+ const window: { startTimestamp: Date; endTimestamp: Date } = (
713
+ analyticsAggregateMock.mock.calls[0]![0] as {
714
+ aggregateBy: { startTimestamp: Date; endTimestamp: Date };
715
+ }
716
+ ).aggregateBy;
717
+ expect(
718
+ (window.endTimestamp.getTime() - window.startTimestamp.getTime()) / HOUR,
719
+ ).toBe(24);
720
+ });
721
+
722
+ test("a shrinking volume gets no forecast, and so no forecast (i)", async () => {
723
+ diskSamples = [...diskSamples]
724
+ .reverse()
725
+ .map(
726
+ (
727
+ sample: { timestamp: Date; value: number },
728
+ index: number,
729
+ ): { timestamp: Date; value: number } => {
730
+ return {
731
+ timestamp: diskSamples[index]!.timestamp,
732
+ value: sample.value,
733
+ };
734
+ },
735
+ );
736
+
737
+ await renderPage(
738
+ ProxmoxClusterStorageDetail,
739
+ "storage%2Fpve1%2Flocal",
740
+ "local",
741
+ );
742
+
743
+ expect(infoLabels()).not.toContain("Growth Forecast");
744
+ expect(screen.queryByText("Growth Forecast")).not.toBeInTheDocument();
745
+ });
746
+ });
747
+
748
+ // ------------------------------------------------------------------ lists
749
+
750
+ function columnHeader(title: string): HTMLElement {
751
+ return infoButton(title).closest("th") as HTMLElement;
752
+ }
753
+
754
+ function rowOf(name: string): HTMLElement {
755
+ return screen.getByText(name).closest("tr") as HTMLElement;
756
+ }
757
+
758
+ describe("Proxmox nodes list", () => {
759
+ test("Status, CPU, Memory and Age headers each explain their column", async () => {
760
+ await renderPage(ProxmoxClusterNodes, CLUSTER_ID, "pve1");
761
+
762
+ expect(infoLabels()).toEqual(["Status", "CPU", "Memory", "Age"]);
763
+ await expectExplained([
764
+ ["Status", "nodesTableStatus"],
765
+ ["CPU", "nodesTableCpu"],
766
+ ["Memory", "nodesTableMemory"],
767
+ ["Age", "nodesTableUptime"],
768
+ ]);
769
+ for (const title of ["Status", "CPU", "Memory", "Age"]) {
770
+ expect(columnHeader(title)).toBeTruthy();
771
+ }
772
+ expectNoNestedInfoButtons();
773
+ });
774
+
775
+ test("a node with no update in 15 minutes shows N/A for CPU and memory", async () => {
776
+ await renderPage(ProxmoxClusterNodes, CLUSTER_ID, "pve1");
777
+
778
+ const fresh: HTMLElement = rowOf("pve1");
779
+ expect(fresh).toHaveTextContent("10.0%");
780
+ expect(fresh).toHaveTextContent("25%");
781
+
782
+ const stale: HTMLElement = rowOf("pve2");
783
+ expect(within(stale).getAllByText("N/A")).toHaveLength(2);
784
+ expect(stale).not.toHaveTextContent("42.0%");
785
+ });
786
+
787
+ test("the Age column is the node's uptime", async () => {
788
+ await renderPage(ProxmoxClusterNodes, CLUSTER_ID, "pve1");
789
+
790
+ expect(rowOf("pve1")).toHaveTextContent("1d 2h");
791
+ expect(rowOf("pve2")).toHaveTextContent("3h 0m");
792
+ });
793
+ });
794
+
795
+ describe("Proxmox guests list", () => {
796
+ test("every metric column explains itself; identity columns do not", async () => {
797
+ await renderPage(ProxmoxClusterGuests, CLUSTER_ID, "web");
798
+
799
+ expect(infoLabels()).toEqual([
800
+ "Status",
801
+ "HA State",
802
+ "Backup",
803
+ "CPU",
804
+ "Memory",
805
+ "Age",
806
+ ]);
807
+ await expectExplained([
808
+ ["Status", "guestsTableStatus"],
809
+ ["HA State", "guestsTableHaState"],
810
+ ["Backup", "guestsTableBackup"],
811
+ ["CPU", "guestsTableCpu"],
812
+ ["Memory", "guestsTableMemory"],
813
+ ["Age", "guestsTableUptime"],
814
+ ]);
815
+ for (const title of ["VMID", "Type", "Node", "Name"]) {
816
+ expect(
817
+ screen.queryByRole("button", { name: `About ${title}` }),
818
+ ).not.toBeInTheDocument();
819
+ }
820
+ });
821
+
822
+ test("the Backup column shows the three values its (i) spells out", async () => {
823
+ await renderPage(ProxmoxClusterGuests, CLUSTER_ID, "web");
824
+
825
+ expect(rowOf("web")).toHaveTextContent("In job");
826
+ expect(rowOf("db")).toHaveTextContent("Not backed up");
827
+ expect(within(rowOf("cache")).getAllByText("-").length).toBeGreaterThan(0);
828
+ });
829
+
830
+ test("HA State shows a dash for a guest HA does not manage", async () => {
831
+ await renderPage(ProxmoxClusterGuests, CLUSTER_ID, "web");
832
+
833
+ expect(rowOf("web")).toHaveTextContent("started");
834
+ expect(rowOf("cache")).not.toHaveTextContent("started");
835
+ });
836
+
837
+ test("a stopped guest shows a dash for its uptime", async () => {
838
+ await renderPage(ProxmoxClusterGuests, CLUSTER_ID, "web");
839
+
840
+ expect(rowOf("web")).toHaveTextContent("1h 30m");
841
+ const stopped: HTMLElement = rowOf("db");
842
+ expect(stopped).toHaveTextContent("Stopped");
843
+ expect(stopped).not.toHaveTextContent(/\d+m\b/);
844
+ });
845
+
846
+ test("memory is compared with what the guest was assigned", async () => {
847
+ await renderPage(ProxmoxClusterGuests, CLUSTER_ID, "web");
848
+
849
+ // 3 of 4 GiB assigned.
850
+ expect(rowOf("web")).toHaveTextContent("75%");
851
+ expect(rowOf("web")).toHaveTextContent("3.00 GB / 4.00 GB");
852
+ });
853
+ });
854
+
855
+ describe("Proxmox storage list", () => {
856
+ test("Status, Used / Total and Age explain themselves; there are no CPU or memory columns", async () => {
857
+ await renderPage(ProxmoxClusterStorage, CLUSTER_ID, "local");
858
+
859
+ expect(infoLabels()).toEqual(["Status", "Used / Total", "Age"]);
860
+ await expectExplained([
861
+ ["Status", "storageTableStatus"],
862
+ ["Used / Total", "storageTableUsage"],
863
+ ["Age", "storageTableAge"],
864
+ ]);
865
+ expect(screen.queryByText("CPU")).not.toBeInTheDocument();
866
+ });
867
+
868
+ test("usage shows used, total and percentage, or N/A when a figure is missing", async () => {
869
+ await renderPage(ProxmoxClusterStorage, CLUSTER_ID, "local");
870
+
871
+ expect(rowOf("local")).toHaveTextContent("50.0 GiB / 100 GiB (50.0%)");
872
+ expect(rowOf("backup")).toHaveTextContent("N/A");
873
+ });
874
+
875
+ test("storage reports no uptime, so Age is a dash", async () => {
876
+ await renderPage(ProxmoxClusterStorage, CLUSTER_ID, "local");
877
+
878
+ const ageIndex: number = Array.from(
879
+ columnHeader("Age").parentElement!.children,
880
+ ).indexOf(columnHeader("Age"));
881
+ for (const name of ["local", "backup"]) {
882
+ expect(rowOf(name).children[ageIndex]).toHaveTextContent("-");
883
+ }
884
+ });
885
+ });
886
+
887
+ // --------------------------------------------------------------- insights
888
+
889
+ describe("Proxmox insights", () => {
890
+ test("the Disk Throughput chart header is the only (i), and it explains the cluster-wide sum", async () => {
891
+ await renderPage(ProxmoxClusterInsights, CLUSTER_ID, "Compute");
892
+
893
+ expect(infoLabels()).toEqual(["Disk Throughput"]);
894
+ await expectExplained([["Disk Throughput", "insightsDiskThroughput"]]);
895
+ expectHeaderAboveChart("Disk Throughput", "pve_disk_read_bytes");
896
+
897
+ // The cluster-wide chart is not filtered to one resource.
898
+ const diskChart: { extraAttributes?: unknown } = rateChartMock.mock.calls
899
+ .map((call: Array<unknown>) => {
900
+ return call[0] as {
901
+ series: Array<{ metricName: string }>;
902
+ extraAttributes?: unknown;
903
+ };
904
+ })
905
+ .find((props: { series: Array<{ metricName: string }> }) => {
906
+ return props.series[0]!.metricName === "pve_disk_read_bytes";
907
+ })!;
908
+ expect(diskChart.extraAttributes).toBeUndefined();
909
+ });
910
+
911
+ test("the network chart is explained by its card's visible description", async () => {
912
+ await renderPage(ProxmoxClusterInsights, CLUSTER_ID, "Compute");
913
+
914
+ expect(
915
+ screen.getByText(
916
+ "Per-second inbound and outbound network throughput summed across all guests.",
917
+ ),
918
+ ).toBeInTheDocument();
919
+ expect(
920
+ screen.queryByRole("button", { name: "About Network" }),
921
+ ).not.toBeInTheDocument();
922
+ });
923
+ });