vitalCard.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import React from 'react';
  2. import styled from '@emotion/styled';
  3. import {withTheme} from 'emotion-theming';
  4. import {Location} from 'history';
  5. import isEqual from 'lodash/isEqual';
  6. import throttle from 'lodash/throttle';
  7. import BarChart from 'app/components/charts/barChart';
  8. import BarChartZoom from 'app/components/charts/barChartZoom';
  9. import MarkLine from 'app/components/charts/components/markLine';
  10. import TransparentLoadingMask from 'app/components/charts/transparentLoadingMask';
  11. import DiscoverButton from 'app/components/discoverButton';
  12. import Placeholder from 'app/components/placeholder';
  13. import {t} from 'app/locale';
  14. import space from 'app/styles/space';
  15. import {Organization} from 'app/types';
  16. import {trackAnalyticsEvent} from 'app/utils/analytics';
  17. import EventView from 'app/utils/discover/eventView';
  18. import {getAggregateAlias, WebVital} from 'app/utils/discover/fields';
  19. import {formatAbbreviatedNumber, formatFloat, getDuration} from 'app/utils/formatters';
  20. import getDynamicText from 'app/utils/getDynamicText';
  21. import {HistogramData} from 'app/utils/performance/histogram/types';
  22. import {computeBuckets, formatHistogramData} from 'app/utils/performance/histogram/utils';
  23. import {Vital} from 'app/utils/performance/vitals/types';
  24. import {VitalData} from 'app/utils/performance/vitals/vitalsCardsDiscoverQuery';
  25. import {Theme} from 'app/utils/theme';
  26. import {stringifyQueryObject, tokenizeSearch} from 'app/utils/tokenizeSearch';
  27. import {VitalBar} from '../landing/vitalsCards';
  28. import {
  29. VitalState,
  30. vitalStateColors,
  31. webVitalMeh,
  32. webVitalPoor,
  33. } from '../vitalDetail/utils';
  34. import {NUM_BUCKETS, PERCENTILE} from './constants';
  35. import {Card, CardSectionHeading, CardSummary, Description, StatNumber} from './styles';
  36. import {Rectangle} from './types';
  37. import {asPixelRect, findNearestBucketIndex, getRefRect, mapPoint} from './utils';
  38. type Props = {
  39. theme: Theme;
  40. location: Location;
  41. organization: Organization;
  42. isLoading: boolean;
  43. error: boolean;
  44. vital: WebVital;
  45. vitalDetails: Vital;
  46. summaryData: VitalData | null;
  47. chartData: HistogramData;
  48. colors: [string];
  49. eventView: EventView;
  50. min?: number;
  51. max?: number;
  52. precision?: number;
  53. };
  54. type State = {
  55. /**
  56. * This is a pair of reference points on the graph that we can use to map any
  57. * other points to their pixel coordinates on the graph.
  58. *
  59. * The x values here are the index of the cooresponding bucket and the y value
  60. * are the respective counts.
  61. *
  62. * Invariances:
  63. * - refDataRect.point1.x < refDataRect.point2.x
  64. * - refDataRect.point1.y < refDataRect.point2.y
  65. */
  66. refDataRect: Rectangle | null;
  67. /**
  68. * This is the cooresponding pixel coordinate of the references points from refDataRect.
  69. *
  70. * ECharts' pixel coordinates are relative to the top left whereas the axis coordinates
  71. * used here are relative to the bottom right. Because of this and the invariances imposed
  72. * on refDataRect, these points have the difference invariances.
  73. *
  74. * Invariances:
  75. * - refPixelRect.point1.x < refPixelRect.point2.x
  76. * - refPixelRect.point1.y > refPixelRect.point2.y
  77. */
  78. refPixelRect: Rectangle | null;
  79. };
  80. class VitalCard extends React.Component<Props, State> {
  81. state = {
  82. refDataRect: null,
  83. refPixelRect: null,
  84. };
  85. static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State) {
  86. const {isLoading, error, chartData} = nextProps;
  87. if (isLoading || error === null) {
  88. return {...prevState};
  89. }
  90. const refDataRect = getRefRect(chartData);
  91. if (
  92. prevState.refDataRect === null ||
  93. (refDataRect !== null && !isEqual(refDataRect, prevState.refDataRect))
  94. ) {
  95. return {
  96. ...prevState,
  97. refDataRect,
  98. };
  99. }
  100. return {...prevState};
  101. }
  102. showVitalColours() {
  103. return this.props.organization.features.includes('performance-vitals-overview');
  104. }
  105. trackOpenInDiscoverClicked = () => {
  106. const {organization} = this.props;
  107. const {vitalDetails: vital} = this.props;
  108. trackAnalyticsEvent({
  109. eventKey: 'performance_views.vitals.open_in_discover',
  110. eventName: 'Performance Views: Open vitals in discover',
  111. organization_id: organization.id,
  112. vital: vital.slug,
  113. });
  114. };
  115. get summary() {
  116. const {summaryData} = this.props;
  117. return summaryData?.p75 ?? null;
  118. }
  119. get failureRate() {
  120. const {summaryData} = this.props;
  121. const numerator = summaryData?.poor ?? 0;
  122. const denominator = summaryData?.total ?? 0;
  123. return denominator <= 0 ? 0 : numerator / denominator;
  124. }
  125. getFormattedStatNumber() {
  126. const {vitalDetails: vital} = this.props;
  127. const summary = this.summary;
  128. const {type} = vital;
  129. return summary === null
  130. ? '\u2014'
  131. : type === 'duration'
  132. ? getDuration(summary / 1000, 2, true)
  133. : formatFloat(summary, 2);
  134. }
  135. renderSummary() {
  136. const {vitalDetails: vital, eventView, organization, min, max} = this.props;
  137. const {slug, name, description} = vital;
  138. const column = `measurements.${slug}`;
  139. const newEventView = eventView
  140. .withColumns([
  141. {kind: 'field', field: 'transaction'},
  142. {kind: 'function', function: ['percentile', column, PERCENTILE.toString()]},
  143. {kind: 'function', function: ['count', '', '']},
  144. ])
  145. .withSorts([
  146. {
  147. kind: 'desc',
  148. field: getAggregateAlias(`percentile(${column},${PERCENTILE.toString()})`),
  149. },
  150. ]);
  151. const query = tokenizeSearch(newEventView.query ?? '');
  152. query.addTagValues('has', [column]);
  153. // add in any range constraints if any
  154. if (min !== undefined || max !== undefined) {
  155. if (min !== undefined) {
  156. query.addTagValues(column, [`>=${min}`]);
  157. }
  158. if (max !== undefined) {
  159. query.addTagValues(column, [`<=${max}`]);
  160. }
  161. }
  162. newEventView.query = stringifyQueryObject(query);
  163. return (
  164. <CardSummary>
  165. <SummaryHeading>
  166. <CardSectionHeading>{`${name} (${slug.toUpperCase()})`}</CardSectionHeading>
  167. </SummaryHeading>
  168. <StatNumber>
  169. {getDynamicText({
  170. value: this.getFormattedStatNumber(),
  171. fixed: '\u2014',
  172. })}
  173. </StatNumber>
  174. <Description>{description}</Description>
  175. <div>
  176. <DiscoverButton
  177. size="small"
  178. to={newEventView.getResultsViewUrlTarget(organization.slug)}
  179. onClick={this.trackOpenInDiscoverClicked}
  180. >
  181. {t('Open in Discover')}
  182. </DiscoverButton>
  183. </div>
  184. </CardSummary>
  185. );
  186. }
  187. /**
  188. * This callback happens everytime ECharts renders. This is NOT when ECharts
  189. * finishes rendering, so it can be called quite frequently. The calculations
  190. * here can get expensive if done frequently, furthermore, this can trigger a
  191. * state change leading to a re-render. So slow down the updates here as they
  192. * do not need to be updated every single time.
  193. */
  194. handleRendered = throttle(
  195. (_, chartRef) => {
  196. const {chartData} = this.props;
  197. const {refDataRect} = this.state;
  198. if (refDataRect === null || chartData.length < 1) {
  199. return;
  200. }
  201. const refPixelRect =
  202. refDataRect === null ? null : asPixelRect(chartRef, refDataRect!);
  203. if (refPixelRect !== null && !isEqual(refPixelRect, this.state.refPixelRect)) {
  204. this.setState({refPixelRect});
  205. }
  206. },
  207. 200,
  208. {leading: true}
  209. );
  210. handleDataZoomCancelled = () => {};
  211. renderHistogram() {
  212. const {
  213. theme,
  214. location,
  215. isLoading,
  216. chartData,
  217. summaryData,
  218. error,
  219. colors,
  220. vital,
  221. vitalDetails,
  222. precision = 0,
  223. } = this.props;
  224. const {slug} = vitalDetails;
  225. const series = this.getSeries();
  226. const xAxis = {
  227. type: 'category' as const,
  228. truncate: true,
  229. axisTick: {
  230. alignWithLabel: true,
  231. },
  232. };
  233. const values = series.data.map(point => point.value);
  234. const max = values.length ? Math.max(...values) : undefined;
  235. const yAxis = {
  236. type: 'value' as const,
  237. max,
  238. axisLabel: {
  239. color: theme.chartLabel,
  240. formatter: formatAbbreviatedNumber,
  241. },
  242. };
  243. const allSeries = [series];
  244. if (!isLoading && !error) {
  245. const baselineSeries = this.getBaselineSeries();
  246. if (baselineSeries !== null) {
  247. allSeries.push(baselineSeries);
  248. }
  249. }
  250. const vitalData =
  251. !isLoading && !error && summaryData !== null ? {[vital]: summaryData} : {};
  252. return (
  253. <BarChartZoom
  254. minZoomWidth={10 ** -precision * NUM_BUCKETS}
  255. location={location}
  256. paramStart={`${slug}Start`}
  257. paramEnd={`${slug}End`}
  258. xAxisIndex={[0]}
  259. buckets={computeBuckets(chartData)}
  260. onDataZoomCancelled={this.handleDataZoomCancelled}
  261. >
  262. {zoomRenderProps => (
  263. <Container>
  264. <TransparentLoadingMask visible={isLoading} />
  265. <PercentContainer>
  266. <VitalBar
  267. isLoading={isLoading}
  268. data={vitalData}
  269. vital={vital}
  270. showBar={false}
  271. showStates={false}
  272. showVitalPercentNames={false}
  273. showDurationDetail={false}
  274. />
  275. </PercentContainer>
  276. {getDynamicText({
  277. value: (
  278. <BarChart
  279. series={allSeries}
  280. xAxis={xAxis}
  281. yAxis={yAxis}
  282. colors={colors}
  283. onRendered={this.handleRendered}
  284. grid={{
  285. left: space(3),
  286. right: space(3),
  287. top: space(3),
  288. bottom: space(1.5),
  289. }}
  290. stacked
  291. {...zoomRenderProps}
  292. />
  293. ),
  294. fixed: <Placeholder testId="skeleton-ui" height="200px" />,
  295. })}
  296. </Container>
  297. )}
  298. </BarChartZoom>
  299. );
  300. }
  301. bucketWidth() {
  302. const {chartData} = this.props;
  303. // We can assume that all buckets are of equal width, use the first two
  304. // buckets to get the width. The value of each histogram function indicates
  305. // the beginning of the bucket.
  306. return chartData.length >= 2 ? chartData[1].bin - chartData[0].bin : 0;
  307. }
  308. getSeries() {
  309. const {theme, chartData, precision, vitalDetails, vital} = this.props;
  310. const additionalFieldsFn = bucket => {
  311. if (this.showVitalColours()) {
  312. return {
  313. itemStyle: {color: theme[this.getVitalsColor(vital, bucket)]},
  314. };
  315. }
  316. return {};
  317. };
  318. const data = formatHistogramData(chartData, {
  319. precision: precision === 0 ? undefined : precision,
  320. type: vitalDetails.type,
  321. additionalFieldsFn,
  322. });
  323. return {
  324. seriesName: t('Count'),
  325. data,
  326. };
  327. }
  328. getVitalsColor(vital: WebVital, value: number) {
  329. const poorThreshold = webVitalPoor[vital];
  330. const mehThreshold = webVitalMeh[vital];
  331. if (value >= poorThreshold) {
  332. return vitalStateColors[VitalState.POOR];
  333. } else if (value >= mehThreshold) {
  334. return vitalStateColors[VitalState.MEH];
  335. } else {
  336. return vitalStateColors[VitalState.GOOD];
  337. }
  338. }
  339. getBaselineSeries() {
  340. const {theme, chartData} = this.props;
  341. const summary = this.summary;
  342. if (summary === null || this.state.refPixelRect === null) {
  343. return null;
  344. }
  345. const summaryBucket = findNearestBucketIndex(chartData, summary);
  346. if (summaryBucket === null || summaryBucket === -1) {
  347. return null;
  348. }
  349. const thresholdPixelBottom = mapPoint(
  350. {
  351. // subtract 0.5 from the x here to ensure that the threshold lies between buckets
  352. x: summaryBucket - 0.5,
  353. y: 0,
  354. },
  355. this.state.refDataRect!,
  356. this.state.refPixelRect!
  357. );
  358. if (thresholdPixelBottom === null) {
  359. return null;
  360. }
  361. const thresholdPixelTop = mapPoint(
  362. {
  363. // subtract 0.5 from the x here to ensure that the threshold lies between buckets
  364. x: summaryBucket - 0.5,
  365. y: Math.max(...chartData.map(data => data.count)) || 1,
  366. },
  367. this.state.refDataRect!,
  368. this.state.refPixelRect!
  369. );
  370. if (thresholdPixelTop === null) {
  371. return null;
  372. }
  373. const markLine = MarkLine({
  374. animationDuration: 200,
  375. data: [[thresholdPixelBottom, thresholdPixelTop] as any],
  376. label: {
  377. show: false,
  378. },
  379. lineStyle: {
  380. color: theme.textColor,
  381. type: 'solid',
  382. },
  383. });
  384. // TODO(tonyx): This conflicts with the types declaration of `MarkLine`
  385. // if we add it in the constructor. So we opt to add it here so typescript
  386. // doesn't complain.
  387. (markLine as any).tooltip = {
  388. formatter: () => {
  389. return [
  390. '<div class="tooltip-series tooltip-series-solo">',
  391. '<span class="tooltip-label">',
  392. `<strong>${t('p75')}</strong>`,
  393. '</span>',
  394. '</div>',
  395. '<div class="tooltip-arrow"></div>',
  396. ].join('');
  397. },
  398. };
  399. return {
  400. seriesName: t('p75'),
  401. data: [],
  402. markLine,
  403. };
  404. }
  405. render() {
  406. return (
  407. <Card>
  408. {this.renderSummary()}
  409. {this.renderHistogram()}
  410. </Card>
  411. );
  412. }
  413. }
  414. const SummaryHeading = styled('div')`
  415. display: flex;
  416. justify-content: space-between;
  417. `;
  418. const Container = styled('div')`
  419. position: relative;
  420. `;
  421. const PercentContainer = styled('div')`
  422. position: absolute;
  423. top: ${space(2)};
  424. right: ${space(3)};
  425. z-index: 2;
  426. `;
  427. export default withTheme(VitalCard);