import {Component, Fragment} from 'react'; import type {InjectedRouter} from 'react-router'; import type {Theme} from '@emotion/react'; import {useTheme} from '@emotion/react'; import type {LegendComponentOption, LineSeriesOption} from 'echarts'; import isEqual from 'lodash/isEqual'; import type {Client} from 'sentry/api'; 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 StackedAreaChart from 'sentry/components/charts/stackedAreaChart'; import {HeaderTitleLegend} from 'sentry/components/charts/styles'; import TransitionChart from 'sentry/components/charts/transitionChart'; import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask'; import {RELEASE_LINES_THRESHOLD} from 'sentry/components/charts/utils'; import QuestionTooltip from 'sentry/components/questionTooltip'; import {IconWarning} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Organization, PageFilters} from 'sentry/types'; import type {EChartEventHandler, Series} from 'sentry/types/echarts'; import getDynamicText from 'sentry/utils/getDynamicText'; import {MINUTES_THRESHOLD_TO_DISPLAY_SECONDS} from 'sentry/utils/sessions'; import withPageFilters from 'sentry/utils/withPageFilters'; import {displayCrashFreePercent} from 'sentry/views/releases/utils'; import {sessionTerm} from 'sentry/views/releases/utils/sessionTerm'; import {DisplayModes} from '../projectCharts'; import ProjectSessionsAnrRequest from './projectSessionsAnrRequest'; import ProjectSessionsChartRequest from './projectSessionsChartRequest'; type Props = { api: Client; displayMode: | DisplayModes.SESSIONS | DisplayModes.STABILITY_USERS | DisplayModes.ANR_RATE | DisplayModes.FOREGROUND_ANR_RATE | DisplayModes.STABILITY; onTotalValuesChange: (value: number | null) => void; organization: Organization; router: InjectedRouter; selection: PageFilters; title: string; disablePrevious?: boolean; help?: string; query?: string; }; function ProjectBaseSessionsChart({ title, organization, router, selection, api, onTotalValuesChange, displayMode, help, disablePrevious, query, }: Props) { const theme = useTheme(); const {projects, environments, datetime} = selection; const {start, end, period, utc} = datetime; const Request = [DisplayModes.ANR_RATE, DisplayModes.FOREGROUND_ANR_RATE].includes( displayMode ) ? ProjectSessionsAnrRequest : ProjectSessionsChartRequest; return ( {getDynamicText({ value: ( {zoomRenderProps => ( {({ errored, loading, reloading, timeseriesData, previousTimeseriesData, additionalSeries, }) => ( {({releaseSeries}) => { if (errored) { return ( ); } return ( {title} {help && ( )} ); }} )} )} ), fixed: `${title} Chart`, })} ); } type ChartProps = { displayMode: | DisplayModes.SESSIONS | DisplayModes.STABILITY | DisplayModes.STABILITY_USERS | DisplayModes.ANR_RATE | DisplayModes.FOREGROUND_ANR_RATE; releaseSeries: Series[]; reloading: boolean; theme: Theme; timeSeries: Series[]; zoomRenderProps: ZoomRenderProps; additionalSeries?: LineSeriesOption[]; previousTimeSeries?: Series[]; }; type ChartState = { forceUpdate: boolean; seriesSelection: Record; }; class Chart extends Component { state: ChartState = { seriesSelection: {}, forceUpdate: false, }; shouldComponentUpdate(nextProps: ChartProps, nextState: ChartState) { if (nextState.forceUpdate) { return true; } if (!isEqual(this.state.seriesSelection, nextState.seriesSelection)) { return true; } if ( nextProps.releaseSeries !== this.props.releaseSeries && !nextProps.reloading && !this.props.reloading ) { return true; } if (this.props.reloading && !nextProps.reloading) { return true; } if (nextProps.timeSeries !== this.props.timeSeries) { return true; } return false; } // inspired by app/components/charts/eventsChart.tsx@handleLegendSelectChanged handleLegendSelectChanged: EChartEventHandler<{ name: string; selected: Record; type: 'legendselectchanged'; }> = ({selected}) => { const seriesSelection = Object.keys(selected).reduce((state, key) => { state[key] = selected[key]; 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}) ); }; get isCrashFree() { const {displayMode} = this.props; return [DisplayModes.STABILITY, DisplayModes.STABILITY_USERS].includes(displayMode); } get isAnr() { const {displayMode} = this.props; return [DisplayModes.ANR_RATE, DisplayModes.FOREGROUND_ANR_RATE].includes( displayMode ); } get legend(): LegendComponentOption { const {theme, timeSeries, previousTimeSeries, releaseSeries, additionalSeries} = this.props; const {seriesSelection} = this.state; const hideReleasesByDefault = (releaseSeries[0] as any)?.markLine?.data.length >= RELEASE_LINES_THRESHOLD; const hideHealthyByDefault = timeSeries .filter(s => sessionTerm.healthy !== s.seriesName) .some(s => s.data.some(d => d.value > 0)); const selected = Object.keys(seriesSelection).length === 0 && (hideReleasesByDefault || hideHealthyByDefault) ? { [t('Releases')]: !hideReleasesByDefault, [sessionTerm.healthy]: !hideHealthyByDefault, } : seriesSelection; return { right: 10, top: 0, icon: 'circle', itemHeight: 8, itemWidth: 8, itemGap: 12, align: 'left' as const, textStyle: { color: theme.textColor, verticalAlign: 'top', fontSize: 11, fontFamily: theme.text.family, }, data: [ ...timeSeries.map(s => s.seriesName), ...(previousTimeSeries ?? []).map(s => s.seriesName), ...(additionalSeries ?? []).map(s => s.name?.toString() ?? ''), ...releaseSeries.map(s => s.seriesName), ], selected, }; } get chartOptions(): Omit { return { grid: {left: '10px', right: '10px', top: '40px', bottom: '0px'}, seriesOptions: { showSymbol: false, }, tooltip: { trigger: 'axis', truncate: 80, valueFormatter: (value: number | null) => { if (value === null) { return '\u2014'; } if (this.isCrashFree) { return displayCrashFreePercent(value, 0, 3); } if (this.isAnr) { return displayCrashFreePercent(value, 0, 3, false); } return typeof value === 'number' ? value.toLocaleString() : value; }, }, yAxis: this.isCrashFree ? { axisLabel: { formatter: (value: number) => displayCrashFreePercent(value), }, scale: true, max: 100, } : this.isAnr ? { axisLabel: { formatter: (value: number) => displayCrashFreePercent(value, 0, 3, false), }, scale: true, } : {min: 0}, }; } render() { const { zoomRenderProps, timeSeries, previousTimeSeries, releaseSeries, additionalSeries, } = this.props; const ChartComponent = this.isCrashFree ? LineChart : this.isAnr ? BarChart : StackedAreaChart; return ( ); } } export default withPageFilters(ProjectBaseSessionsChart);