123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741 |
- import {Component, isValidElement} from 'react';
- import {InjectedRouter} from 'react-router';
- import {Theme, withTheme} from '@emotion/react';
- import type {
- EChartsOption,
- LegendComponentOption,
- LineSeriesOption,
- XAXisComponentOption,
- YAXisComponentOption,
- } from 'echarts';
- import {Query} from 'history';
- import isEqual from 'lodash/isEqual';
- import {Client} from 'sentry/api';
- import {AreaChart, AreaChartProps} from 'sentry/components/charts/areaChart';
- import {BarChart, BarChartProps} from 'sentry/components/charts/barChart';
- import ChartZoom, {ZoomRenderProps} from 'sentry/components/charts/chartZoom';
- import ErrorPanel from 'sentry/components/charts/errorPanel';
- import {LineChart, LineChartProps} from 'sentry/components/charts/lineChart';
- import ReleaseSeries from 'sentry/components/charts/releaseSeries';
- import TransitionChart from 'sentry/components/charts/transitionChart';
- import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
- import {
- getInterval,
- processTableResults,
- RELEASE_LINES_THRESHOLD,
- } from 'sentry/components/charts/utils';
- import {WorldMapChart, WorldMapChartProps} from 'sentry/components/charts/worldMapChart';
- import {IconWarning} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {DateString, OrganizationSummary} from 'sentry/types';
- import {Series} from 'sentry/types/echarts';
- import {defined} from 'sentry/utils';
- import {
- axisLabelFormatter,
- axisLabelFormatterUsingAggregateOutputType,
- tooltipFormatter,
- } from 'sentry/utils/discover/charts';
- import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
- import {
- aggregateMultiPlotType,
- aggregateOutputType,
- AggregationOutputType,
- getEquation,
- isEquation,
- } from 'sentry/utils/discover/fields';
- import {DiscoverDatasets} from 'sentry/utils/discover/types';
- import {decodeList} from 'sentry/utils/queryString';
- import EventsGeoRequest from './eventsGeoRequest';
- import EventsRequest from './eventsRequest';
- type ChartComponent =
- | React.ComponentType<BarChartProps>
- | React.ComponentType<AreaChartProps>
- | React.ComponentType<LineChartProps>
- | React.ComponentType<WorldMapChartProps>;
- type ChartProps = {
- currentSeriesNames: string[];
- loading: boolean;
- previousSeriesNames: string[];
- reloading: boolean;
- stacked: boolean;
- tableData: TableDataWithTitle[];
- theme: Theme;
- timeseriesData: Series[];
- yAxis: string;
- zoomRenderProps: ZoomRenderProps;
- additionalSeries?: LineSeriesOption[];
- chartComponent?: ChartComponent;
- chartOptions?: Omit<EChartsOption, 'xAxis' | 'yAxis'> & {
- xAxis?: XAXisComponentOption;
- yAxis?: YAXisComponentOption;
- };
- colors?: string[];
- /**
- * By default, only the release series is disableable. This adds
- * a list of series names that are also disableable.
- */
- disableableSeries?: string[];
- fromDiscover?: boolean;
- height?: number;
- interval?: string;
- legendOptions?: LegendComponentOption;
- minutesThresholdToDisplaySeconds?: number;
- previousSeriesTransformer?: (series?: Series | null) => Series | null | undefined;
- previousTimeseriesData?: Series[] | null;
- referrer?: string;
- releaseSeries?: Series[];
- /**
- * A callback to allow for post-processing of the series data.
- * Can be used to rename series or even insert a new series.
- */
- seriesTransformer?: (series: Series[]) => Series[];
- showDaily?: boolean;
- showLegend?: boolean;
- timeframe?: {end: number; start: number};
- timeseriesResultsTypes?: Record<string, AggregationOutputType>;
- topEvents?: number;
- };
- type State = {
- forceUpdate: boolean;
- seriesSelection: Record<string, boolean>;
- };
- class Chart extends Component<ChartProps, State> {
- state: State = {
- seriesSelection: {},
- forceUpdate: false,
- };
- shouldComponentUpdate(nextProps: ChartProps, nextState: State) {
- if (nextState.forceUpdate) {
- return true;
- }
- if (!isEqual(this.state.seriesSelection, nextState.seriesSelection)) {
- return true;
- }
- if (nextProps.reloading || !nextProps.timeseriesData) {
- return false;
- }
- if (
- isEqual(this.props.timeseriesData, nextProps.timeseriesData) &&
- isEqual(this.props.releaseSeries, nextProps.releaseSeries) &&
- isEqual(this.props.previousTimeseriesData, nextProps.previousTimeseriesData) &&
- isEqual(this.props.tableData, nextProps.tableData) &&
- isEqual(this.props.additionalSeries, nextProps.additionalSeries)
- ) {
- return false;
- }
- return true;
- }
- getChartComponent(): ChartComponent {
- const {showDaily, timeseriesData, yAxis, chartComponent} = this.props;
- if (defined(chartComponent)) {
- return chartComponent;
- }
- if (showDaily) {
- return BarChart;
- }
- if (timeseriesData.length > 1) {
- switch (aggregateMultiPlotType(yAxis)) {
- case 'line':
- return LineChart;
- case 'area':
- return AreaChart;
- default:
- throw new Error(`Unknown multi plot type for ${yAxis}`);
- }
- }
- return AreaChart;
- }
- handleLegendSelectChanged = legendChange => {
- const {disableableSeries = []} = this.props;
- const {selected} = legendChange;
- const seriesSelection = Object.keys(selected).reduce((state, key) => {
- // we only want them to be able to disable the Releases&Other series,
- // and not any of the other possible series here
- const disableable =
- ['Releases', 'Other'].includes(key) || disableableSeries.includes(key);
- state[key] = disableable ? selected[key] : true;
- return state;
- }, {});
- // we have to force an update here otherwise ECharts will
- // update its internal state and disable the series
- this.setState({seriesSelection, forceUpdate: true}, () =>
- this.setState({forceUpdate: false})
- );
- };
- render() {
- const {
- theme,
- loading: _loading,
- reloading: _reloading,
- yAxis,
- releaseSeries,
- zoomRenderProps,
- timeseriesData,
- previousTimeseriesData,
- showLegend,
- legendOptions,
- chartOptions: chartOptionsProp,
- currentSeriesNames,
- previousSeriesNames,
- seriesTransformer,
- previousSeriesTransformer,
- colors,
- height,
- timeframe,
- topEvents,
- tableData,
- fromDiscover,
- timeseriesResultsTypes,
- additionalSeries,
- ...props
- } = this.props;
- const {seriesSelection} = this.state;
- let ChartComponent = this.getChartComponent();
- if (ChartComponent === WorldMapChart) {
- const {data, title} = processTableResults(tableData);
- const tableSeries = [
- {
- seriesName: title,
- data,
- },
- ];
- return <WorldMapChart series={tableSeries} fromDiscover={fromDiscover} />;
- }
- ChartComponent = ChartComponent as Exclude<
- ChartComponent,
- React.ComponentType<WorldMapChartProps>
- >;
- const data = [
- ...(currentSeriesNames.length > 0 ? currentSeriesNames : [t('Current')]),
- ...(previousSeriesNames.length > 0 ? previousSeriesNames : [t('Previous')]),
- ...(additionalSeries ? additionalSeries.map(series => series.name as string) : []),
- ];
- const releasesLegend = t('Releases');
- const hasOther = topEvents && topEvents + 1 === timeseriesData.length;
- if (hasOther) {
- data.push('Other');
- }
- if (Array.isArray(releaseSeries)) {
- data.push(releasesLegend);
- }
- // Temporary fix to improve performance on pages with a high number of releases.
- const releases = releaseSeries && releaseSeries[0];
- const hideReleasesByDefault =
- Array.isArray(releaseSeries) &&
- (releases as any)?.markLine?.data &&
- (releases as any).markLine.data.length >= RELEASE_LINES_THRESHOLD;
- const selected = !Array.isArray(releaseSeries)
- ? seriesSelection
- : Object.keys(seriesSelection).length === 0 && hideReleasesByDefault
- ? {[releasesLegend]: false}
- : seriesSelection;
- const legend = showLegend
- ? {
- right: 16,
- top: 12,
- data,
- selected,
- ...(legendOptions ?? {}),
- }
- : undefined;
- let series = Array.isArray(releaseSeries)
- ? [...timeseriesData, ...releaseSeries]
- : timeseriesData;
- let previousSeries = previousTimeseriesData;
- if (seriesTransformer) {
- series = seriesTransformer(series);
- }
- if (previousSeriesTransformer) {
- previousSeries = previousSeries?.map(
- prev => previousSeriesTransformer(prev) as Series
- );
- }
- const chartColors = timeseriesData.length
- ? colors?.slice(0, series.length) ?? [
- ...theme.charts.getColorPalette(timeseriesData.length - 2 - (hasOther ? 1 : 0)),
- ]
- : undefined;
- if (chartColors && chartColors.length && hasOther) {
- chartColors.push(theme.chartOther);
- }
- const chartOptions = {
- colors: chartColors,
- grid: {
- left: '24px',
- right: '24px',
- top: '32px',
- bottom: '12px',
- },
- seriesOptions: {
- showSymbol: false,
- },
- tooltip: {
- trigger: 'axis' as const,
- truncate: 80,
- valueFormatter: (value: number, label?: string) => {
- const aggregateName = label
- ?.replace(/^previous /, '')
- .split(':')
- .pop()
- ?.trim();
- if (aggregateName) {
- return timeseriesResultsTypes
- ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
- : tooltipFormatter(value, aggregateOutputType(aggregateName));
- }
- return tooltipFormatter(value, 'number');
- },
- },
- xAxis: timeframe
- ? {
- min: timeframe.start,
- max: timeframe.end,
- }
- : undefined,
- yAxis: {
- axisLabel: {
- color: theme.chartLabel,
- formatter: (value: number) => {
- if (timeseriesResultsTypes) {
- // Check to see if all series output types are the same. If not, then default to number.
- const outputType =
- new Set(Object.values(timeseriesResultsTypes)).size === 1
- ? timeseriesResultsTypes[yAxis]
- : 'number';
- return axisLabelFormatterUsingAggregateOutputType(value, outputType);
- }
- return axisLabelFormatter(value, aggregateOutputType(yAxis));
- },
- },
- },
- ...(chartOptionsProp ?? {}),
- animation: typeof ChartComponent === typeof BarChart ? false : undefined,
- };
- return (
- <ChartComponent
- {...props}
- {...zoomRenderProps}
- {...chartOptions}
- legend={legend}
- onLegendSelectChanged={this.handleLegendSelectChanged}
- series={series}
- previousPeriod={previousSeries ? previousSeries : undefined}
- height={height}
- additionalSeries={additionalSeries}
- />
- );
- }
- }
- const ThemedChart = withTheme(Chart);
- export type EventsChartProps = {
- api: Client;
- /**
- * Absolute end date.
- */
- end: DateString;
- /**
- * Environment condition.
- */
- environments: string[];
- organization: OrganizationSummary;
- /**
- * Project ids
- */
- projects: number[];
- /**
- * The discover query string to find events with.
- */
- query: string;
- router: InjectedRouter;
- /**
- * Absolute start date.
- */
- start: DateString;
- /**
- * The aggregate/metric to plot.
- */
- yAxis: string | string[];
- additionalSeries?: LineSeriesOption[];
- /**
- * Markup for optional chart header
- */
- chartHeader?: React.ReactNode;
- /**
- * Override the default color palette.
- */
- colors?: string[];
- confirmedQuery?: boolean;
- /**
- * Name of the series
- */
- currentSeriesName?: string;
- /**
- * Specifies the dataset to query from. Defaults to discover.
- */
- dataset?: DiscoverDatasets;
- /**
- * Don't show the previous period's data. Will automatically disable
- * when start/end are used.
- */
- disablePrevious?: boolean;
- /**
- * Don't show the release marklines.
- */
- disableReleases?: boolean;
- /**
- * A list of release names to visually emphasize. Can only be used when `disableReleases` is false.
- */
- emphasizeReleases?: string[];
- /**
- * The fields that act as grouping conditions when generating a topEvents chart.
- */
- field?: string[];
- /**
- * The interval resolution for a chart e.g. 1m, 5m, 1d
- */
- interval?: string;
- /**
- * Whether or not the request for processed baseline data has been resolved/terminated
- */
- loadingAdditionalSeries?: boolean;
- /**
- * Order condition when showing topEvents
- */
- orderby?: string;
- /**
- * Relative datetime expression. eg. 14d
- */
- period?: string | null;
- preserveReleaseQueryParams?: boolean;
- /**
- * Name of the previous series
- */
- previousSeriesName?: string;
- /**
- * A unique name for what's triggering this request, see organization_events_stats for an allowlist
- */
- referrer?: string;
- releaseQueryExtra?: Query;
- reloadingAdditionalSeries?: boolean;
- /**
- * Override the interval calculation and show daily results.
- */
- showDaily?: boolean;
- /**
- * Fetch n top events as dictated by the field and orderby props.
- */
- topEvents?: number;
- /**
- * Chart zoom will change 'pageStart' instead of 'start'
- */
- usePageZoom?: boolean;
- /**
- * Should datetimes be formatted in UTC?
- */
- utc?: boolean | null;
- /**
- * Whether or not to zerofill results
- */
- withoutZerofill?: boolean;
- } & Pick<
- ChartProps,
- | 'seriesTransformer'
- | 'previousSeriesTransformer'
- | 'showLegend'
- | 'minutesThresholdToDisplaySeconds'
- | 'disableableSeries'
- | 'legendOptions'
- | 'chartOptions'
- | 'chartComponent'
- | 'height'
- | 'fromDiscover'
- >;
- type ChartDataProps = {
- errored: boolean;
- loading: boolean;
- reloading: boolean;
- zoomRenderProps: ZoomRenderProps;
- previousTimeseriesData?: Series[] | null;
- releaseSeries?: Series[];
- results?: Series[];
- tableData?: TableDataWithTitle[];
- timeframe?: {end: number; start: number};
- timeseriesData?: Series[];
- timeseriesResultsTypes?: Record<string, AggregationOutputType>;
- topEvents?: number;
- };
- class EventsChart extends Component<EventsChartProps> {
- isStacked() {
- const {topEvents, yAxis} = this.props;
- return (
- (typeof topEvents === 'number' && topEvents > 0) ||
- (Array.isArray(yAxis) && yAxis.length > 1)
- );
- }
- render() {
- const {
- api,
- organization,
- period,
- utc,
- query,
- router,
- start,
- end,
- projects,
- environments,
- showLegend,
- minutesThresholdToDisplaySeconds,
- yAxis,
- disablePrevious,
- disableReleases,
- emphasizeReleases,
- currentSeriesName: currentName,
- previousSeriesName: previousName,
- seriesTransformer,
- previousSeriesTransformer,
- field,
- interval,
- showDaily,
- topEvents,
- orderby,
- confirmedQuery,
- colors,
- chartHeader,
- legendOptions,
- chartOptions,
- preserveReleaseQueryParams,
- releaseQueryExtra,
- disableableSeries,
- chartComponent,
- usePageZoom,
- height,
- withoutZerofill,
- fromDiscover,
- additionalSeries,
- loadingAdditionalSeries,
- reloadingAdditionalSeries,
- dataset,
- ...props
- } = this.props;
- // Include previous only on relative dates (defaults to relative if no start and end)
- const includePrevious = !disablePrevious && !start && !end;
- const yAxisArray = decodeList(yAxis);
- const yAxisSeriesNames = yAxisArray.map(name => {
- let yAxisLabel = name && isEquation(name) ? getEquation(name) : name;
- if (yAxisLabel && yAxisLabel.length > 60) {
- yAxisLabel = yAxisLabel.substr(0, 60) + '...';
- }
- return yAxisLabel;
- });
- const previousSeriesNames = previousName
- ? [previousName]
- : yAxisSeriesNames.map(name => t('previous %s', name));
- const currentSeriesNames = currentName ? [currentName] : yAxisSeriesNames;
- const intervalVal = showDaily ? '1d' : interval || getInterval(this.props, 'high');
- let chartImplementation = ({
- zoomRenderProps,
- releaseSeries,
- errored,
- loading,
- reloading,
- results,
- timeseriesData,
- previousTimeseriesData,
- timeframe,
- tableData,
- timeseriesResultsTypes,
- }: ChartDataProps) => {
- if (errored) {
- return (
- <ErrorPanel>
- <IconWarning color="gray300" size="lg" />
- </ErrorPanel>
- );
- }
- const seriesData = results ? results : timeseriesData;
- return (
- <TransitionChart
- loading={loading}
- reloading={reloading || !!reloadingAdditionalSeries}
- height={height ? `${height}px` : undefined}
- >
- <TransparentLoadingMask visible={reloading || !!reloadingAdditionalSeries} />
- {isValidElement(chartHeader) && chartHeader}
- <ThemedChart
- zoomRenderProps={zoomRenderProps}
- loading={loading || !!loadingAdditionalSeries}
- reloading={reloading || !!reloadingAdditionalSeries}
- showLegend={showLegend}
- minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
- releaseSeries={releaseSeries || []}
- timeseriesData={seriesData ?? []}
- previousTimeseriesData={previousTimeseriesData}
- currentSeriesNames={currentSeriesNames}
- previousSeriesNames={previousSeriesNames}
- seriesTransformer={seriesTransformer}
- additionalSeries={additionalSeries}
- previousSeriesTransformer={previousSeriesTransformer}
- stacked={this.isStacked()}
- yAxis={yAxisArray[0]}
- showDaily={showDaily}
- colors={colors}
- legendOptions={legendOptions}
- chartOptions={chartOptions}
- disableableSeries={disableableSeries}
- chartComponent={chartComponent}
- height={height}
- timeframe={timeframe}
- topEvents={topEvents}
- tableData={tableData ?? []}
- fromDiscover={fromDiscover}
- timeseriesResultsTypes={timeseriesResultsTypes}
- />
- </TransitionChart>
- );
- };
- if (!disableReleases) {
- const previousChart = chartImplementation;
- chartImplementation = chartProps => (
- <ReleaseSeries
- utc={utc}
- period={period}
- start={start}
- end={end}
- projects={projects}
- environments={environments}
- emphasizeReleases={emphasizeReleases}
- preserveQueryParams={preserveReleaseQueryParams}
- queryExtra={releaseQueryExtra}
- >
- {({releaseSeries}) => previousChart({...chartProps, releaseSeries})}
- </ReleaseSeries>
- );
- }
- return (
- <ChartZoom
- router={router}
- period={period}
- start={start}
- end={end}
- utc={utc}
- usePageDate={usePageZoom}
- {...props}
- >
- {zoomRenderProps => {
- if (chartComponent === WorldMapChart) {
- return (
- <EventsGeoRequest
- api={api}
- organization={organization}
- yAxis={yAxis}
- query={query}
- orderby={orderby}
- projects={projects}
- period={period}
- start={start}
- end={end}
- environments={environments}
- referrer={props.referrer}
- dataset={dataset}
- >
- {({errored, loading, reloading, tableData}) =>
- chartImplementation({
- errored,
- loading,
- reloading,
- zoomRenderProps,
- tableData,
- })
- }
- </EventsGeoRequest>
- );
- }
- return (
- <EventsRequest
- {...props}
- api={api}
- organization={organization}
- period={period}
- project={projects}
- environment={environments}
- start={start}
- end={end}
- interval={intervalVal}
- query={query}
- includePrevious={includePrevious}
- currentSeriesNames={currentSeriesNames}
- previousSeriesNames={previousSeriesNames}
- yAxis={yAxis}
- field={field}
- orderby={orderby}
- topEvents={topEvents}
- confirmedQuery={confirmedQuery}
- partial
- // Cannot do interpolation when stacking series
- withoutZerofill={withoutZerofill && !this.isStacked()}
- dataset={dataset}
- >
- {eventData => {
- return chartImplementation({
- ...eventData,
- zoomRenderProps,
- });
- }}
- </EventsRequest>
- );
- }}
- </ChartZoom>
- );
- }
- }
- export default EventsChart;
|