webVitalMeters.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import {Fragment} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  5. import ExternalLink from 'sentry/components/links/externalLink';
  6. import QuestionTooltip from 'sentry/components/questionTooltip';
  7. import {Tooltip} from 'sentry/components/tooltip';
  8. import {t, tct} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import type {TableData} from 'sentry/utils/discover/discoverQuery';
  11. import getDuration from 'sentry/utils/duration/getDuration';
  12. import {VITAL_DESCRIPTIONS} from 'sentry/views/insights/browser/webVitals/components/webVitalDescription';
  13. import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings';
  14. import type {
  15. ProjectScore,
  16. WebVitals,
  17. } from 'sentry/views/insights/browser/webVitals/types';
  18. import {PERFORMANCE_SCORE_COLORS} from 'sentry/views/insights/browser/webVitals/utils/performanceScoreColors';
  19. import {
  20. scoreToStatus,
  21. STATUS_TEXT,
  22. } from 'sentry/views/insights/browser/webVitals/utils/scoreToStatus';
  23. type Props = {
  24. onClick?: (webVital: WebVitals) => void;
  25. projectData?: TableData;
  26. projectScore?: ProjectScore;
  27. showTooltip?: boolean;
  28. transaction?: string;
  29. };
  30. export const WEB_VITALS_METERS_CONFIG = {
  31. lcp: {
  32. name: t('Largest Contentful Paint'),
  33. formatter: (value: number) => getFormattedDuration(value / 1000),
  34. },
  35. fcp: {
  36. name: t('First Contentful Paint'),
  37. formatter: (value: number) => getFormattedDuration(value / 1000),
  38. },
  39. inp: {
  40. name: t('Interaction to Next Paint'),
  41. formatter: (value: number) => getFormattedDuration(value / 1000),
  42. },
  43. cls: {
  44. name: t('Cumulative Layout Shift'),
  45. formatter: (value: number) => Math.round(value * 100) / 100,
  46. },
  47. ttfb: {
  48. name: t('Time To First Byte'),
  49. formatter: (value: number) => getFormattedDuration(value / 1000),
  50. },
  51. };
  52. export default function WebVitalMeters({
  53. onClick,
  54. projectData,
  55. projectScore,
  56. showTooltip = true,
  57. }: Props) {
  58. const theme = useTheme();
  59. if (!projectScore) {
  60. return null;
  61. }
  62. const webVitalsConfig = WEB_VITALS_METERS_CONFIG;
  63. const webVitals = Object.keys(webVitalsConfig) as WebVitals[];
  64. const colors = theme.charts.getColorPalette(3) ?? [];
  65. const renderVitals = () => {
  66. return webVitals.map((webVital, index) => {
  67. const webVitalKey = `p75(measurements.${webVital})`;
  68. const score = projectScore[`${webVital}Score`];
  69. const meterValue = projectData?.data?.[0]?.[webVitalKey] as number;
  70. if (!score) {
  71. return null;
  72. }
  73. return (
  74. <VitalMeter
  75. key={webVital}
  76. webVital={webVital}
  77. showTooltip={showTooltip}
  78. score={score}
  79. meterValue={meterValue}
  80. color={colors[index]!}
  81. onClick={onClick}
  82. />
  83. );
  84. });
  85. };
  86. return (
  87. <Container>
  88. <Flex>{renderVitals()}</Flex>
  89. </Container>
  90. );
  91. }
  92. type VitalMeterProps = {
  93. color: string;
  94. meterValue: number | undefined;
  95. score: number | undefined;
  96. showTooltip: boolean;
  97. webVital: WebVitals;
  98. isAggregateMode?: boolean;
  99. onClick?: (webVital: WebVitals) => void;
  100. };
  101. export function VitalMeter({
  102. webVital,
  103. showTooltip,
  104. score,
  105. meterValue,
  106. color,
  107. onClick,
  108. isAggregateMode = true,
  109. }: VitalMeterProps) {
  110. const webVitalsConfig = WEB_VITALS_METERS_CONFIG;
  111. const webVitalExists = score !== undefined;
  112. const formattedMeterValueText =
  113. webVitalExists && meterValue ? (
  114. webVitalsConfig[webVital].formatter(meterValue)
  115. ) : (
  116. <NoValue />
  117. );
  118. const webVitalKey = `measurements.${webVital}`;
  119. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  120. const {shortDescription} = VITAL_DESCRIPTIONS[webVitalKey];
  121. const headerText = webVitalsConfig[webVital].name;
  122. const meterBody = (
  123. <Fragment>
  124. <MeterBarBody>
  125. {showTooltip && (
  126. <StyledQuestionTooltip
  127. isHoverable
  128. size="xs"
  129. title={
  130. <span>
  131. {shortDescription}
  132. <br />
  133. <ExternalLink href={`${MODULE_DOC_LINK}#performance-score`}>
  134. {t('Find out how performance scores are calculated here.')}
  135. </ExternalLink>
  136. </span>
  137. }
  138. />
  139. )}
  140. <MeterHeader>{headerText}</MeterHeader>
  141. <MeterValueText>
  142. <Dot color={color} />
  143. {formattedMeterValueText}
  144. </MeterValueText>
  145. </MeterBarBody>
  146. <MeterBarFooter score={score} />
  147. </Fragment>
  148. );
  149. return (
  150. <VitalContainer
  151. key={webVital}
  152. webVital={webVital}
  153. webVitalExists={webVitalExists}
  154. meterBody={meterBody}
  155. onClick={onClick}
  156. isAggregateMode={isAggregateMode}
  157. />
  158. );
  159. }
  160. type VitalContainerProps = {
  161. meterBody: React.ReactNode;
  162. webVital: WebVitals;
  163. webVitalExists: boolean;
  164. isAggregateMode?: boolean;
  165. onClick?: (webVital: WebVitals) => void;
  166. };
  167. function VitalContainer({
  168. webVital,
  169. webVitalExists,
  170. meterBody,
  171. onClick,
  172. isAggregateMode = true,
  173. }: VitalContainerProps) {
  174. return (
  175. <MeterBarContainer
  176. key={webVital}
  177. onClick={() => webVitalExists && onClick?.(webVital)}
  178. clickable={webVitalExists}
  179. >
  180. {webVitalExists && <InteractionStateLayer />}
  181. {webVitalExists && meterBody}
  182. {!webVitalExists && (
  183. <StyledTooltip
  184. title={tct('No [webVital] data found in this [selection].', {
  185. webVital: webVital.toUpperCase(),
  186. selection: isAggregateMode ? 'project' : 'trace',
  187. })}
  188. >
  189. {meterBody}
  190. </StyledTooltip>
  191. )}
  192. </MeterBarContainer>
  193. );
  194. }
  195. export const getFormattedDuration = (value: number) => {
  196. return getDuration(value, value < 1 ? 0 : 2, true);
  197. };
  198. const Container = styled('div')`
  199. margin-bottom: ${space(1)};
  200. `;
  201. const Flex = styled('div')<{gap?: number}>`
  202. display: flex;
  203. flex-direction: row;
  204. justify-content: center;
  205. width: 100%;
  206. gap: ${p => (p.gap ? `${p.gap}px` : space(1))};
  207. align-items: center;
  208. flex-wrap: wrap;
  209. `;
  210. const MeterBarContainer = styled('div')<{clickable?: boolean}>`
  211. background-color: ${p => p.theme.background};
  212. flex: 1;
  213. position: relative;
  214. padding: 0;
  215. cursor: ${p => (p.clickable ? 'pointer' : 'default')};
  216. min-width: 140px;
  217. `;
  218. const MeterBarBody = styled('div')`
  219. border: 1px solid ${p => p.theme.gray200};
  220. border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
  221. border-bottom: none;
  222. padding: ${space(1)} 0 ${space(0.5)} 0;
  223. `;
  224. const MeterHeader = styled('div')`
  225. font-size: ${p => p.theme.fontSizeSmall};
  226. font-weight: ${p => p.theme.fontWeightBold};
  227. color: ${p => p.theme.textColor};
  228. display: inline-block;
  229. text-align: center;
  230. width: 100%;
  231. `;
  232. const MeterValueText = styled('div')`
  233. display: flex;
  234. justify-content: center;
  235. align-items: center;
  236. font-size: ${p => p.theme.headerFontSize};
  237. color: ${p => p.theme.textColor};
  238. flex: 1;
  239. text-align: center;
  240. `;
  241. function MeterBarFooter({score}: {score: number | undefined}) {
  242. if (score === undefined) {
  243. return (
  244. <MeterBarFooterContainer status="none">{t('No Data')}</MeterBarFooterContainer>
  245. );
  246. }
  247. const status = scoreToStatus(score);
  248. return (
  249. <MeterBarFooterContainer status={status}>
  250. {STATUS_TEXT[status]} {score}
  251. </MeterBarFooterContainer>
  252. );
  253. }
  254. const MeterBarFooterContainer = styled('div')<{
  255. status: keyof typeof PERFORMANCE_SCORE_COLORS;
  256. }>`
  257. color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].normal]};
  258. border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius};
  259. background-color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].light]};
  260. border: solid 1px ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].border]};
  261. font-size: ${p => p.theme.fontSizeExtraSmall};
  262. padding: ${space(0.5)};
  263. text-align: center;
  264. `;
  265. const NoValueContainer = styled('span')`
  266. color: ${p => p.theme.gray300};
  267. font-size: ${p => p.theme.headerFontSize};
  268. `;
  269. function NoValue() {
  270. return <NoValueContainer>{' \u2014 '}</NoValueContainer>;
  271. }
  272. const StyledTooltip = styled(Tooltip)`
  273. display: block;
  274. width: 100%;
  275. `;
  276. const StyledQuestionTooltip = styled(QuestionTooltip)`
  277. position: absolute;
  278. right: ${space(1)};
  279. `;
  280. export const Dot = styled('span')<{color: string}>`
  281. display: inline-block;
  282. margin-right: ${space(1)};
  283. border-radius: ${p => p.theme.borderRadius};
  284. width: ${space(1)};
  285. height: ${space(1)};
  286. background-color: ${p => p.color};
  287. `;
  288. // A compressed version of the VitalMeter component used in the trace context panel
  289. type VitalPillProps = Omit<
  290. VitalMeterProps,
  291. 'showTooltip' | 'isAggregateMode' | 'onClick' | 'color'
  292. >;
  293. export function VitalPill({webVital, score, meterValue}: VitalPillProps) {
  294. const status = score !== undefined ? scoreToStatus(score) : 'none';
  295. const webVitalExists = score !== undefined;
  296. const webVitalsConfig = WEB_VITALS_METERS_CONFIG;
  297. const formattedMeterValueText =
  298. webVitalExists && meterValue ? (
  299. webVitalsConfig[webVital].formatter(meterValue)
  300. ) : (
  301. <NoValue />
  302. );
  303. const tooltipText = VITAL_DESCRIPTIONS[`measurements.${webVital}`];
  304. return (
  305. <VitalPillContainer>
  306. <Tooltip title={tooltipText?.shortDescription}>
  307. <VitalPillName status={status}>
  308. {`${webVital ? webVital.toUpperCase() : ''} (${status === 'none' ? 'N/A' : STATUS_TEXT[status]})`}
  309. </VitalPillName>
  310. </Tooltip>
  311. <VitalPillValue>{formattedMeterValueText}</VitalPillValue>
  312. </VitalPillContainer>
  313. );
  314. }
  315. const VitalPillContainer = styled('div')`
  316. display: flex;
  317. flex-direction: row;
  318. width: 100%;
  319. height: 30px;
  320. `;
  321. const VitalPillName = styled('div')<{status: keyof typeof PERFORMANCE_SCORE_COLORS}>`
  322. display: flex;
  323. align-items: center;
  324. position: relative;
  325. height: 100%;
  326. padding: 0 ${space(1)};
  327. border: solid 1px ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].border]};
  328. border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius};
  329. background-color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].light]};
  330. color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].normal]};
  331. font-size: ${p => p.theme.fontSizeSmall};
  332. font-weight: ${p => p.theme.fontWeightBold};
  333. text-decoration: underline;
  334. text-decoration-style: dotted;
  335. text-underline-offset: ${space(0.25)};
  336. text-decoration-thickness: 1px;
  337. cursor: pointer;
  338. `;
  339. const VitalPillValue = styled('div')`
  340. display: flex;
  341. flex: 1;
  342. align-items: center;
  343. justify-content: flex-end;
  344. height: 100%;
  345. padding: 0 ${space(0.5)};
  346. border: 1px solid ${p => p.theme.gray200};
  347. border-left: none;
  348. border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0;
  349. background: ${p => p.theme.background};
  350. color: ${p => p.theme.textColor};
  351. font-size: ${p => p.theme.fontSizeLarge};
  352. `;