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; 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; 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[] = [ { 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; footer?: React.ReactNode; isError?: boolean; isLoading?: boolean; title?: React.ReactNode; }; type State = { xAxisDates: string[]; }; export type ChartStats = { accepted: NonNullable; dropped: NonNullable; projected: NonNullable; filtered?: NonNullable; }; export class UsageChart extends Component { 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, 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 ( ); } if (isError) { return ( {errors && Object.keys(errors).map(k => {errors[k]?.message})} ); } const { xAxisData, xAxisTickInterval, xAxisLabelInterval, yAxisMinInterval, yAxisFormatter, } = this.chartMetadata; const colors = categoryColors?.length ? categoryColors : this.chartColors; return ( {title || t('Current Usage Period')} {getDynamicText({ value: ( 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: , })} ); } render() { const {footer} = this.props; return ( {this.renderChart()} {footer} ); } } export default withTheme(UsageChart); const ErrorMessages = styled('div')` display: flex; flex-direction: column; margin-top: ${space(1)}; font-size: ${p => p.theme.fontSizeSmall}; `;