@oneuptime/common 14.0.2 → 14.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/AlertFeed.ts +2 -0
- package/Models/DatabaseModels/IncidentAlert.ts +540 -0
- package/Models/DatabaseModels/IncidentFeed.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/Project.ts +60 -0
- package/Models/DatabaseModels/RumApplication.ts +52 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
- package/Server/Services/IncidentAlertService.ts +1811 -0
- package/Server/Services/IncidentService.ts +210 -10
- package/Server/Services/IncidentStateTimelineService.ts +24 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
- package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
- package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
- package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
- package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
- package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
- package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
- package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
- package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
- package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
- package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
- package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
- package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
- package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
- package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
- package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
- package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
- package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
- package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
- package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
- package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
- package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
- package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
- package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
- package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
- package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
- package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
- package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
- package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
- package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
- package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
- package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
- package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
- package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
- package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
- package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
- package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
- package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
- package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
- package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
- package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
- package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
- package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
- package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
- package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
- package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
- package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
- package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
- package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
- package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
- package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
- package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
- package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
- package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
- package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
- package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
- package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
- package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
- package/Tests/Types/Rum/WebVitals.test.ts +173 -0
- package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
- package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
- package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
- package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
- package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
- package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
- package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
- package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
- package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
- package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
- package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
- package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
- package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
- package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
- package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
- package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
- package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
- package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
- package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
- package/Types/Incident/IncidentAlertLink.ts +38 -0
- package/Types/Permission.ts +48 -0
- package/Types/Rum/SessionReplay.ts +25 -6
- package/Types/Rum/WebVitals.ts +52 -0
- package/UI/Components/Detail/FieldLabel.tsx +8 -0
- package/UI/Components/InfoCard/InfoCard.tsx +47 -1
- package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
- package/UI/Components/ModelTable/Column.ts +5 -0
- package/UI/Components/Table/TableHeader.tsx +47 -2
- package/UI/Components/Table/Types/Column.ts +7 -0
- package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
- package/UI/Utils/PermissionGate.ts +11 -2
- package/Utils/Incident/IncidentFromAlerts.ts +516 -0
- package/Utils/Rum/SessionTraceState.ts +212 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
- package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
- package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +62 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
- package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
- package/build/dist/Server/Services/IncidentService.js +164 -12
- package/build/dist/Server/Services/IncidentService.js.map +1 -1
- package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
- package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
- package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
- package/build/dist/Types/Permission.js +42 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +25 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
- package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
- package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +18 -2
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
- package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
- package/build/dist/UI/Utils/PermissionGate.js +4 -2
- package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
- package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
- package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
- package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
- package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
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, waitFor } from "@testing-library/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import { MemoryRouter } from "react-router-dom";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The shared link dialog, rendered for real (BasicFormModal, BasicForm and
|
|
16
|
+
* the entity dropdown included): it shows a loader while the recent records
|
|
17
|
+
* are fetched, then the picker with its label and help text, inside a modal
|
|
18
|
+
* carrying the page's title, description and submit button. The flows
|
|
19
|
+
* behind it are covered with a stubbed modal in
|
|
20
|
+
* IncidentAlertLinkDialog.test.tsx and BulkIncidentLinkActions.test.tsx.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
jest.mock("react-i18next", () => {
|
|
24
|
+
return {
|
|
25
|
+
useTranslation: () => {
|
|
26
|
+
return {
|
|
27
|
+
t: (key: string, opts?: { defaultValue?: string }): string => {
|
|
28
|
+
return opts?.defaultValue ?? key;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
import LinkIncidentAlertModal from "../../../../App/FeatureSet/Dashboard/src/Components/IncidentAlert/LinkIncidentAlertModal";
|
|
36
|
+
import Alert from "../../../Models/DatabaseModels/Alert";
|
|
37
|
+
import { DropdownOption } from "../../../UI/Components/Dropdown/Dropdown";
|
|
38
|
+
|
|
39
|
+
type Deferred = {
|
|
40
|
+
promise: Promise<Array<DropdownOption>>;
|
|
41
|
+
resolve: (options: Array<DropdownOption>) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type MakeDeferredFunction = () => Deferred;
|
|
45
|
+
|
|
46
|
+
const makeDeferred: MakeDeferredFunction = (): Deferred => {
|
|
47
|
+
let resolve: (options: Array<DropdownOption>) => void = (): void => {};
|
|
48
|
+
const promise: Promise<Array<DropdownOption>> = new Promise<
|
|
49
|
+
Array<DropdownOption>
|
|
50
|
+
>((done: (options: Array<DropdownOption>) => void): void => {
|
|
51
|
+
resolve = done;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { promise, resolve };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("LinkIncidentAlertModal", () => {
|
|
58
|
+
let deferred: Deferred;
|
|
59
|
+
let loadOptionCalls: number = 0;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
deferred = makeDeferred();
|
|
63
|
+
loadOptionCalls = 0;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
cleanup();
|
|
68
|
+
jest.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("shows a loader until the recent records arrive, then the picker", async () => {
|
|
72
|
+
render(
|
|
73
|
+
<MemoryRouter>
|
|
74
|
+
<LinkIncidentAlertModal
|
|
75
|
+
title="Link Alert"
|
|
76
|
+
description="Select an alert to link to this incident."
|
|
77
|
+
submitButtonText="Link Alert"
|
|
78
|
+
fieldTitle="Alert"
|
|
79
|
+
fieldDescription="Recent alerts are listed with their number. Type to search every alert by title."
|
|
80
|
+
placeholder="Select an alert"
|
|
81
|
+
modelType={Alert}
|
|
82
|
+
loadOptions={(): Promise<Array<DropdownOption>> => {
|
|
83
|
+
loadOptionCalls++;
|
|
84
|
+
return deferred.promise;
|
|
85
|
+
}}
|
|
86
|
+
onSubmit={async (): Promise<void> => {
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}}
|
|
89
|
+
onClose={(): void => {}}
|
|
90
|
+
/>
|
|
91
|
+
</MemoryRouter>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// The modal's title and its submit button.
|
|
95
|
+
expect(screen.getAllByText("Link Alert")).toHaveLength(2);
|
|
96
|
+
expect(
|
|
97
|
+
screen.getByText("Select an alert to link to this incident."),
|
|
98
|
+
).toBeInTheDocument();
|
|
99
|
+
expect(loadOptionCalls).toBe(1);
|
|
100
|
+
expect(
|
|
101
|
+
screen.queryByText(
|
|
102
|
+
"Recent alerts are listed with their number. Type to search every alert by title.",
|
|
103
|
+
),
|
|
104
|
+
).not.toBeInTheDocument();
|
|
105
|
+
|
|
106
|
+
deferred.resolve([
|
|
107
|
+
{ label: "ALT-63: Checkout API is offline", value: "a-63" },
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(
|
|
112
|
+
screen.getByText(
|
|
113
|
+
"Recent alerts are listed with their number. Type to search every alert by title.",
|
|
114
|
+
),
|
|
115
|
+
).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(screen.getAllByText("Alert").length).toBeGreaterThan(0);
|
|
119
|
+
expect(loadOptionCalls).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("still shows the picker when the recent records cannot be read", async () => {
|
|
123
|
+
render(
|
|
124
|
+
<MemoryRouter>
|
|
125
|
+
<LinkIncidentAlertModal
|
|
126
|
+
title="Link Incident"
|
|
127
|
+
description="Select an incident to link this alert to."
|
|
128
|
+
submitButtonText="Link Incident"
|
|
129
|
+
fieldTitle="Incident"
|
|
130
|
+
fieldDescription="Recent incidents are listed with their number. Type to search every incident by title."
|
|
131
|
+
placeholder="Select an incident"
|
|
132
|
+
modelType={Alert}
|
|
133
|
+
loadOptions={async (): Promise<Array<DropdownOption>> => {
|
|
134
|
+
throw new Error("Forbidden");
|
|
135
|
+
}}
|
|
136
|
+
onSubmit={async (): Promise<void> => {
|
|
137
|
+
return Promise.resolve();
|
|
138
|
+
}}
|
|
139
|
+
onClose={(): void => {}}
|
|
140
|
+
/>
|
|
141
|
+
</MemoryRouter>,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
expect(
|
|
146
|
+
screen.getByText(
|
|
147
|
+
"Recent incidents are listed with their number. Type to search every incident by title.",
|
|
148
|
+
),
|
|
149
|
+
).toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(screen.queryByText("Forbidden")).not.toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { expect } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The rules every metric explanation in the dashboard is held to - the text
|
|
5
|
+
* a customer reads in the (i) tooltip beside a tile, chart or column title.
|
|
6
|
+
* Shared by the per-resource tests and by MetricDescriptionsCatalog.test.ts,
|
|
7
|
+
* which applies them to every module under
|
|
8
|
+
* App/FeatureSet/Dashboard/src/Components/MetricDescriptions.
|
|
9
|
+
*
|
|
10
|
+
* Not a test file itself (no .test. in the name), so jest does not run it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const MIN_DESCRIPTION_LENGTH: number = 25;
|
|
14
|
+
// A tooltip is 350px wide; past ~340 characters it stops being "small".
|
|
15
|
+
export const MAX_DESCRIPTION_LENGTH: number = 340;
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Words that give away a placeholder or an unfinished sentence rather than
|
|
19
|
+
* an explanation.
|
|
20
|
+
*/
|
|
21
|
+
const PLACEHOLDER_PATTERN: RegExp = /\b(TODO|TBD|FIXME|lorem|ipsum|XXX)\b/i;
|
|
22
|
+
const DOUBLE_SPACE_PATTERN: RegExp = /\s{2,}/;
|
|
23
|
+
const SENTENCE_END_PATTERN: RegExp = /[.!?)"']$/;
|
|
24
|
+
// A sentence, or a percentile token such as "p95 means ...".
|
|
25
|
+
const SENTENCE_START_PATTERN: RegExp = /^(p\d{2}\b|[A-Z0-9"'(])/;
|
|
26
|
+
const LEAKED_VALUE_PATTERN: RegExp =
|
|
27
|
+
/\bundefined\b|\bnull\b|\bNaN\b|\[object Object\]/;
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* A percentile named in the text has to be explained in the same text - the
|
|
31
|
+
* whole point of the tooltip is that "p95" means nothing to most readers.
|
|
32
|
+
*/
|
|
33
|
+
const PERCENTILE_EXPLANATIONS: Array<{
|
|
34
|
+
token: RegExp;
|
|
35
|
+
explained: RegExp;
|
|
36
|
+
name: string;
|
|
37
|
+
}> = [
|
|
38
|
+
{
|
|
39
|
+
token: /\bp50\b/i,
|
|
40
|
+
explained: /(50%|half|median|typical)/i,
|
|
41
|
+
name: "p50",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
token: /\bp75\b/i,
|
|
45
|
+
explained: /(75%|75th)/,
|
|
46
|
+
name: "p75",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
token: /\bp90\b/i,
|
|
50
|
+
explained: /(90%|90th)/,
|
|
51
|
+
name: "p90",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
token: /\bp95\b/i,
|
|
55
|
+
explained: /(95%|95th)/,
|
|
56
|
+
name: "p95",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
token: /\bp99\b/i,
|
|
60
|
+
explained: /(99%|99th)/,
|
|
61
|
+
name: "p99",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export function metricDescriptionProblems(text: unknown): Array<string> {
|
|
66
|
+
const problems: Array<string> = [];
|
|
67
|
+
|
|
68
|
+
if (typeof text !== "string") {
|
|
69
|
+
return [`is not a string (${typeof text})`];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (text !== text.trim()) {
|
|
73
|
+
problems.push("has leading or trailing whitespace");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (text.length < MIN_DESCRIPTION_LENGTH) {
|
|
77
|
+
problems.push(`is too short to explain anything (${text.length} chars)`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (text.length > MAX_DESCRIPTION_LENGTH) {
|
|
81
|
+
problems.push(
|
|
82
|
+
`is too long for a small tooltip (${text.length} > ${MAX_DESCRIPTION_LENGTH} chars)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (DOUBLE_SPACE_PATTERN.test(text)) {
|
|
87
|
+
problems.push("contains a double space or a line break");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!SENTENCE_END_PATTERN.test(text.trim())) {
|
|
91
|
+
problems.push("does not end like a sentence");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!SENTENCE_START_PATTERN.test(text.trim())) {
|
|
95
|
+
problems.push("does not start like a sentence");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (PLACEHOLDER_PATTERN.test(text)) {
|
|
99
|
+
problems.push("contains placeholder text");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (LEAKED_VALUE_PATTERN.test(text)) {
|
|
103
|
+
problems.push("contains a leaked programming value");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const percentile of PERCENTILE_EXPLANATIONS) {
|
|
107
|
+
if (percentile.token.test(text) && !percentile.explained.test(text)) {
|
|
108
|
+
problems.push(`names ${percentile.name} without explaining it`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return problems;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function expectReadableMetricDescription(
|
|
116
|
+
text: unknown,
|
|
117
|
+
context: string,
|
|
118
|
+
): void {
|
|
119
|
+
expect({ context, problems: metricDescriptionProblems(text) }).toEqual({
|
|
120
|
+
context,
|
|
121
|
+
problems: [],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* Every value of a description record passes the rules, and no two metrics
|
|
127
|
+
* on one page borrow the same words.
|
|
128
|
+
*/
|
|
129
|
+
export function expectReadableDescriptionRecord(
|
|
130
|
+
record: Record<string, unknown>,
|
|
131
|
+
context: string,
|
|
132
|
+
): void {
|
|
133
|
+
const entries: Array<[string, unknown]> = Object.entries(record);
|
|
134
|
+
|
|
135
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
136
|
+
|
|
137
|
+
for (const [key, text] of entries) {
|
|
138
|
+
expectReadableMetricDescription(text, `${context}.${key}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const seen: Map<string, string> = new Map<string, string>();
|
|
142
|
+
const duplicates: Array<string> = [];
|
|
143
|
+
|
|
144
|
+
for (const [key, text] of entries) {
|
|
145
|
+
const previous: string | undefined = seen.get(String(text));
|
|
146
|
+
|
|
147
|
+
if (previous) {
|
|
148
|
+
duplicates.push(`${context}.${key} repeats ${context}.${previous}`);
|
|
149
|
+
} else {
|
|
150
|
+
seen.set(String(text), key);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
expect(duplicates).toEqual([]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* A title that names a percentile gets a description that explains it -
|
|
159
|
+
* the tile "p95 duration" cannot be explained by "How long requests took."
|
|
160
|
+
*/
|
|
161
|
+
export function expectTitleExplained(title: string, text: string): void {
|
|
162
|
+
for (const percentile of PERCENTILE_EXPLANATIONS) {
|
|
163
|
+
if (percentile.token.test(title)) {
|
|
164
|
+
expect({ title, explains: percentile.explained.test(text) }).toEqual({
|
|
165
|
+
title,
|
|
166
|
+
explains: true,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import {
|
|
5
|
+
expectReadableDescriptionRecord,
|
|
6
|
+
metricDescriptionProblems,
|
|
7
|
+
} from "./MetricDescriptionRules";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* Every metric-explanation module in the dashboard, found on disk rather
|
|
11
|
+
* than listed by hand, so a new resource type's descriptions are held to the
|
|
12
|
+
* same rules the day they are added. Each module exports one or more
|
|
13
|
+
* Record<string, string> of tooltip texts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DESCRIPTIONS_DIR: string = path.join(
|
|
17
|
+
__dirname,
|
|
18
|
+
"..",
|
|
19
|
+
"..",
|
|
20
|
+
"..",
|
|
21
|
+
"..",
|
|
22
|
+
"App",
|
|
23
|
+
"FeatureSet",
|
|
24
|
+
"Dashboard",
|
|
25
|
+
"src",
|
|
26
|
+
"Components",
|
|
27
|
+
"MetricDescriptions",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const MODULE_FILES: Array<string> = fs
|
|
31
|
+
.readdirSync(DESCRIPTIONS_DIR)
|
|
32
|
+
.filter((file: string): boolean => {
|
|
33
|
+
return file.endsWith(".ts") && !file.endsWith(".d.ts");
|
|
34
|
+
})
|
|
35
|
+
.sort();
|
|
36
|
+
|
|
37
|
+
const DASHBOARD_SRC: string = path.join(DESCRIPTIONS_DIR, "..", "..");
|
|
38
|
+
const SOURCE_FILE_PATTERN: RegExp = /\.(ts|tsx)$/;
|
|
39
|
+
|
|
40
|
+
function listSources(directory: string): Array<string> {
|
|
41
|
+
const files: Array<string> = [];
|
|
42
|
+
|
|
43
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
44
|
+
const full: string = path.join(directory, entry.name);
|
|
45
|
+
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
files.push(...listSources(full));
|
|
48
|
+
} else if (SOURCE_FILE_PATTERN.test(entry.name)) {
|
|
49
|
+
files.push(full);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every dashboard source except the description modules themselves.
|
|
57
|
+
const DASHBOARD_SOURCES: Array<string> = listSources(DASHBOARD_SRC)
|
|
58
|
+
.filter((file: string): boolean => {
|
|
59
|
+
return path.dirname(file) !== DESCRIPTIONS_DIR;
|
|
60
|
+
})
|
|
61
|
+
.map((file: string): string => {
|
|
62
|
+
return fs.readFileSync(file, "utf8");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
type DescriptionModule = Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
function loadModule(file: string): DescriptionModule {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
|
69
|
+
return require(path.join(DESCRIPTIONS_DIR, file)) as DescriptionModule;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function descriptionRecords(
|
|
73
|
+
module: DescriptionModule,
|
|
74
|
+
): Array<[string, Record<string, unknown>]> {
|
|
75
|
+
return Object.entries(module).filter(
|
|
76
|
+
(entry: [string, unknown]): entry is [string, Record<string, unknown>] => {
|
|
77
|
+
const value: unknown = entry[1];
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
Boolean(value) &&
|
|
81
|
+
typeof value === "object" &&
|
|
82
|
+
!Array.isArray(value) &&
|
|
83
|
+
Object.values(value as Record<string, unknown>).every(
|
|
84
|
+
(v: unknown): boolean => {
|
|
85
|
+
return typeof v === "string";
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("metric description modules", () => {
|
|
94
|
+
test("the directory exists and holds modules", () => {
|
|
95
|
+
expect(MODULE_FILES.length).toBeGreaterThan(0);
|
|
96
|
+
expect(MODULE_FILES).toContain("RumMetricDescriptions.ts");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test.each(MODULE_FILES)(
|
|
100
|
+
"%s is plain data - no React, no API client",
|
|
101
|
+
(file: string) => {
|
|
102
|
+
const source: string = fs.readFileSync(
|
|
103
|
+
path.join(DESCRIPTIONS_DIR, file),
|
|
104
|
+
"utf8",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* The App suite imports these from a node test; anything that reaches
|
|
109
|
+
* react or Common/UI/Config would break it.
|
|
110
|
+
*/
|
|
111
|
+
expect(source).not.toMatch(/from\s+["']react["']/);
|
|
112
|
+
expect(source).not.toMatch(/Common\/UI\//);
|
|
113
|
+
expect(source).not.toMatch(/\.tsx["']/);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
test.each(MODULE_FILES)(
|
|
118
|
+
"%s exports at least one description record",
|
|
119
|
+
(file: string) => {
|
|
120
|
+
expect(descriptionRecords(loadModule(file)).length).toBeGreaterThan(0);
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
test.each(MODULE_FILES)(
|
|
125
|
+
"%s: every description reads as a short, finished, jargon-explaining sentence",
|
|
126
|
+
(file: string) => {
|
|
127
|
+
for (const [exportName, record] of descriptionRecords(loadModule(file))) {
|
|
128
|
+
expectReadableDescriptionRecord(record, `${file}:${exportName}`);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* A description nobody reads is either a tile that lost its tooltip or
|
|
135
|
+
* text for a metric that no longer exists. Every key has to be referenced
|
|
136
|
+
* from a page or component as RECORD.key.
|
|
137
|
+
*/
|
|
138
|
+
test.each(MODULE_FILES)(
|
|
139
|
+
"%s: every description is shown somewhere in the dashboard",
|
|
140
|
+
(file: string) => {
|
|
141
|
+
const unused: Array<string> = [];
|
|
142
|
+
|
|
143
|
+
for (const [exportName, record] of descriptionRecords(loadModule(file))) {
|
|
144
|
+
for (const key of Object.keys(record)) {
|
|
145
|
+
const reference: RegExp = new RegExp(
|
|
146
|
+
`\\b${exportName}\\s*(\\.\\s*${key}\\b|\\[\\s*["']${key}["']\\s*\\])`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
!DASHBOARD_SOURCES.some((source: string): boolean => {
|
|
151
|
+
return reference.test(source);
|
|
152
|
+
})
|
|
153
|
+
) {
|
|
154
|
+
unused.push(`${exportName}.${key}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
expect(unused).toEqual([]);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("the rules themselves", () => {
|
|
165
|
+
test("accept a good explanation", () => {
|
|
166
|
+
expect(
|
|
167
|
+
metricDescriptionProblems(
|
|
168
|
+
"p95 means 95% of requests finished faster than this, and the slowest 5% took longer.",
|
|
169
|
+
),
|
|
170
|
+
).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("reject a percentile that is named but not explained", () => {
|
|
174
|
+
expect(
|
|
175
|
+
metricDescriptionProblems("The p95 latency of requests in the range."),
|
|
176
|
+
).toContain("names p95 without explaining it");
|
|
177
|
+
expect(
|
|
178
|
+
metricDescriptionProblems("The p99 latency of requests in the range."),
|
|
179
|
+
).toContain("names p99 without explaining it");
|
|
180
|
+
expect(
|
|
181
|
+
metricDescriptionProblems("The p50 latency of requests in the range."),
|
|
182
|
+
).toContain("names p50 without explaining it");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("reject placeholders, leaked values and unfinished text", () => {
|
|
186
|
+
expect(
|
|
187
|
+
metricDescriptionProblems("TODO explain this metric properly."),
|
|
188
|
+
).toContain("contains placeholder text");
|
|
189
|
+
expect(
|
|
190
|
+
metricDescriptionProblems("The value is undefined for this resource."),
|
|
191
|
+
).toContain("contains a leaked programming value");
|
|
192
|
+
expect(
|
|
193
|
+
metricDescriptionProblems("How long requests took on this service"),
|
|
194
|
+
).toContain("does not end like a sentence");
|
|
195
|
+
expect(metricDescriptionProblems("CPU.")).toContain(
|
|
196
|
+
"is too short to explain anything (4 chars)",
|
|
197
|
+
);
|
|
198
|
+
expect(
|
|
199
|
+
metricDescriptionProblems(`${"Long words. ".repeat(40)}`.trim()),
|
|
200
|
+
).toEqual(expect.arrayContaining([expect.stringContaining("is too long")]));
|
|
201
|
+
expect(
|
|
202
|
+
metricDescriptionProblems("Share of requests that failed in the range."),
|
|
203
|
+
).toContain("contains a double space or a line break");
|
|
204
|
+
expect(metricDescriptionProblems(42)).toEqual(["is not a string (number)"]);
|
|
205
|
+
});
|
|
206
|
+
});
|