@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,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ParsedSessionTraceState,
|
|
3
|
+
SESSION_TRACE_STATE_KEY,
|
|
4
|
+
buildSessionTraceStateMember,
|
|
5
|
+
parseSessionTraceState,
|
|
6
|
+
} from "../../../Utils/Rum/SessionTraceState";
|
|
7
|
+
|
|
8
|
+
const SESSION_ID: string = "0123456789abcdef0123456789abcdef";
|
|
9
|
+
const PARENT_ID: string = "fedcba9876543210";
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The W3C / OpenTelemetry validation rules for a tracestate member, copied
|
|
13
|
+
* from @opentelemetry/core's validators. A member that fails them is
|
|
14
|
+
* dropped by the backend's propagator before any span sees it.
|
|
15
|
+
*/
|
|
16
|
+
const W3C_KEY_PATTERN: RegExp =
|
|
17
|
+
/^(?:[a-z][_0-9a-z\-*/]{0,255}|[a-z0-9][_0-9a-z\-*/]{0,240}@[a-z][_0-9a-z\-*/]{0,13})$/;
|
|
18
|
+
const W3C_VALUE_PATTERN: RegExp = /^[ -~]{0,255}[!-~]$/;
|
|
19
|
+
const W3C_VALUE_FORBIDDEN: RegExp = /,|=/;
|
|
20
|
+
|
|
21
|
+
describe("SessionTraceState", () => {
|
|
22
|
+
describe("buildSessionTraceStateMember", () => {
|
|
23
|
+
it("writes the session id under the oneuptime key", () => {
|
|
24
|
+
expect(buildSessionTraceStateMember(SESSION_ID)).toBe(
|
|
25
|
+
`oneuptime=sid:${SESSION_ID}`,
|
|
26
|
+
);
|
|
27
|
+
expect(SESSION_TRACE_STATE_KEY).toBe("oneuptime");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("adds the synthetic parent id only when one is given", () => {
|
|
31
|
+
expect(buildSessionTraceStateMember(SESSION_ID, PARENT_ID)).toBe(
|
|
32
|
+
`oneuptime=sid:${SESSION_ID};p:${PARENT_ID}`,
|
|
33
|
+
);
|
|
34
|
+
expect(buildSessionTraceStateMember(SESSION_ID, null)).toBe(
|
|
35
|
+
`oneuptime=sid:${SESSION_ID}`,
|
|
36
|
+
);
|
|
37
|
+
expect(buildSessionTraceStateMember(SESSION_ID, "")).toBe(
|
|
38
|
+
`oneuptime=sid:${SESSION_ID}`,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("produces a member every W3C tracestate parser accepts", () => {
|
|
43
|
+
for (const member of [
|
|
44
|
+
buildSessionTraceStateMember(SESSION_ID),
|
|
45
|
+
buildSessionTraceStateMember(SESSION_ID, PARENT_ID),
|
|
46
|
+
]) {
|
|
47
|
+
const [key, value] = member.split("=") as [string, string];
|
|
48
|
+
|
|
49
|
+
expect(W3C_KEY_PATTERN.test(key)).toBe(true);
|
|
50
|
+
expect(W3C_VALUE_PATTERN.test(value)).toBe(true);
|
|
51
|
+
expect(W3C_VALUE_FORBIDDEN.test(value)).toBe(false);
|
|
52
|
+
expect(member.length).toBeLessThanOrEqual(512);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("parseSessionTraceState", () => {
|
|
58
|
+
it("round-trips what the recorder writes", () => {
|
|
59
|
+
expect(
|
|
60
|
+
parseSessionTraceState(buildSessionTraceStateMember(SESSION_ID)),
|
|
61
|
+
).toEqual({
|
|
62
|
+
sessionTraceState: {
|
|
63
|
+
sessionId: SESSION_ID,
|
|
64
|
+
syntheticParentSpanId: null,
|
|
65
|
+
},
|
|
66
|
+
remainingTraceState: "",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(
|
|
70
|
+
parseSessionTraceState(
|
|
71
|
+
buildSessionTraceStateMember(SESSION_ID, PARENT_ID),
|
|
72
|
+
),
|
|
73
|
+
).toEqual({
|
|
74
|
+
sessionTraceState: {
|
|
75
|
+
sessionId: SESSION_ID,
|
|
76
|
+
syntheticParentSpanId: PARENT_ID,
|
|
77
|
+
},
|
|
78
|
+
remainingTraceState: "",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("finds the member among other vendors' members and strips only ours", () => {
|
|
83
|
+
const parsed: ParsedSessionTraceState = parseSessionTraceState(
|
|
84
|
+
`dd=s:1;o:rum, oneuptime=sid:${SESSION_ID} ,congo=t61rcWkgMzE,tenant@vendor=x`,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(parsed.sessionTraceState?.sessionId).toBe(SESSION_ID);
|
|
88
|
+
expect(parsed.remainingTraceState).toBe(
|
|
89
|
+
"dd=s:1;o:rum,congo=t61rcWkgMzE,tenant@vendor=x",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("tolerates optional whitespace and tabs around members and fields", () => {
|
|
94
|
+
const parsed: ParsedSessionTraceState = parseSessionTraceState(
|
|
95
|
+
`\toneuptime = sid:${SESSION_ID} ; p:${PARENT_ID}\t`,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(parsed.sessionTraceState).toEqual({
|
|
99
|
+
sessionId: SESSION_ID,
|
|
100
|
+
syntheticParentSpanId: PARENT_ID,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("reads fields in any order and ignores unknown ones", () => {
|
|
105
|
+
expect(
|
|
106
|
+
parseSessionTraceState(
|
|
107
|
+
`oneuptime=v:2;p:${PARENT_ID};x:y;sid:${SESSION_ID}`,
|
|
108
|
+
).sessionTraceState,
|
|
109
|
+
).toEqual({
|
|
110
|
+
sessionId: SESSION_ID,
|
|
111
|
+
syntheticParentSpanId: PARENT_ID,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("lowercases hex ids", () => {
|
|
116
|
+
expect(
|
|
117
|
+
parseSessionTraceState(
|
|
118
|
+
`oneuptime=sid:${SESSION_ID.toUpperCase()};p:${PARENT_ID.toUpperCase()}`,
|
|
119
|
+
).sessionTraceState,
|
|
120
|
+
).toEqual({
|
|
121
|
+
sessionId: SESSION_ID,
|
|
122
|
+
syntheticParentSpanId: PARENT_ID,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("takes the first oneuptime member and strips every one", () => {
|
|
127
|
+
const other: string = "ffffffffffffffffffffffffffffffff";
|
|
128
|
+
const parsed: ParsedSessionTraceState = parseSessionTraceState(
|
|
129
|
+
`oneuptime=sid:${SESSION_ID},a=b,oneuptime=sid:${other}`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(parsed.sessionTraceState?.sessionId).toBe(SESSION_ID);
|
|
133
|
+
expect(parsed.remainingTraceState).toBe("a=b");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("strips a malformed member of ours but yields no session", () => {
|
|
137
|
+
for (const member of [
|
|
138
|
+
"oneuptime=sid:not-hex",
|
|
139
|
+
"oneuptime=sid:0123",
|
|
140
|
+
`oneuptime=sid:${SESSION_ID}00`,
|
|
141
|
+
"oneuptime=sid:00000000000000000000000000000000",
|
|
142
|
+
"oneuptime=p:fedcba9876543210",
|
|
143
|
+
"oneuptime=",
|
|
144
|
+
"oneuptime",
|
|
145
|
+
`oneuptime=${SESSION_ID}`,
|
|
146
|
+
]) {
|
|
147
|
+
const parsed: ParsedSessionTraceState = parseSessionTraceState(
|
|
148
|
+
`${member},keep=me`,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(parsed.sessionTraceState).toBeNull();
|
|
152
|
+
expect(parsed.remainingTraceState).toBe("keep=me");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("keeps the session when only the parent id is malformed", () => {
|
|
157
|
+
for (const parent of ["xyz", "0000000000000000", "fedcba98765432", ""]) {
|
|
158
|
+
expect(
|
|
159
|
+
parseSessionTraceState(`oneuptime=sid:${SESSION_ID};p:${parent}`)
|
|
160
|
+
.sessionTraceState,
|
|
161
|
+
).toEqual({ sessionId: SESSION_ID, syntheticParentSpanId: null });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does not match keys that only contain the word", () => {
|
|
166
|
+
const value: string = `acme@oneuptime=sid:${SESSION_ID},oneuptimex=sid:${SESSION_ID}`;
|
|
167
|
+
const parsed: ParsedSessionTraceState = parseSessionTraceState(value);
|
|
168
|
+
|
|
169
|
+
expect(parsed.sessionTraceState).toBeNull();
|
|
170
|
+
expect(parsed.remainingTraceState).toBe(value);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("returns the value untouched when there is no member of ours", () => {
|
|
174
|
+
expect(parseSessionTraceState("rojo=00f067aa0ba902b7")).toEqual({
|
|
175
|
+
sessionTraceState: null,
|
|
176
|
+
remainingTraceState: "rojo=00f067aa0ba902b7",
|
|
177
|
+
});
|
|
178
|
+
expect(parseSessionTraceState("")).toEqual({
|
|
179
|
+
sessionTraceState: null,
|
|
180
|
+
remainingTraceState: "",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("never throws on non-string input", () => {
|
|
185
|
+
for (const value of [
|
|
186
|
+
undefined,
|
|
187
|
+
null,
|
|
188
|
+
42,
|
|
189
|
+
true,
|
|
190
|
+
{},
|
|
191
|
+
[`oneuptime=sid:${SESSION_ID}`],
|
|
192
|
+
]) {
|
|
193
|
+
expect(parseSessionTraceState(value)).toEqual({
|
|
194
|
+
sessionTraceState: null,
|
|
195
|
+
remainingTraceState: "",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("reads no session out of oversized or over-long lists but still strips ours", () => {
|
|
201
|
+
const others: string = "a=b,".repeat(3000);
|
|
202
|
+
const huge: string = `oneuptime=sid:${SESSION_ID};p:${PARENT_ID},${others}`;
|
|
203
|
+
const parsedHuge: ParsedSessionTraceState = parseSessionTraceState(huge);
|
|
204
|
+
|
|
205
|
+
expect(parsedHuge.sessionTraceState).toBeNull();
|
|
206
|
+
expect(parsedHuge.remainingTraceState).toBe("a=b,".repeat(2999) + "a=b");
|
|
207
|
+
expect(parsedHuge.remainingTraceState).not.toContain(SESSION_ID);
|
|
208
|
+
|
|
209
|
+
const many: string = `${"k=v,".repeat(80)}oneuptime=sid:${SESSION_ID}`;
|
|
210
|
+
const parsedMany: ParsedSessionTraceState = parseSessionTraceState(many);
|
|
211
|
+
|
|
212
|
+
expect(parsedMany.sessionTraceState).toBeNull();
|
|
213
|
+
expect(parsedMany.remainingTraceState).toBe("k=v,".repeat(79) + "k=v");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("leaves an oversized list with no member of ours untouched", () => {
|
|
217
|
+
const huge: string = "a=b,".repeat(3000);
|
|
218
|
+
expect(parseSessionTraceState(huge)).toEqual({
|
|
219
|
+
sessionTraceState: null,
|
|
220
|
+
remainingTraceState: huge,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("accepts a long but legitimate list that ends with our member", () => {
|
|
225
|
+
const others: Array<string> = [];
|
|
226
|
+
|
|
227
|
+
for (let i: number = 0; i < 20; i++) {
|
|
228
|
+
others.push(`vendor${i}=${"x".repeat(30)}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const value: string = `${others.join(",")},oneuptime=sid:${SESSION_ID}`;
|
|
232
|
+
expect(value.length).toBeGreaterThan(600);
|
|
233
|
+
|
|
234
|
+
const parsed: ParsedSessionTraceState = parseSessionTraceState(value);
|
|
235
|
+
expect(parsed.sessionTraceState?.sessionId).toBe(SESSION_ID);
|
|
236
|
+
expect(parsed.remainingTraceState).toBe(others.join(","));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared between the dashboard and the server for linking alerts to incidents
|
|
3
|
+
* (the IncidentAlert model).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Declaring an incident from alerts sends the alert ids along with the
|
|
8
|
+
* incident as `miscDataProps[INCIDENT_ALERT_IDS_TO_LINK_KEY]` (an array of id
|
|
9
|
+
* strings). IncidentService validates them before the incident is created and
|
|
10
|
+
* links them once it exists, so API clients get the same one-request flow as
|
|
11
|
+
* the dashboard.
|
|
12
|
+
*/
|
|
13
|
+
export const INCIDENT_ALERT_IDS_TO_LINK_KEY: string = "alertIdsToLink";
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* The create-incident page reads the alerts to declare an incident from out of
|
|
17
|
+
* this query string parameter, as a comma separated list of alert ids.
|
|
18
|
+
*/
|
|
19
|
+
export const INCIDENT_CREATE_ALERT_IDS_QUERY_PARAM: string = "alertIds";
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The most alerts one action can link to an incident: declaring an incident
|
|
23
|
+
* from alerts, or linking a selection of alerts from the alerts table. Every
|
|
24
|
+
* link writes a feed entry on the incident and the alert, and the incident's
|
|
25
|
+
* entry is posted to its Slack / Microsoft Teams channels, so an unbounded
|
|
26
|
+
* "select all" would flood both. It also keeps the alert ids within what a URL
|
|
27
|
+
* can carry.
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_ALERTS_PER_INCIDENT_LINK_ACTION: number = 50;
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* What linking a pair that is already linked answers with - whether the
|
|
33
|
+
* duplicate is caught by the model's unique-together check or, when two
|
|
34
|
+
* requests race past it, by the unique index itself. The dashboard counts a
|
|
35
|
+
* link that fails with this message as done.
|
|
36
|
+
*/
|
|
37
|
+
export const INCIDENT_ALERT_ALREADY_LINKED_MESSAGE: string =
|
|
38
|
+
"This alert is already linked to this incident.";
|
package/Types/Permission.ts
CHANGED
|
@@ -1851,6 +1851,12 @@ enum Permission {
|
|
|
1851
1851
|
EditIncidentEpisodeMember = "EditIncidentEpisodeMember",
|
|
1852
1852
|
ReadIncidentEpisodeMember = "ReadIncidentEpisodeMember",
|
|
1853
1853
|
|
|
1854
|
+
// Incident Alert (alerts linked to an incident) Permissions
|
|
1855
|
+
CreateIncidentAlert = "CreateIncidentAlert",
|
|
1856
|
+
DeleteIncidentAlert = "DeleteIncidentAlert",
|
|
1857
|
+
EditIncidentAlert = "EditIncidentAlert",
|
|
1858
|
+
ReadIncidentAlert = "ReadIncidentAlert",
|
|
1859
|
+
|
|
1854
1860
|
// Incident Episode State Timeline Permissions
|
|
1855
1861
|
CreateIncidentEpisodeStateTimeline = "CreateIncidentEpisodeStateTimeline",
|
|
1856
1862
|
DeleteIncidentEpisodeStateTimeline = "DeleteIncidentEpisodeStateTimeline",
|
|
@@ -15015,6 +15021,48 @@ export class PermissionHelper {
|
|
|
15015
15021
|
group: PermissionGroup.Incident,
|
|
15016
15022
|
},
|
|
15017
15023
|
|
|
15024
|
+
// Incident Alert (alerts linked to an incident) Permissions
|
|
15025
|
+
{
|
|
15026
|
+
permission: Permission.CreateIncidentAlert,
|
|
15027
|
+
title: "Create Incident Alert",
|
|
15028
|
+
description:
|
|
15029
|
+
"This permission can link alerts to incidents in this project.",
|
|
15030
|
+
isAssignableToTenant: true,
|
|
15031
|
+
isAccessControlPermission: false,
|
|
15032
|
+
isRolePermission: false,
|
|
15033
|
+
group: PermissionGroup.Incident,
|
|
15034
|
+
},
|
|
15035
|
+
{
|
|
15036
|
+
permission: Permission.DeleteIncidentAlert,
|
|
15037
|
+
title: "Delete Incident Alert",
|
|
15038
|
+
description:
|
|
15039
|
+
"This permission can unlink alerts from incidents in this project.",
|
|
15040
|
+
isAssignableToTenant: true,
|
|
15041
|
+
isAccessControlPermission: false,
|
|
15042
|
+
isRolePermission: false,
|
|
15043
|
+
group: PermissionGroup.Incident,
|
|
15044
|
+
},
|
|
15045
|
+
{
|
|
15046
|
+
permission: Permission.EditIncidentAlert,
|
|
15047
|
+
title: "Edit Incident Alert",
|
|
15048
|
+
description:
|
|
15049
|
+
"This permission can edit the links between alerts and incidents in this project.",
|
|
15050
|
+
isAssignableToTenant: true,
|
|
15051
|
+
isAccessControlPermission: false,
|
|
15052
|
+
isRolePermission: false,
|
|
15053
|
+
group: PermissionGroup.Incident,
|
|
15054
|
+
},
|
|
15055
|
+
{
|
|
15056
|
+
permission: Permission.ReadIncidentAlert,
|
|
15057
|
+
title: "Read Incident Alert",
|
|
15058
|
+
description:
|
|
15059
|
+
"This permission can read which alerts are linked to which incidents in this project.",
|
|
15060
|
+
isAssignableToTenant: true,
|
|
15061
|
+
isAccessControlPermission: false,
|
|
15062
|
+
isRolePermission: false,
|
|
15063
|
+
group: PermissionGroup.Incident,
|
|
15064
|
+
},
|
|
15065
|
+
|
|
15018
15066
|
// Incident Episode State Timeline Permissions
|
|
15019
15067
|
{
|
|
15020
15068
|
permission: Permission.CreateIncidentEpisodeStateTimeline,
|
|
@@ -851,19 +851,38 @@ export interface SessionReplayConfigResponse {
|
|
|
851
851
|
*/
|
|
852
852
|
|
|
853
853
|
/*
|
|
854
|
-
*
|
|
855
|
-
* recording links to the backend trace of the request that
|
|
856
|
-
* with NO OTel browser instrumentation on the customer's page.
|
|
854
|
+
* CROSS-origin APIs the recorder may inject a W3C traceparent header
|
|
855
|
+
* into, so a recording links to the backend trace of the request that
|
|
856
|
+
* failed — with NO OTel browser instrumentation on the customer's page.
|
|
857
|
+
* Listed origins get traceparent only, never the session id. The page's
|
|
858
|
+
* own origin needs no entry: see sameOriginTracePropagation.
|
|
857
859
|
*
|
|
858
|
-
* Empty means
|
|
859
|
-
* adding a request header turns a simple cross-origin request
|
|
860
|
-
* preflighted one, and an API that does not allow traceparent in
|
|
860
|
+
* Empty means no cross-origin injection, and that default is the safety
|
|
861
|
+
* mechanism: adding a request header turns a simple cross-origin request
|
|
862
|
+
* into a preflighted one, and an API that does not allow traceparent in
|
|
861
863
|
* Access-Control-Allow-Headers would start failing because a RUM
|
|
862
864
|
* script was installed. Each listed origin is an explicit statement
|
|
863
865
|
* that its API accepts the header.
|
|
864
866
|
*/
|
|
865
867
|
tracePropagationOrigins?: Array<string>;
|
|
866
868
|
|
|
869
|
+
/*
|
|
870
|
+
* RumApplication.sessionReplaySameOriginTracePropagation. When true, the
|
|
871
|
+
* recorder adds a traceparent (unless the request or an inner tracing
|
|
872
|
+
* instrumentation already sets one) and a tracestate member carrying the
|
|
873
|
+
* replay session id (`oneuptime=sid:<id>`, see Utils/Rum/SessionTraceState)
|
|
874
|
+
* to fetch/XHR requests to the page's OWN origin, and only while the
|
|
875
|
+
* session is uploading with consent. Span ingest reads the member back,
|
|
876
|
+
* so backend spans link to the recording with no customer code.
|
|
877
|
+
*
|
|
878
|
+
* A same-origin request is never preflighted, so this needs no origin
|
|
879
|
+
* list and defaults on; the switch turns it off per application without
|
|
880
|
+
* a customer redeploy. The server always sends an explicit boolean (false
|
|
881
|
+
* on every disabled response) and the recorder reads anything but `true`
|
|
882
|
+
* as off, so an older server or a cached stub never turns it on.
|
|
883
|
+
*/
|
|
884
|
+
sameOriginTracePropagation?: boolean;
|
|
885
|
+
|
|
867
886
|
/*
|
|
868
887
|
* Performance capture budgets, milliseconds; 0 disables that trigger.
|
|
869
888
|
* Exceeding a budget fires the Performance capture trigger, so slow
|
package/Types/Rum/WebVitals.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export interface WebVitalDefinition {
|
|
2
2
|
key: string;
|
|
3
3
|
label: string;
|
|
4
|
+
/*
|
|
5
|
+
* What the vital measures, in words a non-specialist can act on. The
|
|
6
|
+
* Good / Poor limits are not repeated here - describeWebVitalThresholds
|
|
7
|
+
* derives them from `thresholds`, so the two cannot disagree.
|
|
8
|
+
*/
|
|
9
|
+
description: string;
|
|
4
10
|
unit: "ms" | "score";
|
|
5
11
|
thresholds: { warn: number; danger: number };
|
|
6
12
|
names: Array<string>;
|
|
@@ -11,6 +17,8 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
|
|
|
11
17
|
{
|
|
12
18
|
key: "lcp",
|
|
13
19
|
label: "Largest Contentful Paint",
|
|
20
|
+
description:
|
|
21
|
+
"How long until the biggest image or block of text on screen has rendered - roughly when the page looks loaded to the visitor.",
|
|
14
22
|
unit: "ms",
|
|
15
23
|
thresholds: { warn: 2500, danger: 4000 },
|
|
16
24
|
names: [
|
|
@@ -23,6 +31,8 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
|
|
|
23
31
|
{
|
|
24
32
|
key: "inp",
|
|
25
33
|
label: "Interaction to Next Paint",
|
|
34
|
+
description:
|
|
35
|
+
"How quickly the page reacts to clicks, taps and key presses: the delay before the screen visibly updates, for close to the slowest interaction on the page.",
|
|
26
36
|
unit: "ms",
|
|
27
37
|
thresholds: { warn: 200, danger: 500 },
|
|
28
38
|
names: [
|
|
@@ -35,6 +45,8 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
|
|
|
35
45
|
{
|
|
36
46
|
key: "cls",
|
|
37
47
|
label: "Cumulative Layout Shift",
|
|
48
|
+
description:
|
|
49
|
+
"How much the page jumps around while it loads - content moving under the visitor's cursor or finger. A score, not a time: lower is better and 0 means nothing moved.",
|
|
38
50
|
unit: "score",
|
|
39
51
|
thresholds: { warn: 0.1, danger: 0.25 },
|
|
40
52
|
names: [
|
|
@@ -47,6 +59,8 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
|
|
|
47
59
|
{
|
|
48
60
|
key: "fcp",
|
|
49
61
|
label: "First Contentful Paint",
|
|
62
|
+
description:
|
|
63
|
+
"How long until the first text or image appears, so the visitor can see the page has started loading.",
|
|
50
64
|
unit: "ms",
|
|
51
65
|
thresholds: { warn: 1800, danger: 3000 },
|
|
52
66
|
names: [
|
|
@@ -59,6 +73,8 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
|
|
|
59
73
|
{
|
|
60
74
|
key: "ttfb",
|
|
61
75
|
label: "Time to First Byte",
|
|
76
|
+
description:
|
|
77
|
+
"How long the browser waited for the first byte of the page from your server, including redirects, looking up the address and connecting.",
|
|
62
78
|
unit: "ms",
|
|
63
79
|
thresholds: { warn: 800, danger: 1800 },
|
|
64
80
|
names: [
|
|
@@ -69,3 +85,39 @@ export const WebVitalDefinitions: Array<WebVitalDefinition> = [
|
|
|
69
85
|
],
|
|
70
86
|
},
|
|
71
87
|
];
|
|
88
|
+
|
|
89
|
+
// A threshold the way a person would say it: "2.5 s", "800 ms", "0.1".
|
|
90
|
+
export function formatWebVitalThreshold(
|
|
91
|
+
value: number,
|
|
92
|
+
unit: WebVitalDefinition["unit"],
|
|
93
|
+
): string {
|
|
94
|
+
if (unit === "score") {
|
|
95
|
+
return String(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (value < 1000) {
|
|
99
|
+
return `${value} ms`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return `${Number((value / 1000).toFixed(2))} s`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
* The Good / Poor limits in words, matching how the overview rates a value:
|
|
107
|
+
* below `warn` is Good, at or above `danger` is Poor, and anything between
|
|
108
|
+
* needs work.
|
|
109
|
+
*/
|
|
110
|
+
export function describeWebVitalThresholds(
|
|
111
|
+
definition: Pick<WebVitalDefinition, "unit" | "thresholds">,
|
|
112
|
+
): string {
|
|
113
|
+
const warn: string = formatWebVitalThreshold(
|
|
114
|
+
definition.thresholds.warn,
|
|
115
|
+
definition.unit,
|
|
116
|
+
);
|
|
117
|
+
const danger: string = formatWebVitalThreshold(
|
|
118
|
+
definition.thresholds.danger,
|
|
119
|
+
definition.unit,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return `Good below ${warn}; poor at ${danger} or more.`;
|
|
123
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Link from "../Link/Link";
|
|
2
2
|
import { DetailSideLink } from "./Field";
|
|
3
3
|
import useTranslateValue from "../../Utils/Translation";
|
|
4
|
+
import InfoTooltip from "../Tooltip/InfoTooltip";
|
|
4
5
|
import React, { FunctionComponent, ReactElement } from "react";
|
|
5
6
|
|
|
6
7
|
export enum Size {
|
|
@@ -16,6 +17,8 @@ export interface ComponentProps {
|
|
|
16
17
|
sideLink?: DetailSideLink | undefined;
|
|
17
18
|
size?: Size | undefined;
|
|
18
19
|
isCardStyle?: boolean | undefined;
|
|
20
|
+
// What the field means, shown in an (i) tooltip beside the title.
|
|
21
|
+
tooltip?: string | undefined;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const FieldLabelElement: FunctionComponent<ComponentProps> = (
|
|
@@ -40,6 +43,11 @@ const FieldLabelElement: FunctionComponent<ComponentProps> = (
|
|
|
40
43
|
>
|
|
41
44
|
<span className={`${props.alignClassName} flex items-center gap-1.5`}>
|
|
42
45
|
{translatedTitle}
|
|
46
|
+
<InfoTooltip
|
|
47
|
+
label={translatedTitle}
|
|
48
|
+
text={props.tooltip}
|
|
49
|
+
className="relative z-10 normal-case tracking-normal"
|
|
50
|
+
/>
|
|
43
51
|
<span className="w-1 h-1 rounded-full bg-gray-300"></span>
|
|
44
52
|
</span>
|
|
45
53
|
{props.sideLink && translatedSideLinkText && props.sideLink?.url && (
|
|
@@ -20,6 +20,8 @@ export interface ComponentProps {
|
|
|
20
20
|
isSelected?: boolean | undefined;
|
|
21
21
|
// Announced in place of the title when the card is clickable.
|
|
22
22
|
ariaLabel?: string | undefined;
|
|
23
|
+
// What the value means, shown in an (i) tooltip beside the title.
|
|
24
|
+
tooltip?: string | undefined;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const InfoCard: FunctionComponent<ComponentProps> = (
|
|
@@ -37,6 +39,50 @@ const InfoCard: FunctionComponent<ComponentProps> = (
|
|
|
37
39
|
? "border-indigo-500 ring-1 ring-indigo-500"
|
|
38
40
|
: "border-gray-200";
|
|
39
41
|
|
|
42
|
+
/*
|
|
43
|
+
* A clickable card with a tooltip cannot stay a role="button" div: the (i)
|
|
44
|
+
* is a button of its own, and a button's children are presentational, so
|
|
45
|
+
* screen readers would never reach it. The card becomes a plain container
|
|
46
|
+
* with a real button laid over it, and the (i) sits above that button
|
|
47
|
+
* (FieldLabel lifts it with relative z-10). Cards without a tooltip keep
|
|
48
|
+
* the markup below, unchanged.
|
|
49
|
+
*/
|
|
50
|
+
if (isClickable && props.tooltip?.trim()) {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={`relative rounded-xl bg-white border ${borderClassName} shadow-sm hover:shadow-md transition-shadow duration-200 p-5 ${props.className || ""}`}
|
|
54
|
+
>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={props.onClick}
|
|
58
|
+
aria-pressed={
|
|
59
|
+
props.isSelected !== undefined
|
|
60
|
+
? Boolean(props.isSelected)
|
|
61
|
+
: undefined
|
|
62
|
+
}
|
|
63
|
+
/*
|
|
64
|
+
* Named like the old role="button" card, which read its title and
|
|
65
|
+
* value - unless the value is an element, which has no text to
|
|
66
|
+
* borrow here.
|
|
67
|
+
*/
|
|
68
|
+
aria-label={
|
|
69
|
+
props.ariaLabel ||
|
|
70
|
+
(typeof props.value === "string"
|
|
71
|
+
? `${props.title} ${props.value}`
|
|
72
|
+
: props.title)
|
|
73
|
+
}
|
|
74
|
+
className="absolute inset-0 rounded-xl cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
|
|
75
|
+
/>
|
|
76
|
+
<div className="mb-2">
|
|
77
|
+
<FieldLabelElement title={props.title} tooltip={props.tooltip} />
|
|
78
|
+
</div>
|
|
79
|
+
<div className={props.textClassName || "text-gray-900"}>
|
|
80
|
+
{props.value}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
40
86
|
return (
|
|
41
87
|
<div
|
|
42
88
|
onClick={props.onClick}
|
|
@@ -75,7 +121,7 @@ const InfoCard: FunctionComponent<ComponentProps> = (
|
|
|
75
121
|
className={`rounded-xl bg-white border ${borderClassName} shadow-sm hover:shadow-md transition-shadow duration-200 p-5 ${isClickable ? "cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2" : ""} ${props.className || ""}`}
|
|
76
122
|
>
|
|
77
123
|
<div className="mb-2">
|
|
78
|
-
<FieldLabelElement title={props.title} />
|
|
124
|
+
<FieldLabelElement title={props.title} tooltip={props.tooltip} />
|
|
79
125
|
</div>
|
|
80
126
|
<div className={props.textClassName || "text-gray-900"}>
|
|
81
127
|
{props.value}
|