@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,725 @@
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 { cleanup, render, screen } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import getJestMockFunction, { MockFunction } from "../../MockType";
13
+ import {
14
+ explanationOnFocus,
15
+ explanationOnHover,
16
+ expectNotNestedInControl,
17
+ infoButtonsFor,
18
+ infoLabels,
19
+ never,
20
+ settle,
21
+ } from "./HostTooltipHarness";
22
+
23
+ /*
24
+ * The three Host detail views - one process, one Windows service, one
25
+ * systemd unit - rendered for real with their data layer mocked. Each tile
26
+ * and chart shows an (i) that explains its own metric, and the numbers the
27
+ * pages compute are the ones the texts describe (including where they read
28
+ * low: the process CPU tile averages the collector's three CPU readings).
29
+ */
30
+
31
+ const MODEL_ID: string = "84858d6c-1111-4aaa-8bbb-000000000001";
32
+ const GIB: number = 1024 * 1024 * 1024;
33
+
34
+ const getItemMock: MockFunction = getJestMockFunction();
35
+ const getListMock: MockFunction = getJestMockFunction();
36
+ const aggregateMock: MockFunction = getJestMockFunction();
37
+ const lastParamMock: MockFunction = getJestMockFunction();
38
+
39
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
40
+ return {
41
+ __esModule: true,
42
+ default: {
43
+ getItem: (...args: Array<unknown>) => {
44
+ return getItemMock(...args);
45
+ },
46
+ },
47
+ };
48
+ });
49
+
50
+ jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
51
+ return {
52
+ __esModule: true,
53
+ default: {
54
+ getList: (...args: Array<unknown>) => {
55
+ return getListMock(...args);
56
+ },
57
+ aggregate: (...args: Array<unknown>) => {
58
+ return aggregateMock(...args);
59
+ },
60
+ },
61
+ };
62
+ });
63
+
64
+ jest.mock("../../../UI/Utils/API/API", () => {
65
+ return {
66
+ __esModule: true,
67
+ default: {
68
+ getFriendlyMessage: (error: unknown) => {
69
+ return String((error as Error)?.message || error);
70
+ },
71
+ },
72
+ };
73
+ });
74
+
75
+ jest.mock("../../../UI/Utils/Navigation", () => {
76
+ return {
77
+ __esModule: true,
78
+ default: {
79
+ getLastParamAsObjectID: () => {
80
+ const { default: ObjectIDType } = jest.requireActual(
81
+ "../../../Types/ObjectID",
82
+ ) as { default: new (id: string) => unknown };
83
+ return new ObjectIDType("84858d6c-1111-4aaa-8bbb-000000000001");
84
+ },
85
+ getLastParamAsString: () => {
86
+ return lastParamMock();
87
+ },
88
+ navigate: () => {},
89
+ },
90
+ };
91
+ });
92
+
93
+ jest.mock("../../../UI/Utils/Project", () => {
94
+ return {
95
+ __esModule: true,
96
+ default: {
97
+ getCurrentProjectId: () => {
98
+ const { default: ObjectIDType } = jest.requireActual(
99
+ "../../../Types/ObjectID",
100
+ ) as { default: new (id: string) => unknown };
101
+ return new ObjectIDType("10000000-0000-4000-8000-000000000001");
102
+ },
103
+ },
104
+ };
105
+ });
106
+
107
+ jest.mock("../../../UI/Components/Charts/Line/LineChart", () => {
108
+ return {
109
+ __esModule: true,
110
+ default: () => {
111
+ return <div data-testid="line-chart" />;
112
+ },
113
+ };
114
+ });
115
+
116
+ jest.mock(
117
+ "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/AutoRefreshControl",
118
+ () => {
119
+ return {
120
+ __esModule: true,
121
+ default: () => {
122
+ return null;
123
+ },
124
+ };
125
+ },
126
+ );
127
+
128
+ import HostProcessView from "../../../../App/FeatureSet/Dashboard/src/Pages/Host/View/ProcessView";
129
+ import HostServiceView from "../../../../App/FeatureSet/Dashboard/src/Pages/Host/View/ServiceView";
130
+ import HostSystemdUnitView from "../../../../App/FeatureSet/Dashboard/src/Pages/Host/View/SystemdUnitView";
131
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
132
+ import {
133
+ HOST_METRIC_DESCRIPTIONS,
134
+ HostMetric,
135
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/HostMetricDescriptions";
136
+
137
+ const D: Record<HostMetric, string> = HOST_METRIC_DESCRIPTIONS;
138
+
139
+ // The Host pages read their ids from the route, not from these props.
140
+ const PAGE_PROPS: PageComponentProps = {} as PageComponentProps;
141
+
142
+ type Row = {
143
+ timestamp: Date;
144
+ value: number;
145
+ attributes?: Record<string, string>;
146
+ };
147
+
148
+ interface AggregateCall {
149
+ aggregateBy: { query: { name: string } };
150
+ }
151
+
152
+ function secondsAgo(seconds: number): Date {
153
+ return new Date(Date.now() - seconds * 1000);
154
+ }
155
+
156
+ function perMinute(
157
+ value: (minute: number) => number,
158
+ attributes?: Record<string, string>,
159
+ ): Array<Row> {
160
+ const rows: Array<Row> = [];
161
+
162
+ for (let minute: number = 29; minute >= 0; minute--) {
163
+ rows.push({
164
+ timestamp: secondsAgo(minute * 60),
165
+ value: value(minute),
166
+ ...(attributes ? { attributes } : {}),
167
+ });
168
+ }
169
+
170
+ return rows;
171
+ }
172
+
173
+ function hostItem(): Record<string, unknown> {
174
+ return {
175
+ _id: MODEL_ID,
176
+ name: "web-01",
177
+ hostIdentifier: "web-01",
178
+ cpuCores: 4,
179
+ totalMemoryBytes: 16 * GIB,
180
+ osType: "linux",
181
+ };
182
+ }
183
+
184
+ beforeEach(() => {
185
+ jest.useFakeTimers();
186
+ getItemMock.mockReset();
187
+ getListMock.mockReset();
188
+ aggregateMock.mockReset();
189
+ lastParamMock.mockReset();
190
+ getItemMock.mockResolvedValue(hostItem());
191
+ });
192
+
193
+ afterEach(() => {
194
+ cleanup();
195
+ jest.useRealTimers();
196
+ });
197
+
198
+ // ---------------------------------------------------------------- process
199
+
200
+ /*
201
+ * process.cpu.utilization arrives as three readings per scrape. This
202
+ * process is 30% user + 6% system + 0% wait = 36% busy.
203
+ */
204
+ function processRows(call: AggregateCall): Array<Row> {
205
+ switch (call.aggregateBy.query.name) {
206
+ case "process.cpu.utilization":
207
+ return [
208
+ ...perMinute(
209
+ () => {
210
+ return 0.3;
211
+ },
212
+ { state: "user" },
213
+ ),
214
+ ...perMinute(
215
+ () => {
216
+ return 0.06;
217
+ },
218
+ { state: "system" },
219
+ ),
220
+ ...perMinute(
221
+ () => {
222
+ return 0;
223
+ },
224
+ { state: "wait" },
225
+ ),
226
+ ];
227
+ case "process.memory.usage":
228
+ return perMinute(() => {
229
+ return 2 * GIB;
230
+ });
231
+ case "process.memory.virtual":
232
+ return perMinute(() => {
233
+ return 12 * GIB;
234
+ });
235
+ case "process.disk.io":
236
+ return [
237
+ ...perMinute(
238
+ (minute: number) => {
239
+ return (30 - minute) * 60_000;
240
+ },
241
+ { direction: "read" },
242
+ ),
243
+ ...perMinute(
244
+ (minute: number) => {
245
+ return (30 - minute) * 120_000;
246
+ },
247
+ { direction: "write" },
248
+ ),
249
+ ];
250
+ // process.threads and process.open_file_descriptors: off by default.
251
+ default:
252
+ return [];
253
+ }
254
+ }
255
+
256
+ /*
257
+ * The aggregate API averages every datapoint in a bucket, whatever its
258
+ * attributes. Collapse the per-state rows to one Avg row per timestamp so
259
+ * the page sees what the server would hand it.
260
+ */
261
+ function averagedPerTimestamp(rows: Array<Row>): Array<Row> {
262
+ const byTime: Map<number, { sum: number; count: number; row: Row }> =
263
+ new Map();
264
+
265
+ for (const row of rows) {
266
+ const key: number = row.timestamp.getTime();
267
+ const entry: { sum: number; count: number; row: Row } | undefined =
268
+ byTime.get(key);
269
+
270
+ if (entry) {
271
+ entry.sum += row.value;
272
+ entry.count += 1;
273
+ } else {
274
+ byTime.set(key, { sum: row.value, count: 1, row });
275
+ }
276
+ }
277
+
278
+ return Array.from(byTime.values()).map(
279
+ (entry: { sum: number; count: number; row: Row }): Row => {
280
+ return {
281
+ timestamp: entry.row.timestamp,
282
+ value: entry.sum / entry.count,
283
+ };
284
+ },
285
+ );
286
+ }
287
+
288
+ function mockProcess(): void {
289
+ lastParamMock.mockReturnValue("4321");
290
+ getListMock.mockResolvedValue({
291
+ data: [
292
+ {
293
+ time: secondsAgo(20),
294
+ attributes: {
295
+ "resource.process.pid": "4321",
296
+ "resource.process.executable.name": "postgres",
297
+ "resource.process.command": "postgres -D /var/lib/postgresql",
298
+ "resource.process.owner": "postgres",
299
+ },
300
+ },
301
+ ],
302
+ });
303
+ aggregateMock.mockImplementation((call: unknown) => {
304
+ const typed: AggregateCall = call as AggregateCall;
305
+ const rows: Array<Row> = processRows(typed);
306
+ const grouped: boolean = typed.aggregateBy.query.name === "process.disk.io";
307
+
308
+ return Promise.resolve({
309
+ data: grouped ? rows : averagedPerTimestamp(rows),
310
+ });
311
+ });
312
+ }
313
+
314
+ const PROCESS_LOADED: Array<[string, number, HostMetric]> = [
315
+ ["CPU", 0, "processCpu"],
316
+ ["Memory (RSS)", 0, "processMemoryRss"],
317
+ ["Virtual Memory", 0, "processVirtualMemory"],
318
+ ["Threads", 0, "processThreads"],
319
+ ["CPU", 1, "processCpuChart"],
320
+ ["Memory (RSS)", 1, "processMemoryRssChart"],
321
+ ["Disk I/O", 0, "processDiskIoChart"],
322
+ ];
323
+
324
+ async function renderProcess(): Promise<void> {
325
+ render(<HostProcessView {...PAGE_PROPS} />);
326
+ await settle();
327
+ expect(screen.getByRole("heading", { name: "postgres" })).toBeInTheDocument();
328
+ }
329
+
330
+ describe("Host process view", () => {
331
+ beforeEach(() => {
332
+ mockProcess();
333
+ });
334
+
335
+ test("shows an (i) beside every tile and chart, in page order", async () => {
336
+ await renderProcess();
337
+
338
+ expect(infoLabels()).toEqual(
339
+ PROCESS_LOADED.map((entry: [string, number, HostMetric]): string => {
340
+ return entry[0];
341
+ }),
342
+ );
343
+ });
344
+
345
+ test.each(PROCESS_LOADED)(
346
+ "the (i) for %s (#%i) explains it with %s",
347
+ async (label: string, index: number, key: HostMetric) => {
348
+ await renderProcess();
349
+
350
+ expect(await explanationOnHover(infoButtonsFor(label)[index]!)).toBe(
351
+ D[key],
352
+ );
353
+ },
354
+ );
355
+
356
+ test("no (i) is nested inside a button or a link", async () => {
357
+ await renderProcess();
358
+
359
+ for (const [label, index] of PROCESS_LOADED) {
360
+ expectNotNestedInControl(infoButtonsFor(label)[index]!);
361
+ }
362
+ });
363
+
364
+ test("the CPU tile reads a third of the real use, as its text warns", async () => {
365
+ await renderProcess();
366
+
367
+ const tile: HTMLElement = infoButtonsFor("CPU")[0]!.closest(
368
+ ".rounded-xl",
369
+ ) as HTMLElement;
370
+
371
+ // (30% + 6% + 0%) / 3 = 12%, where the process is really 36% busy.
372
+ expect(tile).toHaveTextContent("12.0%");
373
+ expect(tile).not.toHaveTextContent("36.0%");
374
+ expect(D.processCpu).toContain("about a third of the real use");
375
+ });
376
+
377
+ test("RSS is compared with the host's total RAM", async () => {
378
+ await renderProcess();
379
+
380
+ const tile: HTMLElement = infoButtonsFor("Memory (RSS)")[0]!.closest(
381
+ ".rounded-xl",
382
+ ) as HTMLElement;
383
+
384
+ expect(tile).toHaveTextContent("2.0 GiB");
385
+ expect(tile).toHaveTextContent("12.5% of 16 GiB");
386
+ });
387
+
388
+ test("a process with no recent samples shows its whole-range average, as the texts say", async () => {
389
+ /*
390
+ * The process stopped reporting 10 minutes ago. No bucket starts in the
391
+ * tile window, so the tile averages the whole range rather than going
392
+ * blank - which every process tile text now discloses.
393
+ */
394
+ for (const key of [
395
+ "processCpu",
396
+ "processMemoryRss",
397
+ "processVirtualMemory",
398
+ "processThreads",
399
+ ] as Array<HostMetric>) {
400
+ expect(D[key]).toContain("no recent data");
401
+ }
402
+
403
+ aggregateMock.mockImplementation((call: unknown) => {
404
+ const typed: AggregateCall = call as AggregateCall;
405
+
406
+ if (typed.aggregateBy.query.name !== "process.memory.usage") {
407
+ const rows: Array<Row> = processRows(typed);
408
+ const grouped: boolean =
409
+ typed.aggregateBy.query.name === "process.disk.io";
410
+
411
+ return Promise.resolve({
412
+ data: grouped ? rows : averagedPerTimestamp(rows),
413
+ });
414
+ }
415
+
416
+ const rows: Array<Row> = [];
417
+
418
+ for (let minute: number = 29; minute >= 10; minute--) {
419
+ rows.push({
420
+ timestamp: secondsAgo(minute * 60),
421
+ value: (minute >= 20 ? 1 : 2) * GIB,
422
+ });
423
+ }
424
+
425
+ return Promise.resolve({ data: rows });
426
+ });
427
+
428
+ await renderProcess();
429
+
430
+ const tile: HTMLElement = infoButtonsFor("Memory (RSS)")[0]!.closest(
431
+ ".rounded-xl",
432
+ ) as HTMLElement;
433
+
434
+ // (1 GiB x 10 + 2 GiB x 10) / 20 buckets = 1.5 GiB, 9.4% of 16 GiB.
435
+ expect(tile).toHaveTextContent("1.5 GiB");
436
+ expect(tile).toHaveTextContent("9.4% of 16 GiB");
437
+ });
438
+
439
+ test("threads read as a dash until the collector sends them, as the text says", async () => {
440
+ await renderProcess();
441
+
442
+ const tile: HTMLElement = infoButtonsFor("Threads")[0]!.closest(
443
+ ".rounded-xl",
444
+ ) as HTMLElement;
445
+
446
+ expect(tile).toHaveTextContent("—");
447
+ expect(tile).toHaveTextContent("thread count");
448
+ expect(D.processThreads).toContain("turned on");
449
+ });
450
+
451
+ test("the skeleton chart cards carry their (i) before the data arrives", async () => {
452
+ aggregateMock.mockImplementation(() => {
453
+ return never();
454
+ });
455
+
456
+ render(<HostProcessView {...PAGE_PROPS} />);
457
+ await settle();
458
+
459
+ expect(infoLabels()).toEqual(["CPU", "Memory (RSS)", "Disk I/O"]);
460
+ expect(await explanationOnHover(infoButtonsFor("Disk I/O")[0]!)).toBe(
461
+ D.processDiskIoChart,
462
+ );
463
+ expect(await explanationOnHover(infoButtonsFor("CPU")[0]!)).toBe(
464
+ D.processCpuChart,
465
+ );
466
+ });
467
+ });
468
+
469
+ // ---------------------------------------------------------------- service
470
+
471
+ function serviceRow(
472
+ secondsBack: number,
473
+ code: number,
474
+ ): Record<string, unknown> {
475
+ return {
476
+ time: secondsAgo(secondsBack),
477
+ value: code,
478
+ attributes: {
479
+ name: "Spooler",
480
+ startup_mode: "auto_start",
481
+ "resource.host.name": "web-01",
482
+ },
483
+ };
484
+ }
485
+
486
+ // Newest first, as the API returns them: 18 Running, then a 2-sample stop.
487
+ function serviceRows(): Array<Record<string, unknown>> {
488
+ const rows: Array<Record<string, unknown>> = [];
489
+
490
+ for (let i: number = 0; i < 20; i++) {
491
+ rows.push(serviceRow(i * 30, i === 6 || i === 7 ? 1 : 4));
492
+ }
493
+
494
+ return rows;
495
+ }
496
+
497
+ const SERVICE_LOADED: Array<[string, HostMetric]> = [
498
+ ["Current Status", "serviceCurrentStatus"],
499
+ ["Availability", "serviceAvailability"],
500
+ ["Startup Mode", "serviceStartupMode"],
501
+ ["State Changes", "serviceStateChanges"],
502
+ ["Status timeline", "serviceStatusTimeline"],
503
+ ];
504
+
505
+ async function renderService(): Promise<void> {
506
+ render(<HostServiceView {...PAGE_PROPS} />);
507
+ await settle();
508
+ expect(screen.getByRole("heading", { name: "Spooler" })).toBeInTheDocument();
509
+ }
510
+
511
+ describe("Host Windows service view", () => {
512
+ beforeEach(() => {
513
+ lastParamMock.mockReturnValue("Spooler");
514
+ getListMock.mockResolvedValue({ data: serviceRows() });
515
+ });
516
+
517
+ test("shows an (i) beside every tile and the timeline, in page order", async () => {
518
+ await renderService();
519
+
520
+ expect(infoLabels()).toEqual(
521
+ SERVICE_LOADED.map((entry: [string, HostMetric]): string => {
522
+ return entry[0];
523
+ }),
524
+ );
525
+ });
526
+
527
+ test.each(SERVICE_LOADED)(
528
+ "the (i) for %s explains it with %s",
529
+ async (label: string, key: HostMetric) => {
530
+ await renderService();
531
+
532
+ expect(await explanationOnHover(infoButtonsFor(label)[0]!)).toBe(D[key]);
533
+ },
534
+ );
535
+
536
+ test("the timeline (i) also opens from the keyboard", async () => {
537
+ await renderService();
538
+
539
+ expect(
540
+ await explanationOnFocus(infoButtonsFor("Status timeline")[0]!),
541
+ ).toBe(D.serviceStatusTimeline);
542
+ });
543
+
544
+ test("no (i) is nested inside a button or a link", async () => {
545
+ await renderService();
546
+
547
+ for (const [label] of SERVICE_LOADED) {
548
+ expectNotNestedInControl(infoButtonsFor(label)[0]!);
549
+ }
550
+ });
551
+
552
+ test("the timeline (i) sits beside the heading, not inside it", async () => {
553
+ await renderService();
554
+
555
+ const heading: HTMLElement = screen.getByRole("heading", {
556
+ name: "Status timeline",
557
+ });
558
+ const button: HTMLElement = infoButtonsFor("Status timeline")[0]!;
559
+
560
+ expect(heading).not.toContainElement(button);
561
+ expect(button.parentElement).toContainElement(heading);
562
+ });
563
+
564
+ test("availability is a count of Running samples, as the text says", async () => {
565
+ await renderService();
566
+
567
+ const tile: HTMLElement = infoButtonsFor("Availability")[0]!.closest(
568
+ ".rounded-xl",
569
+ ) as HTMLElement;
570
+
571
+ expect(tile).toHaveTextContent("90.0%");
572
+ expect(tile).toHaveTextContent("running in 18 of 20 samples");
573
+ expect(tile).not.toHaveTextContent("capped");
574
+ });
575
+
576
+ test("state changes count sample-to-sample differences", async () => {
577
+ await renderService();
578
+
579
+ const tile: HTMLElement = infoButtonsFor("State Changes")[0]!.closest(
580
+ ".rounded-xl",
581
+ ) as HTMLElement;
582
+
583
+ // Running -> Stopped -> Running.
584
+ expect(tile).toHaveTextContent("2");
585
+ });
586
+
587
+ test("the startup mode uses the labels the text lists", async () => {
588
+ await renderService();
589
+
590
+ const tile: HTMLElement = infoButtonsFor("Startup Mode")[0]!.closest(
591
+ ".rounded-xl",
592
+ ) as HTMLElement;
593
+
594
+ expect(tile).toHaveTextContent("Automatic");
595
+ expect(D.serviceStartupMode).toContain("Automatic");
596
+ });
597
+
598
+ test("a range past 2,000 samples is marked capped, as the text says", async () => {
599
+ const rows: Array<Record<string, unknown>> = [];
600
+
601
+ for (let i: number = 0; i < 2000; i++) {
602
+ rows.push(serviceRow(i * 30, 4));
603
+ }
604
+ getListMock.mockResolvedValue({ data: rows });
605
+
606
+ await renderService();
607
+
608
+ const tile: HTMLElement = infoButtonsFor("Availability")[0]!.closest(
609
+ ".rounded-xl",
610
+ ) as HTMLElement;
611
+
612
+ expect(tile).toHaveTextContent("(capped)");
613
+ expect(D.serviceAvailability).toContain("capped");
614
+ });
615
+ });
616
+
617
+ // ------------------------------------------------------------ systemd unit
618
+
619
+ function unitRow(secondsBack: number, state: string): Record<string, unknown> {
620
+ return {
621
+ time: secondsAgo(secondsBack),
622
+ value: 1,
623
+ attributes: {
624
+ "resource.systemd.unit.name": "nginx.service",
625
+ "systemd.unit.active_state": state,
626
+ "resource.host.name": "web-01",
627
+ },
628
+ };
629
+ }
630
+
631
+ const UNIT_LOADED: Array<[string, HostMetric]> = [
632
+ ["Current State", "unitCurrentState"],
633
+ ["Availability", "unitAvailability"],
634
+ ["Unit Type", "unitType"],
635
+ ["State Changes", "unitStateChanges"],
636
+ ["State timeline", "unitStateTimeline"],
637
+ ];
638
+
639
+ async function renderUnit(): Promise<void> {
640
+ render(<HostSystemdUnitView {...PAGE_PROPS} />);
641
+ await settle();
642
+ expect(
643
+ screen.getByRole("heading", { name: "nginx.service" }),
644
+ ).toBeInTheDocument();
645
+ }
646
+
647
+ describe("Host systemd unit view", () => {
648
+ beforeEach(() => {
649
+ lastParamMock.mockReturnValue("nginx.service");
650
+ // Newest first: active, reloading, active, active.
651
+ getListMock.mockResolvedValue({
652
+ data: [
653
+ unitRow(0, "active"),
654
+ unitRow(30, "reloading"),
655
+ unitRow(60, "active"),
656
+ unitRow(90, "active"),
657
+ ],
658
+ });
659
+ });
660
+
661
+ test("shows an (i) beside every tile and the timeline, in page order", async () => {
662
+ await renderUnit();
663
+
664
+ expect(infoLabels()).toEqual(
665
+ UNIT_LOADED.map((entry: [string, HostMetric]): string => {
666
+ return entry[0];
667
+ }),
668
+ );
669
+ });
670
+
671
+ test.each(UNIT_LOADED)(
672
+ "the (i) for %s explains it with %s",
673
+ async (label: string, key: HostMetric) => {
674
+ await renderUnit();
675
+
676
+ expect(await explanationOnHover(infoButtonsFor(label)[0]!)).toBe(D[key]);
677
+ },
678
+ );
679
+
680
+ test("no (i) is nested inside a button or a link", async () => {
681
+ await renderUnit();
682
+
683
+ for (const [label] of UNIT_LOADED) {
684
+ expectNotNestedInControl(infoButtonsFor(label)[0]!);
685
+ }
686
+ });
687
+
688
+ test("Reloading counts as not active, as the availability text says", async () => {
689
+ await renderUnit();
690
+
691
+ const tile: HTMLElement = infoButtonsFor("Availability")[0]!.closest(
692
+ ".rounded-xl",
693
+ ) as HTMLElement;
694
+
695
+ expect(tile).toHaveTextContent("75.0%");
696
+ expect(tile).toHaveTextContent("active in 3 of 4 samples");
697
+ });
698
+
699
+ test("the unit type is read from the end of the name", async () => {
700
+ await renderUnit();
701
+
702
+ const tile: HTMLElement = infoButtonsFor("Unit Type")[0]!.closest(
703
+ ".rounded-xl",
704
+ ) as HTMLElement;
705
+
706
+ expect(tile).toHaveTextContent("Service");
707
+ expect(D.unitType).toContain("read from the end of its name");
708
+ });
709
+
710
+ test("with no samples the tiles keep their (i)s and the timeline is hidden", async () => {
711
+ getListMock.mockResolvedValue({ data: [] });
712
+
713
+ render(<HostSystemdUnitView {...PAGE_PROPS} />);
714
+ await settle();
715
+
716
+ // Tiles still render (with dashes); the timeline is hidden without data.
717
+ expect(infoLabels()).toEqual([
718
+ "Current State",
719
+ "Availability",
720
+ "Unit Type",
721
+ "State Changes",
722
+ ]);
723
+ expect(screen.getByText("no samples in range")).toBeInTheDocument();
724
+ });
725
+ });