123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import {Component} from 'react';
- import type {Theme} from '@emotion/react';
- import {withTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import type {Location} from 'history';
- import isEqual from 'lodash/isEqual';
- import throttle from 'lodash/throttle';
- import {Button} from 'sentry/components/button';
- import type {BarChartSeries} from 'sentry/components/charts/barChart';
- import {BarChart} from 'sentry/components/charts/barChart';
- import BarChartZoom from 'sentry/components/charts/barChartZoom';
- import MarkLine from 'sentry/components/charts/components/markLine';
- import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
- import Placeholder from 'sentry/components/placeholder';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {Organization} from 'sentry/types/organization';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import type EventView from 'sentry/utils/discover/eventView';
- import {getAggregateAlias} from 'sentry/utils/discover/fields';
- import getDuration from 'sentry/utils/duration/getDuration';
- import type {WebVital} from 'sentry/utils/fields';
- import {formatAbbreviatedNumber, formatFloat} from 'sentry/utils/formatters';
- import getDynamicText from 'sentry/utils/getDynamicText';
- import type {DataFilter, HistogramData} from 'sentry/utils/performance/histogram/types';
- import {
- computeBuckets,
- formatHistogramData,
- } from 'sentry/utils/performance/histogram/utils';
- import type {Vital} from 'sentry/utils/performance/vitals/types';
- import type {VitalData} from 'sentry/utils/performance/vitals/vitalsCardsDiscoverQuery';
- import {MutableSearch} from 'sentry/utils/tokenizeSearch';
- import {EventsDisplayFilterName} from 'sentry/views/performance/transactionSummary/transactionEvents/utils';
- import {VitalBar} from '../../landing/vitalsCards';
- import {
- VitalState,
- vitalStateColors,
- webVitalMeh,
- webVitalPoor,
- } from '../../vitalDetail/utils';
- import {NUM_BUCKETS, PERCENTILE} from './constants';
- import {Card, CardSectionHeading, CardSummary, Description, StatNumber} from './styles';
- import type {Rectangle} from './types';
- import {asPixelRect, findNearestBucketIndex, getRefRect, mapPoint} from './utils';
- type Props = {
- chartData: HistogramData;
- colors: [string];
- error: boolean;
- eventView: EventView;
- isLoading: boolean;
- location: Location;
- organization: Organization;
- summaryData: VitalData | null;
- theme: Theme;
- vital: WebVital;
- vitalDetails: Vital;
- dataFilter?: DataFilter;
- max?: number;
- min?: number;
- precision?: number;
- };
- type State = {
- /**
- * This is a pair of reference points on the graph that we can use to map any
- * other points to their pixel coordinates on the graph.
- *
- * The x values here are the index of the cooresponding bucket and the y value
- * are the respective counts.
- *
- * Invariances:
- * - refDataRect.point1.x < refDataRect.point2.x
- * - refDataRect.point1.y < refDataRect.point2.y
- */
- refDataRect: Rectangle | null;
- /**
- * This is the cooresponding pixel coordinate of the references points from refDataRect.
- *
- * ECharts' pixel coordinates are relative to the top left whereas the axis coordinates
- * used here are relative to the bottom right. Because of this and the invariances imposed
- * on refDataRect, these points have the difference invariances.
- *
- * Invariances:
- * - refPixelRect.point1.x < refPixelRect.point2.x
- * - refPixelRect.point1.y > refPixelRect.point2.y
- */
- refPixelRect: Rectangle | null;
- };
- class VitalCard extends Component<Props, State> {
- state: State = {
- refDataRect: null,
- refPixelRect: null,
- };
- static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State) {
- const {isLoading, error, chartData} = nextProps;
- if (isLoading || error === null) {
- return {...prevState};
- }
- const refDataRect = getRefRect(chartData);
- if (
- prevState.refDataRect === null ||
- (refDataRect !== null && !isEqual(refDataRect, prevState.refDataRect))
- ) {
- return {
- ...prevState,
- refDataRect,
- };
- }
- return {...prevState};
- }
- trackOpenInDiscoverClicked = () => {
- const {organization} = this.props;
- const {vitalDetails: vital} = this.props;
- trackAnalytics('performance_views.vitals.open_in_discover', {
- organization,
- vital: vital.slug,
- });
- };
- trackOpenAllEventsClicked = () => {
- const {organization} = this.props;
- const {vitalDetails: vital} = this.props;
- trackAnalytics('performance_views.vitals.open_all_events', {
- organization,
- vital: vital.slug,
- });
- };
- get summary() {
- const {summaryData} = this.props;
- return summaryData?.p75 ?? null;
- }
- get failureRate() {
- const {summaryData} = this.props;
- const numerator = summaryData?.poor ?? 0;
- const denominator = summaryData?.total ?? 0;
- return denominator <= 0 ? 0 : numerator / denominator;
- }
- getFormattedStatNumber() {
- const {vitalDetails: vital} = this.props;
- const summary = this.summary;
- const {type} = vital;
- return summary === null
- ? '\u2014'
- : type === 'duration'
- ? getDuration(summary / 1000, 2, true)
- : formatFloat(summary, 2);
- }
- renderSummary() {
- const {
- vitalDetails: vital,
- eventView,
- organization,
- min,
- max,
- dataFilter,
- } = this.props;
- const {slug, name, description} = vital;
- const column = `measurements.${slug}`;
- const newEventView = eventView
- .withColumns([
- {kind: 'field', field: 'transaction'},
- {
- kind: 'function',
- function: ['percentile', column, PERCENTILE.toString(), undefined],
- },
- {kind: 'function', function: ['count', '', '', undefined]},
- ])
- .withSorts([
- {
- kind: 'desc',
- field: getAggregateAlias(`percentile(${column},${PERCENTILE.toString()})`),
- },
- ]);
- const query = new MutableSearch(newEventView.query ?? '');
- query.addFilterValues('has', [column]);
- // add in any range constraints if any
- if (min !== undefined || max !== undefined) {
- if (min !== undefined) {
- query.addFilterValues(column, [`>=${min}`]);
- }
- if (max !== undefined) {
- query.addFilterValues(column, [`<=${max}`]);
- }
- }
- newEventView.query = query.formatString();
- return (
- <CardSummary>
- <SummaryHeading>
- <CardSectionHeading>{`${name} (${slug.toUpperCase()})`}</CardSectionHeading>
- </SummaryHeading>
- <StatNumber>
- {getDynamicText({
- value: this.getFormattedStatNumber(),
- fixed: '\u2014',
- })}
- </StatNumber>
- <Description>{description}</Description>
- <div>
- <Button
- size="xs"
- to={newEventView
- .withColumns([{kind: 'field', field: column}])
- .withSorts([{kind: 'desc', field: column}])
- .getPerformanceTransactionEventsViewUrlTarget(organization.slug, {
- showTransactions:
- dataFilter === 'all'
- ? EventsDisplayFilterName.P100
- : EventsDisplayFilterName.P75,
- webVital: column as WebVital,
- })}
- onClick={this.trackOpenAllEventsClicked}
- >
- {t('View Sampled Events')}
- </Button>
- </div>
- </CardSummary>
- );
- }
- /**
- * This callback happens everytime ECharts renders. This is NOT when ECharts
- * finishes rendering, so it can be called quite frequently. The calculations
- * here can get expensive if done frequently, furthermore, this can trigger a
- * state change leading to a re-render. So slow down the updates here as they
- * do not need to be updated every single time.
- */
- handleRendered = throttle(
- (_, chartRef) => {
- const {chartData} = this.props;
- const {refDataRect} = this.state;
- if (refDataRect === null || chartData.length < 1) {
- return;
- }
- const refPixelRect =
- refDataRect === null ? null : asPixelRect(chartRef, refDataRect!);
- if (refPixelRect !== null && !isEqual(refPixelRect, this.state.refPixelRect)) {
- this.setState({refPixelRect});
- }
- },
- 200,
- {leading: true}
- );
- handleDataZoomCancelled = () => {};
- renderHistogram() {
- const {
- theme,
- location,
- isLoading,
- chartData,
- summaryData,
- error,
- colors,
- vital,
- vitalDetails,
- precision = 0,
- } = this.props;
- const {slug} = vitalDetails;
- const series = this.getSeries();
- const xAxis = {
- type: 'category' as const,
- truncate: true,
- axisTick: {
- alignWithLabel: true,
- },
- };
- const values = series.data.map(point => point.value);
- const max = values.length ? Math.max(...values) : undefined;
- const yAxis = {
- type: 'value' as const,
- max,
- axisLabel: {
- color: theme.chartLabel,
- formatter: (value: string | number) => formatAbbreviatedNumber(value),
- },
- };
- const allSeries = [series];
- if (!isLoading && !error) {
- const baselineSeries = this.getBaselineSeries();
- if (baselineSeries !== null) {
- allSeries.push(baselineSeries);
- }
- }
- const vitalData =
- !isLoading && !error && summaryData !== null ? {[vital]: summaryData} : {};
- return (
- <BarChartZoom
- minZoomWidth={10 ** -precision * NUM_BUCKETS}
- location={location}
- paramStart={`${slug}Start`}
- paramEnd={`${slug}End`}
- xAxisIndex={[0]}
- buckets={computeBuckets(chartData)}
- onDataZoomCancelled={this.handleDataZoomCancelled}
- >
- {zoomRenderProps => (
- <Container>
- <TransparentLoadingMask visible={isLoading} />
- <PercentContainer>
- <VitalBar
- isLoading={isLoading}
- data={vitalData}
- vital={vital}
- showBar={false}
- showStates={false}
- showVitalPercentNames={false}
- showVitalThresholds={false}
- showDurationDetail={false}
- />
- </PercentContainer>
- {getDynamicText({
- value: (
- <BarChart
- series={allSeries}
- xAxis={xAxis}
- yAxis={yAxis}
- colors={colors}
- onRendered={this.handleRendered}
- grid={{
- left: space(3),
- right: space(3),
- top: space(3),
- bottom: space(1.5),
- }}
- stacked
- {...zoomRenderProps}
- />
- ),
- fixed: <Placeholder testId="skeleton-ui" height="200px" />,
- })}
- </Container>
- )}
- </BarChartZoom>
- );
- }
- bucketWidth() {
- const {chartData} = this.props;
- // We can assume that all buckets are of equal width, use the first two
- // buckets to get the width. The value of each histogram function indicates
- // the beginning of the bucket.
- return chartData.length >= 2 ? chartData[1].bin - chartData[0].bin : 0;
- }
- getSeries() {
- const {theme, chartData, precision, vitalDetails, vital} = this.props;
- const additionalFieldsFn = bucket => {
- return {
- itemStyle: {color: theme[this.getVitalsColor(vital, bucket)]},
- };
- };
- const data = formatHistogramData(chartData, {
- precision: precision === 0 ? undefined : precision,
- type: vitalDetails.type,
- additionalFieldsFn,
- });
- return {
- seriesName: t('Count'),
- data,
- };
- }
- getVitalsColor(vital: WebVital, value: number) {
- const poorThreshold = webVitalPoor[vital];
- const mehThreshold = webVitalMeh[vital];
- if (value >= poorThreshold) {
- return vitalStateColors[VitalState.POOR];
- }
- if (value >= mehThreshold) {
- return vitalStateColors[VitalState.MEH];
- }
- return vitalStateColors[VitalState.GOOD];
- }
- getBaselineSeries(): BarChartSeries | null {
- const {theme, chartData} = this.props;
- const summary = this.summary;
- if (summary === null || this.state.refPixelRect === null) {
- return null;
- }
- const summaryBucket = findNearestBucketIndex(chartData, summary);
- if (summaryBucket === null || summaryBucket === -1) {
- return null;
- }
- const thresholdPixelBottom = mapPoint(
- {
- // subtract 0.5 from the x here to ensure that the threshold lies between buckets
- x: summaryBucket - 0.5,
- y: 0,
- },
- this.state.refDataRect!,
- this.state.refPixelRect!
- );
- if (thresholdPixelBottom === null) {
- return null;
- }
- const thresholdPixelTop = mapPoint(
- {
- // subtract 0.5 from the x here to ensure that the threshold lies between buckets
- x: summaryBucket - 0.5,
- y: Math.max(...chartData.map(data => data.count)) || 1,
- },
- this.state.refDataRect!,
- this.state.refPixelRect!
- );
- if (thresholdPixelTop === null) {
- return null;
- }
- const markLine = MarkLine({
- animationDuration: 200,
- data: [[thresholdPixelBottom, thresholdPixelTop] as any],
- label: {
- show: false,
- },
- lineStyle: {
- color: theme.textColor,
- type: 'solid',
- },
- tooltip: {
- formatter: () => {
- return [
- '<div class="tooltip-series tooltip-series-solo">',
- '<span class="tooltip-label">',
- `<strong>${t('p75')}</strong>`,
- '</span>',
- '</div>',
- '<div class="tooltip-arrow"></div>',
- ].join('');
- },
- },
- });
- return {
- seriesName: t('p75'),
- data: [],
- markLine,
- };
- }
- render() {
- return (
- <Card>
- {this.renderSummary()}
- {this.renderHistogram()}
- </Card>
- );
- }
- }
- const SummaryHeading = styled('div')`
- display: flex;
- justify-content: space-between;
- `;
- const Container = styled('div')`
- position: relative;
- `;
- const PercentContainer = styled('div')`
- position: absolute;
- top: ${space(2)};
- right: ${space(3)};
- z-index: 2;
- `;
- export default withTheme(VitalCard);
|