Files
2024-02-15 17:01:50 +00:00

694 lines
20 KiB
TypeScript

import {Component, isValidElement} from 'react';
import type {InjectedRouter} from 'react-router';
import type {Theme} from '@emotion/react';
import {withTheme} from '@emotion/react';
import type {
EChartsOption,
LegendComponentOption,
LineSeriesOption,
XAXisComponentOption,
YAXisComponentOption,
} from 'echarts';
import type {Query} from 'history';
import isEqual from 'lodash/isEqual';
import type {Client} from 'sentry/api';
import type {AreaChartProps} from 'sentry/components/charts/areaChart';
import {AreaChart} from 'sentry/components/charts/areaChart';
import type {BarChartProps} from 'sentry/components/charts/barChart';
import {BarChart} from 'sentry/components/charts/barChart';
import type {ZoomRenderProps} from 'sentry/components/charts/chartZoom';
import ChartZoom from 'sentry/components/charts/chartZoom';
import ErrorPanel from 'sentry/components/charts/errorPanel';
import type {LineChartProps} from 'sentry/components/charts/lineChart';
import {LineChart} 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, RELEASE_LINES_THRESHOLD} from 'sentry/components/charts/utils';
import {IconWarning} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {DateString, OrganizationSummary} from 'sentry/types';
import type {Series} from 'sentry/types/echarts';
import {defined} from 'sentry/utils';
import {
axisLabelFormatter,
axisLabelFormatterUsingAggregateOutputType,
tooltipFormatter,
} from 'sentry/utils/discover/charts';
import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
import {
aggregateMultiPlotType,
aggregateOutputType,
getEquation,
isEquation,
} from 'sentry/utils/discover/fields';
import type {DiscoverDatasets} from 'sentry/utils/discover/types';
import {decodeList} from 'sentry/utils/queryString';
import EventsRequest from './eventsRequest';
type ChartComponent =
| React.ComponentType<BarChartProps>
| React.ComponentType<AreaChartProps>
| React.ComponentType<LineChartProps>;
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,
timeseriesResultsTypes,
additionalSeries,
...props
} = this.props;
const {seriesSelection} = this.state;
const ChartComponent = this.getChartComponent();
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?.[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?.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.substring(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 => {
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;