@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,556 @@
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 { MemoryRouter } from "react-router-dom";
20
+ import getJestMockFunction, { MockFunction } from "../../MockType";
21
+
22
+ /*
23
+ * The (i) tooltips on a device's connectivity and traffic numbers,
24
+ * RENDERED: the on-demand ping result rows, the traceroute hop table, the
25
+ * past hour's latency trend (device Overview and topology drawer) and the
26
+ * Traffic tab's NetFlow top talkers.
27
+ *
28
+ * Only the network is replaced: the metrics query, the diagnostic row reads
29
+ * (through DeviceDiagnostics' own modelAPI seam) and the top-talkers POST.
30
+ * Every statistic is checked for an (i) whose tooltip is the matching
31
+ * NETWORK_DEVICE_METRIC_DESCRIPTIONS entry; the rows and columns that only
32
+ * name something (Host, Reason, Hop, Source IP) are checked for having none.
33
+ */
34
+
35
+ const DEVICE_ID: string = "11111111-1111-4111-8111-111111111111";
36
+ const DIAGNOSTIC_ID: string = "22222222-2222-4222-8222-222222222222";
37
+ const PROBE_ID: string = "44444444-4444-4444-8444-444444444444";
38
+ const PROJECT_ID: string = "10000000-0000-4000-8000-000000000001";
39
+
40
+ const fetchResultsMock: MockFunction = getJestMockFunction();
41
+ const apiPostMock: MockFunction = getJestMockFunction();
42
+
43
+ jest.mock("../../../UI/Utils/Project", () => {
44
+ return {
45
+ __esModule: true,
46
+ default: {
47
+ // Read at render time, long after the module-level id below exists.
48
+ getCurrentProjectId: (): unknown => {
49
+ return mockProjectObjectId;
50
+ },
51
+ },
52
+ };
53
+ });
54
+
55
+ jest.mock("../../../UI/Utils/API/API", () => {
56
+ return {
57
+ __esModule: true,
58
+ default: {
59
+ post: (...args: Array<unknown>): unknown => {
60
+ return apiPostMock(...args);
61
+ },
62
+ getFriendlyMessage: (error: Error): string => {
63
+ return error.message;
64
+ },
65
+ },
66
+ };
67
+ });
68
+
69
+ jest.mock(
70
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
71
+ () => {
72
+ return {
73
+ __esModule: true,
74
+ default: {
75
+ fetchResults: (...args: Array<unknown>): unknown => {
76
+ return fetchResultsMock(...args);
77
+ },
78
+ },
79
+ };
80
+ },
81
+ );
82
+
83
+ jest.mock(
84
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricSparkline",
85
+ () => {
86
+ return {
87
+ __esModule: true,
88
+ default: (): string => {
89
+ return "sparkline-stub";
90
+ },
91
+ };
92
+ },
93
+ );
94
+
95
+ import DeviceDiagnostics from "../../../../App/FeatureSet/Dashboard/src/Components/NetworkDevice/DeviceDiagnostics";
96
+ import { DIAGNOSTIC_POLL_INTERVAL_IN_MS } from "../../../../App/FeatureSet/Dashboard/src/Components/NetworkDevice/DeviceDiagnosticsViewModel";
97
+ import DeviceLatencyTrend from "../../../../App/FeatureSet/Dashboard/src/Components/NetworkDevice/DeviceLatencyTrend";
98
+ import FlowTopTalkers, {
99
+ FlowSectionTitle,
100
+ FlowStatTile,
101
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/NetworkDevice/FlowTopTalkers";
102
+ import TracerouteHopsTable from "../../../../App/FeatureSet/Dashboard/src/Components/NetworkDevice/TracerouteHopsTable";
103
+ import {
104
+ NETWORK_DEVICE_METRIC_DESCRIPTIONS,
105
+ NetworkDeviceMetric,
106
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/MetricDescriptions/NetworkDeviceMetricDescriptions";
107
+ import NetworkDevice from "../../../Models/DatabaseModels/NetworkDevice";
108
+ import NetworkDeviceDiagnostic from "../../../Models/DatabaseModels/NetworkDeviceDiagnostic";
109
+ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
110
+ import ObjectID from "../../../Types/ObjectID";
111
+ import ModelAPI from "../../../UI/Utils/ModelAPI/ModelAPI";
112
+
113
+ const DESCRIPTIONS: Record<NetworkDeviceMetric, string> =
114
+ NETWORK_DEVICE_METRIC_DESCRIPTIONS;
115
+
116
+ // Handed out by the Project mock; below the imports so ObjectID exists.
117
+ const mockProjectObjectId: ObjectID = new ObjectID(PROJECT_ID);
118
+
119
+ async function flush(): Promise<void> {
120
+ for (let i: number = 0; i < 5; i++) {
121
+ await act(async () => {
122
+ await Promise.resolve();
123
+ });
124
+ }
125
+ }
126
+
127
+ function infoButtonNames(container?: HTMLElement): Array<string> {
128
+ const scope: ReturnType<typeof within> | typeof screen = container
129
+ ? within(container)
130
+ : screen;
131
+
132
+ return scope
133
+ .queryAllByRole("button", { name: /^About / })
134
+ .map((button: HTMLElement): string => {
135
+ return button.getAttribute("aria-label") || "";
136
+ });
137
+ }
138
+
139
+ async function expectExplained(label: string, text: string): Promise<void> {
140
+ const button: HTMLElement = screen.getByRole("button", {
141
+ name: `About ${label}`,
142
+ });
143
+
144
+ fireEvent.mouseEnter(button);
145
+ await act(async () => {
146
+ jest.advanceTimersByTime(200);
147
+ });
148
+
149
+ expect(
150
+ screen.getAllByRole("tooltip").map((tooltip: HTMLElement): string => {
151
+ return tooltip.textContent || "";
152
+ }),
153
+ ).toContain(text);
154
+
155
+ fireEvent.mouseLeave(button);
156
+ await act(async () => {
157
+ jest.advanceTimersByTime(400);
158
+ });
159
+ }
160
+
161
+ beforeEach(() => {
162
+ jest.useFakeTimers();
163
+ fetchResultsMock.mockReset();
164
+ apiPostMock.mockReset();
165
+ });
166
+
167
+ afterEach(() => {
168
+ cleanup();
169
+ jest.useRealTimers();
170
+ });
171
+
172
+ // --------------------------------------------------------- ping result
173
+
174
+ const createMock: MockFunction = getJestMockFunction();
175
+ const getDiagnosticMock: MockFunction = getJestMockFunction();
176
+ const getDeviceMock: MockFunction = getJestMockFunction();
177
+
178
+ const fakeModelAPI: typeof ModelAPI = {
179
+ create: createMock,
180
+ getItem: (request: { modelType: unknown }) => {
181
+ return request.modelType === NetworkDevice
182
+ ? getDeviceMock(request)
183
+ : getDiagnosticMock(request);
184
+ },
185
+ } as unknown as typeof ModelAPI;
186
+
187
+ function diagnosticRow(
188
+ overrides: Partial<NetworkDeviceDiagnostic>,
189
+ ): NetworkDeviceDiagnostic {
190
+ return Object.assign(
191
+ new NetworkDeviceDiagnostic(),
192
+ { _id: DIAGNOSTIC_ID, status: "Pending" },
193
+ overrides,
194
+ );
195
+ }
196
+
197
+ async function runPing(result: NetworkDeviceDiagnostic): Promise<HTMLElement> {
198
+ createMock.mockResolvedValue({ data: diagnosticRow({}) });
199
+ getDiagnosticMock.mockResolvedValue(result);
200
+ getDeviceMock.mockResolvedValue(
201
+ Object.assign(new NetworkDevice(), {
202
+ _id: DEVICE_ID,
203
+ hostname: "10.0.0.1",
204
+ probeId: new ObjectID(PROBE_ID),
205
+ }),
206
+ );
207
+
208
+ render(
209
+ <DeviceDiagnostics
210
+ networkDeviceId={new ObjectID(DEVICE_ID)}
211
+ modelAPI={fakeModelAPI}
212
+ />,
213
+ );
214
+ await flush();
215
+
216
+ await act(async () => {
217
+ fireEvent.click(screen.getByTestId("network-device-diagnostic-ping"));
218
+ });
219
+ await act(async () => {
220
+ jest.advanceTimersByTime(DIAGNOSTIC_POLL_INTERVAL_IN_MS);
221
+ });
222
+ await flush();
223
+
224
+ return screen.getByTestId("network-device-diagnostic-result");
225
+ }
226
+
227
+ const COMPLETED_PING: NetworkDeviceDiagnostic = diagnosticRow({
228
+ status: "Completed",
229
+ hostname: "10.0.0.1",
230
+ pingResult: {
231
+ isOnline: true,
232
+ failureCause: "",
233
+ pingResponse: {
234
+ packetsSent: 5,
235
+ packetsReceived: 4,
236
+ packetLossPercent: 20,
237
+ minRoundTripTimeInMs: 10.2,
238
+ maxRoundTripTimeInMs: 20.1,
239
+ avgRoundTripTimeInMs: 12.44,
240
+ jitterInMs: 1.2,
241
+ },
242
+ },
243
+ } as Partial<NetworkDeviceDiagnostic>);
244
+
245
+ describe("the on-demand ping result", () => {
246
+ const PING_ROWS: Array<[string, NetworkDeviceMetric]> = [
247
+ ["Average RTT", "pingAverageRtt"],
248
+ ["Min / Max RTT", "pingMinMaxRtt"],
249
+ ["Jitter", "pingJitter"],
250
+ ["Packet loss", "pingPacketLoss"],
251
+ ];
252
+
253
+ test("each statistic row has an (i); the Host row does not", async () => {
254
+ const result: HTMLElement = await runPing(COMPLETED_PING);
255
+
256
+ expect(result).toHaveTextContent("10.0.0.1");
257
+ expect(infoButtonNames(result)).toEqual(
258
+ PING_ROWS.map(([label]: [string, NetworkDeviceMetric]): string => {
259
+ return `About ${label}`;
260
+ }),
261
+ );
262
+ });
263
+
264
+ test.each(PING_ROWS)(
265
+ "the %s row's (i) reads NETWORK_DEVICE_METRIC_DESCRIPTIONS.%s",
266
+ async (label: string, key: NetworkDeviceMetric) => {
267
+ await runPing(COMPLETED_PING);
268
+
269
+ await expectExplained(label, DESCRIPTIONS[key]);
270
+ },
271
+ );
272
+
273
+ test("the (i) sits in the term, beside its label, not in the value", async () => {
274
+ const result: HTMLElement = await runPing(COMPLETED_PING);
275
+ const info: HTMLElement = within(result).getByRole("button", {
276
+ name: "About Jitter",
277
+ });
278
+
279
+ expect(info.closest("dt")).toHaveTextContent("Jitter");
280
+ expect(info.closest("dd")).toBeNull();
281
+ // The value still reads as it did.
282
+ expect(result).toHaveTextContent("20% (4/5 received)");
283
+ });
284
+
285
+ test("a ping that could not run shows only its Reason, with no (i)", async () => {
286
+ const result: HTMLElement = await runPing(
287
+ diagnosticRow({
288
+ status: "Completed",
289
+ pingResult: {
290
+ isOnline: false,
291
+ failureCause: "ICMP is not usable on this probe.",
292
+ },
293
+ } as Partial<NetworkDeviceDiagnostic>),
294
+ );
295
+
296
+ expect(result).toHaveTextContent("ICMP is not usable on this probe.");
297
+ expect(infoButtonNames(result)).toEqual([]);
298
+ });
299
+ });
300
+
301
+ // -------------------------------------------------------- traceroute
302
+
303
+ describe("the traceroute hop table", () => {
304
+ test("only the RTT header has an (i), and it explains the first-reply rule", async () => {
305
+ render(
306
+ <TracerouteHopsTable
307
+ hops={[
308
+ {
309
+ hopNumber: 1,
310
+ address: "10.0.0.1",
311
+ hostName: "gw",
312
+ roundTripTimeInMS: 0.8,
313
+ isTimeout: false,
314
+ },
315
+ {
316
+ hopNumber: 2,
317
+ address: undefined,
318
+ hostName: undefined,
319
+ roundTripTimeInMS: undefined,
320
+ isTimeout: true,
321
+ },
322
+ ]}
323
+ />,
324
+ );
325
+
326
+ expect(infoButtonNames()).toEqual(["About RTT"]);
327
+ expect(
328
+ screen.getByRole("button", { name: "About RTT" }).closest("th"),
329
+ ).toHaveTextContent("RTT");
330
+ await expectExplained("RTT", DESCRIPTIONS.tracerouteRtt);
331
+ });
332
+
333
+ test("no hops, no table, no (i)", () => {
334
+ const { container } = render(<TracerouteHopsTable hops={[]} />);
335
+
336
+ expect(container).toBeEmptyDOMElement();
337
+ });
338
+ });
339
+
340
+ // ------------------------------------------------------- latency trend
341
+
342
+ function aggregate(
343
+ rows: Array<{ time: string; value: number }>,
344
+ ): AggregatedResult {
345
+ return {
346
+ data: rows.map((row: { time: string; value: number }) => {
347
+ return { timestamp: new Date(row.time), value: row.value };
348
+ }),
349
+ };
350
+ }
351
+
352
+ async function renderTrend(): Promise<void> {
353
+ render(
354
+ <MemoryRouter>
355
+ <DeviceLatencyTrend networkDeviceId={new ObjectID(DEVICE_ID)} />
356
+ </MemoryRouter>,
357
+ );
358
+ await flush();
359
+ }
360
+
361
+ describe("the latency trend", () => {
362
+ test("the header and the loss line each have an (i)", async () => {
363
+ fetchResultsMock.mockResolvedValue([
364
+ aggregate([
365
+ { time: "2026-09-15T10:00:00Z", value: 10 },
366
+ { time: "2026-09-15T10:01:00Z", value: 30 },
367
+ ]),
368
+ aggregate([{ time: "2026-09-15T10:01:00Z", value: 50 }]),
369
+ ]);
370
+
371
+ await renderTrend();
372
+
373
+ expect(infoButtonNames()).toEqual([
374
+ "About Round-trip time, past hour",
375
+ "About Packet loss (1h max)",
376
+ ]);
377
+ // The (i) adds no text: the lines read exactly as before.
378
+ expect(
379
+ screen.getByTestId("network-device-latency-summary"),
380
+ ).toHaveTextContent("now 30.0 ms · avg 20.0 ms · max 30.0 ms");
381
+ expect(screen.getByTestId("network-device-loss-summary").textContent).toBe(
382
+ "Packet loss (1h max): 50%",
383
+ );
384
+ });
385
+
386
+ test("the header's (i) explains now / avg / max as per-minute figures", async () => {
387
+ fetchResultsMock.mockResolvedValue([
388
+ aggregate([{ time: "2026-09-15T10:00:00Z", value: 10 }]),
389
+ aggregate([]),
390
+ ]);
391
+
392
+ await renderTrend();
393
+
394
+ await expectExplained(
395
+ "Round-trip time, past hour",
396
+ DESCRIPTIONS.latencyTrend,
397
+ );
398
+ });
399
+
400
+ test("the loss line's (i) explains the worst minute of the hour", async () => {
401
+ fetchResultsMock.mockResolvedValue([
402
+ aggregate([{ time: "2026-09-15T10:00:00Z", value: 10 }]),
403
+ aggregate([{ time: "2026-09-15T10:00:00Z", value: 0 }]),
404
+ ]);
405
+
406
+ await renderTrend();
407
+
408
+ await expectExplained("Packet loss (1h max)", DESCRIPTIONS.packetLossPeak);
409
+ });
410
+
411
+ test("with no ping data there is no loss line, but the header keeps its (i)", async () => {
412
+ fetchResultsMock.mockResolvedValue([aggregate([]), aggregate([])]);
413
+
414
+ await renderTrend();
415
+
416
+ expect(
417
+ screen.getByText("No ping data in the last hour."),
418
+ ).toBeInTheDocument();
419
+ expect(infoButtonNames()).toEqual(["About Round-trip time, past hour"]);
420
+ });
421
+
422
+ test("the (i) is not inside the Open metrics link", async () => {
423
+ fetchResultsMock.mockResolvedValue([aggregate([]), aggregate([])]);
424
+
425
+ await renderTrend();
426
+
427
+ expect(
428
+ screen
429
+ .getByRole("button", { name: "About Round-trip time, past hour" })
430
+ .closest("a"),
431
+ ).toBeNull();
432
+ });
433
+ });
434
+
435
+ // ------------------------------------------------------- top talkers
436
+
437
+ describe("FlowStatTile and FlowSectionTitle", () => {
438
+ test("the stat tile shows its value and explains it", async () => {
439
+ render(
440
+ <FlowStatTile
441
+ title="Flows"
442
+ value="1,204"
443
+ description={DESCRIPTIONS.flowCount}
444
+ />,
445
+ );
446
+
447
+ expect(screen.getByText("1,204")).toBeInTheDocument();
448
+ await expectExplained("Flows", DESCRIPTIONS.flowCount);
449
+ });
450
+
451
+ test("the section title explains its block", async () => {
452
+ render(
453
+ <FlowSectionTitle
454
+ title="Bandwidth Over Time"
455
+ description={DESCRIPTIONS.flowBandwidth}
456
+ />,
457
+ );
458
+
459
+ expect(screen.getByText("Bandwidth Over Time")).toBeInTheDocument();
460
+ await expectExplained("Bandwidth Over Time", DESCRIPTIONS.flowBandwidth);
461
+ });
462
+ });
463
+
464
+ function topTalkersResponse(): unknown {
465
+ return {
466
+ data: {
467
+ totalOctets: 5 * 1024 * 1024,
468
+ totalPackets: 4200,
469
+ totalFlows: 37,
470
+ topSources: [{ key: "10.0.0.5", octets: 3000, packets: 20 }],
471
+ topDestinations: [{ key: "10.0.0.9", octets: 2000, packets: 10 }],
472
+ topProtocolPorts: [
473
+ { protocolNumber: 6, destinationPort: 443, octets: 5000, packets: 30 },
474
+ ],
475
+ topConversations: [
476
+ {
477
+ sourceIp: "10.0.0.5",
478
+ destinationIp: "10.0.0.9",
479
+ octets: 1000,
480
+ packets: 5,
481
+ },
482
+ ],
483
+ series: [{ time: "2026-09-24T11:00:00Z", octets: 60000, packets: 50 }],
484
+ seriesBucketSeconds: 60,
485
+ windowStartAt: "2026-09-24T11:00:00.000Z",
486
+ windowEndAt: "2026-09-24T12:00:00.000Z",
487
+ },
488
+ };
489
+ }
490
+
491
+ describe("the Traffic tab's top talkers", () => {
492
+ const FLOW_TITLES: Array<[string, NetworkDeviceMetric]> = [
493
+ ["Total Traffic", "flowTotalTraffic"],
494
+ ["Packets", "flowPackets"],
495
+ ["Flows", "flowCount"],
496
+ ["Bandwidth Over Time", "flowBandwidth"],
497
+ ["Top Sources", "flowTopSources"],
498
+ ["Top Destinations", "flowTopDestinations"],
499
+ ["Top Conversations", "flowTopConversations"],
500
+ ["Top Protocols & Ports", "flowTopProtocolsPorts"],
501
+ ];
502
+
503
+ test("every total, the chart and every top-N table carry an (i)", async () => {
504
+ apiPostMock.mockResolvedValue(topTalkersResponse());
505
+
506
+ render(<FlowTopTalkers networkDeviceId={new ObjectID(DEVICE_ID)} />);
507
+ await flush();
508
+
509
+ expect(screen.getByText("5.00 MB")).toBeInTheDocument();
510
+ expect(infoButtonNames()).toEqual(
511
+ FLOW_TITLES.map(([title]: [string, NetworkDeviceMetric]): string => {
512
+ return `About ${title}`;
513
+ }),
514
+ );
515
+ });
516
+
517
+ test.each(FLOW_TITLES)(
518
+ "the %s (i) reads NETWORK_DEVICE_METRIC_DESCRIPTIONS.%s",
519
+ async (title: string, key: NetworkDeviceMetric) => {
520
+ apiPostMock.mockResolvedValue(topTalkersResponse());
521
+
522
+ render(<FlowTopTalkers networkDeviceId={new ObjectID(DEVICE_ID)} />);
523
+ await flush();
524
+
525
+ await expectExplained(title, DESCRIPTIONS[key]);
526
+ },
527
+ );
528
+
529
+ test("no flow data yet: the setup hint, and no numbers to explain", async () => {
530
+ apiPostMock.mockResolvedValue({
531
+ data: { totalOctets: 0, totalPackets: 0, totalFlows: 0 },
532
+ });
533
+
534
+ render(<FlowTopTalkers networkDeviceId={new ObjectID(DEVICE_ID)} />);
535
+ await flush();
536
+
537
+ expect(screen.getByText("No flow data yet.")).toBeInTheDocument();
538
+ expect(infoButtonNames()).toEqual([]);
539
+ });
540
+
541
+ test("the query is the card's own window, which the texts call the selected range", async () => {
542
+ apiPostMock.mockResolvedValue(topTalkersResponse());
543
+
544
+ render(<FlowTopTalkers networkDeviceId={new ObjectID(DEVICE_ID)} />);
545
+ await flush();
546
+
547
+ const request: { data: Record<string, unknown> } = apiPostMock.mock
548
+ .calls[0]![0] as { data: Record<string, unknown> };
549
+
550
+ expect(request.data["networkDeviceId"]).toBe(DEVICE_ID);
551
+ expect(request.data["projectId"]).toBe(PROJECT_ID);
552
+ expect(typeof request.data["startTime"]).toBe("string");
553
+ expect(typeof request.data["endTime"]).toBe("string");
554
+ expect(DESCRIPTIONS.flowTotalTraffic).toContain("the selected range");
555
+ });
556
+ });