projectBaseSessionsChart.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import {Component, Fragment} from 'react';
  2. import {InjectedRouter} from 'react-router';
  3. import {useTheme} from '@emotion/react';
  4. import type {LegendComponentOption} from 'echarts';
  5. import isEqual from 'lodash/isEqual';
  6. import {Client} from 'sentry/api';
  7. import ChartZoom, {ZoomRenderProps} from 'sentry/components/charts/chartZoom';
  8. import ErrorPanel from 'sentry/components/charts/errorPanel';
  9. import {LineChart, LineChartProps} from 'sentry/components/charts/lineChart';
  10. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  11. import StackedAreaChart from 'sentry/components/charts/stackedAreaChart';
  12. import {HeaderTitleLegend} from 'sentry/components/charts/styles';
  13. import TransitionChart from 'sentry/components/charts/transitionChart';
  14. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  15. import {RELEASE_LINES_THRESHOLD} from 'sentry/components/charts/utils';
  16. import QuestionTooltip from 'sentry/components/questionTooltip';
  17. import {IconWarning} from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import {Organization, PageFilters} from 'sentry/types';
  20. import {EChartEventHandler, Series} from 'sentry/types/echarts';
  21. import getDynamicText from 'sentry/utils/getDynamicText';
  22. import {MINUTES_THRESHOLD_TO_DISPLAY_SECONDS} from 'sentry/utils/sessions';
  23. import {Theme} from 'sentry/utils/theme';
  24. import withPageFilters from 'sentry/utils/withPageFilters';
  25. import {displayCrashFreePercent} from 'sentry/views/releases/utils';
  26. import {sessionTerm} from 'sentry/views/releases/utils/sessionTerm';
  27. import {DisplayModes} from '../projectCharts';
  28. import ProjectSessionsChartRequest from './projectSessionsChartRequest';
  29. type Props = {
  30. api: Client;
  31. displayMode:
  32. | DisplayModes.SESSIONS
  33. | DisplayModes.STABILITY_USERS
  34. | DisplayModes.STABILITY;
  35. onTotalValuesChange: (value: number | null) => void;
  36. organization: Organization;
  37. router: InjectedRouter;
  38. selection: PageFilters;
  39. title: string;
  40. disablePrevious?: boolean;
  41. help?: string;
  42. query?: string;
  43. };
  44. function ProjectBaseSessionsChart({
  45. title,
  46. organization,
  47. router,
  48. selection,
  49. api,
  50. onTotalValuesChange,
  51. displayMode,
  52. help,
  53. disablePrevious,
  54. query,
  55. }: Props) {
  56. const theme = useTheme();
  57. const {projects, environments, datetime} = selection;
  58. const {start, end, period, utc} = datetime;
  59. return (
  60. <Fragment>
  61. {getDynamicText({
  62. value: (
  63. <ChartZoom router={router} period={period} start={start} end={end} utc={utc}>
  64. {zoomRenderProps => (
  65. <ProjectSessionsChartRequest
  66. api={api}
  67. selection={selection}
  68. organization={organization}
  69. onTotalValuesChange={onTotalValuesChange}
  70. displayMode={displayMode}
  71. disablePrevious={disablePrevious}
  72. query={query}
  73. >
  74. {({
  75. errored,
  76. loading,
  77. reloading,
  78. timeseriesData,
  79. previousTimeseriesData,
  80. }) => (
  81. <ReleaseSeries
  82. utc={utc}
  83. period={period}
  84. start={start}
  85. end={end}
  86. projects={projects}
  87. environments={environments}
  88. query={query}
  89. >
  90. {({releaseSeries}) => {
  91. if (errored) {
  92. return (
  93. <ErrorPanel>
  94. <IconWarning color="gray300" size="lg" />
  95. </ErrorPanel>
  96. );
  97. }
  98. return (
  99. <TransitionChart loading={loading} reloading={reloading}>
  100. <TransparentLoadingMask visible={reloading} />
  101. <HeaderTitleLegend>
  102. {title}
  103. {help && (
  104. <QuestionTooltip size="sm" position="top" title={help} />
  105. )}
  106. </HeaderTitleLegend>
  107. <Chart
  108. theme={theme}
  109. zoomRenderProps={zoomRenderProps}
  110. reloading={reloading}
  111. timeSeries={timeseriesData}
  112. previousTimeSeries={
  113. previousTimeseriesData
  114. ? [previousTimeseriesData]
  115. : undefined
  116. }
  117. releaseSeries={releaseSeries}
  118. displayMode={displayMode}
  119. />
  120. </TransitionChart>
  121. );
  122. }}
  123. </ReleaseSeries>
  124. )}
  125. </ProjectSessionsChartRequest>
  126. )}
  127. </ChartZoom>
  128. ),
  129. fixed: `${title} Chart`,
  130. })}
  131. </Fragment>
  132. );
  133. }
  134. type ChartProps = {
  135. displayMode:
  136. | DisplayModes.SESSIONS
  137. | DisplayModes.STABILITY
  138. | DisplayModes.STABILITY_USERS;
  139. releaseSeries: Series[];
  140. reloading: boolean;
  141. theme: Theme;
  142. timeSeries: Series[];
  143. zoomRenderProps: ZoomRenderProps;
  144. previousTimeSeries?: Series[];
  145. };
  146. type ChartState = {
  147. forceUpdate: boolean;
  148. seriesSelection: Record<string, boolean>;
  149. };
  150. class Chart extends Component<ChartProps, ChartState> {
  151. state: ChartState = {
  152. seriesSelection: {},
  153. forceUpdate: false,
  154. };
  155. shouldComponentUpdate(nextProps: ChartProps, nextState: ChartState) {
  156. if (nextState.forceUpdate) {
  157. return true;
  158. }
  159. if (!isEqual(this.state.seriesSelection, nextState.seriesSelection)) {
  160. return true;
  161. }
  162. if (
  163. nextProps.releaseSeries !== this.props.releaseSeries &&
  164. !nextProps.reloading &&
  165. !this.props.reloading
  166. ) {
  167. return true;
  168. }
  169. if (this.props.reloading && !nextProps.reloading) {
  170. return true;
  171. }
  172. if (nextProps.timeSeries !== this.props.timeSeries) {
  173. return true;
  174. }
  175. return false;
  176. }
  177. // inspired by app/components/charts/eventsChart.tsx@handleLegendSelectChanged
  178. handleLegendSelectChanged: EChartEventHandler<{
  179. name: string;
  180. selected: Record<string, boolean>;
  181. type: 'legendselectchanged';
  182. }> = ({selected}) => {
  183. const seriesSelection = Object.keys(selected).reduce((state, key) => {
  184. state[key] = selected[key];
  185. return state;
  186. }, {});
  187. // we have to force an update here otherwise ECharts will
  188. // update its internal state and disable the series
  189. this.setState({seriesSelection, forceUpdate: true}, () =>
  190. this.setState({forceUpdate: false})
  191. );
  192. };
  193. get isCrashFree() {
  194. const {displayMode} = this.props;
  195. return [DisplayModes.STABILITY, DisplayModes.STABILITY_USERS].includes(displayMode);
  196. }
  197. get legend(): LegendComponentOption {
  198. const {theme, timeSeries, previousTimeSeries, releaseSeries} = this.props;
  199. const {seriesSelection} = this.state;
  200. const hideReleasesByDefault =
  201. (releaseSeries[0] as any)?.markLine?.data.length >= RELEASE_LINES_THRESHOLD;
  202. const hideHealthyByDefault = timeSeries
  203. .filter(s => sessionTerm.healthy !== s.seriesName)
  204. .some(s => s.data.some(d => d.value > 0));
  205. const selected =
  206. Object.keys(seriesSelection).length === 0 &&
  207. (hideReleasesByDefault || hideHealthyByDefault)
  208. ? {
  209. [t('Releases')]: !hideReleasesByDefault,
  210. [sessionTerm.healthy]: !hideHealthyByDefault,
  211. }
  212. : seriesSelection;
  213. return {
  214. right: 10,
  215. top: 0,
  216. icon: 'circle',
  217. itemHeight: 8,
  218. itemWidth: 8,
  219. itemGap: 12,
  220. align: 'left' as const,
  221. textStyle: {
  222. color: theme.textColor,
  223. verticalAlign: 'top',
  224. fontSize: 11,
  225. fontFamily: theme.text.family,
  226. },
  227. data: [
  228. ...timeSeries.map(s => s.seriesName),
  229. ...(previousTimeSeries ?? []).map(s => s.seriesName),
  230. ...releaseSeries.map(s => s.seriesName),
  231. ],
  232. selected,
  233. };
  234. }
  235. get chartOptions(): Omit<LineChartProps, 'series'> {
  236. return {
  237. grid: {left: '10px', right: '10px', top: '40px', bottom: '0px'},
  238. seriesOptions: {
  239. showSymbol: false,
  240. },
  241. tooltip: {
  242. trigger: 'axis',
  243. truncate: 80,
  244. valueFormatter: (value: number | null) => {
  245. if (value === null) {
  246. return '\u2014';
  247. }
  248. if (this.isCrashFree) {
  249. return displayCrashFreePercent(value, 0, 3);
  250. }
  251. return typeof value === 'number' ? value.toLocaleString() : value;
  252. },
  253. },
  254. yAxis: this.isCrashFree
  255. ? {
  256. axisLabel: {
  257. formatter: (value: number) => displayCrashFreePercent(value),
  258. },
  259. scale: true,
  260. max: 100,
  261. }
  262. : {min: 0},
  263. };
  264. }
  265. render() {
  266. const {zoomRenderProps, timeSeries, previousTimeSeries, releaseSeries} = this.props;
  267. const ChartComponent = this.isCrashFree ? LineChart : StackedAreaChart;
  268. return (
  269. <ChartComponent
  270. {...zoomRenderProps}
  271. {...this.chartOptions}
  272. legend={this.legend}
  273. series={
  274. Array.isArray(releaseSeries) ? [...timeSeries, ...releaseSeries] : timeSeries
  275. }
  276. previousPeriod={previousTimeSeries}
  277. onLegendSelectChanged={this.handleLegendSelectChanged}
  278. minutesThresholdToDisplaySeconds={MINUTES_THRESHOLD_TO_DISPLAY_SECONDS}
  279. transformSinglePointToBar
  280. />
  281. );
  282. }
  283. }
  284. export default withPageFilters(ProjectBaseSessionsChart);