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

539 lines
14 KiB
TypeScript

import {Component, Fragment} from 'react';
import type {Theme} from '@emotion/react';
import {withTheme} from '@emotion/react';
import styled from '@emotion/styled';
import Color from 'color';
import type {
BarSeriesOption,
LegendComponentOption,
SeriesOption,
TooltipComponentOption,
} from 'echarts';
import BaseChart from 'sentry/components/charts/baseChart';
import Legend from 'sentry/components/charts/components/legend';
import xAxis from 'sentry/components/charts/components/xAxis';
import barSeries from 'sentry/components/charts/series/barSeries';
import {ChartContainer, HeaderTitleLegend} from 'sentry/components/charts/styles';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import Panel from 'sentry/components/panels/panel';
import Placeholder from 'sentry/components/placeholder';
import {DATA_CATEGORY_INFO} from 'sentry/constants';
import {IconWarning} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {DataCategoryInfo, IntervalPeriod, SelectValue} from 'sentry/types';
import {parsePeriodToHours, statsPeriodToDays} from 'sentry/utils/dates';
import getDynamicText from 'sentry/utils/getDynamicText';
import commonTheme from 'sentry/utils/theme';
import {formatUsageWithUnits} from '../utils';
import {getTooltipFormatter, getXAxisDates, getXAxisLabelInterval} from './utils';
const GIGABYTE = 10 ** 9;
type ChartProps = React.ComponentProps<typeof BaseChart>;
const COLOR_ERRORS = Color(commonTheme.dataCategory.errors).lighten(0.25).string();
const COLOR_TRANSACTIONS = Color(commonTheme.dataCategory.transactions)
.lighten(0.35)
.string();
const COLOR_ATTACHMENTS = Color(commonTheme.dataCategory.attachments)
.lighten(0.65)
.string();
const COLOR_DROPPED = commonTheme.red300;
const COLOR_FILTERED = commonTheme.pink100;
export type CategoryOption = {
/**
* Scale of y-axis with no usage data.
*/
yAxisMinInterval: number;
} & SelectValue<DataCategoryInfo['plural']>;
export const CHART_OPTIONS_DATACATEGORY: CategoryOption[] = [
{
label: DATA_CATEGORY_INFO.error.titleName,
value: DATA_CATEGORY_INFO.error.plural,
disabled: false,
yAxisMinInterval: 100,
},
{
label: DATA_CATEGORY_INFO.transaction.titleName,
value: DATA_CATEGORY_INFO.transaction.plural,
disabled: false,
yAxisMinInterval: 100,
},
{
label: DATA_CATEGORY_INFO.replay.titleName,
value: DATA_CATEGORY_INFO.replay.plural,
disabled: false,
yAxisMinInterval: 100,
},
{
label: DATA_CATEGORY_INFO.attachment.titleName,
value: DATA_CATEGORY_INFO.attachment.plural,
disabled: false,
yAxisMinInterval: 0.5 * GIGABYTE,
},
{
label: DATA_CATEGORY_INFO.profile.titleName,
value: DATA_CATEGORY_INFO.profile.plural,
disabled: false,
yAxisMinInterval: 100,
},
{
label: DATA_CATEGORY_INFO.monitor.titleName,
value: DATA_CATEGORY_INFO.monitor.plural,
disabled: false,
yAxisMinInterval: 100,
},
];
export enum ChartDataTransform {
CUMULATIVE = 'cumulative',
PERIODIC = 'periodic',
}
export const CHART_OPTIONS_DATA_TRANSFORM: SelectValue<ChartDataTransform>[] = [
{
label: t('Cumulative'),
value: ChartDataTransform.CUMULATIVE,
disabled: false,
},
{
label: t('Periodic'),
value: ChartDataTransform.PERIODIC,
disabled: false,
},
];
export enum SeriesTypes {
ACCEPTED = 'Accepted',
DROPPED = 'Dropped',
PROJECTED = 'Projected',
FILTERED = 'Filtered',
}
type DefaultProps = {
/**
* Config for category dropdown options
*/
categoryOptions: CategoryOption[];
/**
* Modify the usageStats using the transformation method selected.
* 1. This must be a pure function!
* 2. If the parent component will handle the data transformation, you should
* replace this prop with "(s) => {return s}"
*/
handleDataTransformation: (
stats: ChartStats,
transform: ChartDataTransform
) => ChartStats;
/**
* Intervals between the x-axis values
*/
usageDateInterval: IntervalPeriod;
/**
* Display datetime in UTC
*/
usageDateShowUtc: boolean;
};
export type UsageChartProps = DefaultProps & {
dataCategory: DataCategoryInfo['plural'];
dataTransform: ChartDataTransform;
theme: Theme;
usageDateEnd: string;
usageDateStart: string;
/**
* Usage data to draw on chart
*/
usageStats: ChartStats;
/**
* Override chart colors for each outcome
*/
categoryColors?: string[];
/**
* Additional data to draw on the chart alongside usage
*/
chartSeries?: SeriesOption[];
/**
* Replace default tooltip
*/
chartTooltip?: TooltipComponentOption;
errors?: Record<string, Error>;
footer?: React.ReactNode;
isError?: boolean;
isLoading?: boolean;
title?: React.ReactNode;
};
type State = {
xAxisDates: string[];
};
export type ChartStats = {
accepted: NonNullable<BarSeriesOption['data']>;
dropped: NonNullable<BarSeriesOption['data']>;
projected: NonNullable<BarSeriesOption['data']>;
filtered?: NonNullable<BarSeriesOption['data']>;
};
export class UsageChart extends Component<UsageChartProps, State> {
static defaultProps: DefaultProps = {
categoryOptions: CHART_OPTIONS_DATACATEGORY,
usageDateShowUtc: true,
usageDateInterval: '1d',
handleDataTransformation: (stats, transform) => {
const chartData: ChartStats = {
accepted: [],
dropped: [],
projected: [],
filtered: [],
};
const isCumulative = transform === ChartDataTransform.CUMULATIVE;
Object.keys(stats).forEach(k => {
let count = 0;
chartData[k] = stats[k].map(stat => {
const [x, y] = stat.value;
count = isCumulative ? count + y : y;
return {
...stat,
value: [x, count],
};
});
});
return chartData;
},
};
state: State = {
xAxisDates: [],
};
/**
* UsageChart needs to generate the X-Axis dates as props.usageStats may
* not pass the complete range of X-Axis data points
*
* E.g. usageStats.accepted covers day 1-15 of a month, usageStats.projected
* either covers day 16-30 or may not be available at all.
*/
static getDerivedStateFromProps(
nextProps: Readonly<UsageChartProps>,
prevState: State
): State {
const {usageDateStart, usageDateEnd, usageDateShowUtc, usageDateInterval} = nextProps;
return {
...prevState,
xAxisDates: getXAxisDates(
usageDateStart,
usageDateEnd,
usageDateShowUtc,
usageDateInterval
),
};
}
get chartColors() {
const {dataCategory, theme} = this.props;
const COLOR_PROJECTED = theme.chartOther;
if (dataCategory === DATA_CATEGORY_INFO.error.plural) {
return [COLOR_ERRORS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
}
if (dataCategory === DATA_CATEGORY_INFO.attachment.plural) {
return [COLOR_ATTACHMENTS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
}
return [COLOR_TRANSACTIONS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
}
get chartMetadata(): {
chartData: ChartStats;
chartLabel: React.ReactNode;
tooltipValueFormatter: (val?: number) => string;
xAxisData: string[];
xAxisLabelInterval: number;
xAxisTickInterval: number;
yAxisFormatter: (val: number) => string;
yAxisMinInterval: number;
} {
const {categoryOptions, usageDateStart, usageDateEnd} = this.props;
const {
usageDateInterval,
usageStats,
dataCategory,
dataTransform,
handleDataTransformation,
} = this.props;
const {xAxisDates} = this.state;
const selectDataCategory = categoryOptions.find(o => o.value === dataCategory);
if (!selectDataCategory) {
throw new Error('Selected item is not supported');
}
// Do not assume that handleDataTransformation is a pure function
const chartData: ChartStats = {
...handleDataTransformation(usageStats, dataTransform),
};
Object.keys(chartData).forEach(k => {
const isProjected = k === SeriesTypes.PROJECTED;
// Map the array and destructure elements to avoid side-effects
chartData[k] = chartData[k].map(stat => {
return {
...stat,
tooltip: {show: false},
itemStyle: {opacity: isProjected ? 0.6 : 1},
};
});
});
// Use hours as common units
const dataPeriod = statsPeriodToDays(undefined, usageDateStart, usageDateEnd) * 24;
const barPeriod = parsePeriodToHours(usageDateInterval);
if (dataPeriod < 0 || barPeriod < 0) {
throw new Error('UsageChart: Unable to parse data time period');
}
const {xAxisTickInterval, xAxisLabelInterval} = getXAxisLabelInterval(
dataPeriod,
dataPeriod / barPeriod
);
const {label, yAxisMinInterval} = selectDataCategory;
return {
chartLabel: label,
chartData,
xAxisData: xAxisDates,
xAxisTickInterval,
xAxisLabelInterval,
yAxisMinInterval,
yAxisFormatter: (val: number) =>
formatUsageWithUnits(val, dataCategory, {
isAbbreviated: true,
useUnitScaling: true,
}),
tooltipValueFormatter: getTooltipFormatter(dataCategory),
};
}
get chartSeries() {
const {chartSeries} = this.props;
const {chartData} = this.chartMetadata;
let series: SeriesOption[] = [
barSeries({
name: SeriesTypes.ACCEPTED,
data: chartData.accepted,
barMinHeight: 1,
stack: 'usage',
legendHoverLink: false,
}),
barSeries({
name: SeriesTypes.FILTERED,
data: chartData.filtered,
barMinHeight: 1,
stack: 'usage',
legendHoverLink: false,
}),
barSeries({
name: SeriesTypes.DROPPED,
data: chartData.dropped,
stack: 'usage',
legendHoverLink: false,
}),
barSeries({
name: SeriesTypes.PROJECTED,
data: chartData.projected,
barMinHeight: 1,
stack: 'usage',
legendHoverLink: false,
}),
];
// Additional series passed by parent component
if (chartSeries) {
series = series.concat(chartSeries as SeriesOption[]);
}
return series;
}
get chartLegendData() {
const {chartSeries} = this.props;
const {chartData} = this.chartMetadata;
const legend: LegendComponentOption['data'] = [
{
name: SeriesTypes.ACCEPTED,
},
];
if (chartData.filtered && chartData.filtered.length > 0) {
legend.push({
name: SeriesTypes.FILTERED,
});
}
if (chartData.dropped.length > 0) {
legend.push({
name: SeriesTypes.DROPPED,
});
}
if (chartData.projected.length > 0) {
legend.push({
name: SeriesTypes.PROJECTED,
});
}
if (chartSeries) {
chartSeries.forEach(chartOption => {
legend.push({name: `${chartOption.name}`});
});
}
return legend;
}
get chartTooltip(): ChartProps['tooltip'] {
const {chartTooltip} = this.props;
if (chartTooltip) {
return chartTooltip;
}
const {tooltipValueFormatter} = this.chartMetadata;
return {
// Trigger to axis prevents tooltip from redrawing when hovering
// over individual bars
trigger: 'axis',
valueFormatter: tooltipValueFormatter,
};
}
renderChart() {
const {categoryColors, theme, title, isLoading, isError, errors} = this.props;
if (isLoading) {
return (
<Placeholder height="200px">
<LoadingIndicator mini />
</Placeholder>
);
}
if (isError) {
return (
<Placeholder height="200px">
<IconWarning size="sm" />
<ErrorMessages data-test-id="error-messages">
{errors &&
Object.keys(errors).map(k => <span key={k}>{errors[k]?.message}</span>)}
</ErrorMessages>
</Placeholder>
);
}
const {
xAxisData,
xAxisTickInterval,
xAxisLabelInterval,
yAxisMinInterval,
yAxisFormatter,
} = this.chartMetadata;
const colors = categoryColors?.length ? categoryColors : this.chartColors;
return (
<Fragment>
<HeaderTitleLegend>{title || t('Current Usage Period')}</HeaderTitleLegend>
{getDynamicText({
value: (
<BaseChart
colors={colors}
grid={{bottom: '3px', left: '0px', right: '10px', top: '40px'}}
xAxis={xAxis({
show: true,
type: 'category',
name: 'Date',
data: xAxisData,
axisTick: {
interval: xAxisTickInterval,
alignWithLabel: true,
},
axisLabel: {
interval: xAxisLabelInterval,
formatter: (label: string) => label.slice(0, 6), // Limit label to 6 chars
},
theme,
})}
yAxis={{
min: 0,
minInterval: yAxisMinInterval,
axisLabel: {
formatter: yAxisFormatter,
color: theme.chartLabel,
},
}}
series={this.chartSeries}
tooltip={this.chartTooltip}
onLegendSelectChanged={() => {}}
legend={Legend({
right: 10,
top: 5,
data: this.chartLegendData,
theme,
})}
/>
),
fixed: <Placeholder height="200px" />,
})}
</Fragment>
);
}
render() {
const {footer} = this.props;
return (
<Panel id="usage-chart" data-test-id="usage-chart">
<ChartContainer>{this.renderChart()}</ChartContainer>
{footer}
</Panel>
);
}
}
export default withTheme(UsageChart);
const ErrorMessages = styled('div')`
display: flex;
flex-direction: column;
margin-top: ${space(1)};
font-size: ${p => p.theme.fontSizeSmall};
`;