eventsChart.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import * as React from 'react';
  2. import {InjectedRouter} from 'react-router/lib/Router';
  3. import {withTheme} from '@emotion/react';
  4. import {EChartOption} from 'echarts/lib/echarts';
  5. import {Query} from 'history';
  6. import isEqual from 'lodash/isEqual';
  7. import {Client} from 'app/api';
  8. import AreaChart from 'app/components/charts/areaChart';
  9. import BarChart from 'app/components/charts/barChart';
  10. import ChartZoom, {ZoomRenderProps} from 'app/components/charts/chartZoom';
  11. import ErrorPanel from 'app/components/charts/errorPanel';
  12. import LineChart from 'app/components/charts/lineChart';
  13. import ReleaseSeries from 'app/components/charts/releaseSeries';
  14. import TransitionChart from 'app/components/charts/transitionChart';
  15. import TransparentLoadingMask from 'app/components/charts/transparentLoadingMask';
  16. import {getInterval, RELEASE_LINES_THRESHOLD} from 'app/components/charts/utils';
  17. import {IconWarning} from 'app/icons';
  18. import {t} from 'app/locale';
  19. import {DateString, OrganizationSummary} from 'app/types';
  20. import {Series} from 'app/types/echarts';
  21. import {defined} from 'app/utils';
  22. import {axisLabelFormatter, tooltipFormatter} from 'app/utils/discover/charts';
  23. import {aggregateMultiPlotType} from 'app/utils/discover/fields';
  24. import {Theme} from 'app/utils/theme';
  25. import EventsRequest from './eventsRequest';
  26. type ChartProps = {
  27. theme: Theme;
  28. loading: boolean;
  29. reloading: boolean;
  30. zoomRenderProps: ZoomRenderProps;
  31. timeseriesData: Series[];
  32. showLegend?: boolean;
  33. legendOptions?: EChartOption.Legend;
  34. chartOptions?: EChartOption;
  35. currentSeriesName?: string;
  36. releaseSeries?: Series[];
  37. previousTimeseriesData?: Series | null;
  38. previousSeriesName?: string;
  39. /**
  40. * A callback to allow for post-processing of the series data.
  41. * Can be used to rename series or even insert a new series.
  42. */
  43. seriesTransformer?: (series: Series[]) => Series[];
  44. showDaily?: boolean;
  45. interval?: string;
  46. yAxis: string;
  47. stacked: boolean;
  48. colors?: string[];
  49. /**
  50. * By default, only the release series is disableable. This adds
  51. * a list of series names that are also disableable.
  52. */
  53. disableableSeries?: string[];
  54. chartComponent?:
  55. | React.ComponentType<BarChart['props']>
  56. | React.ComponentType<AreaChart['props']>
  57. | React.ComponentType<LineChart['props']>;
  58. };
  59. type State = {
  60. seriesSelection: Record<string, boolean>;
  61. forceUpdate: boolean;
  62. };
  63. class Chart extends React.Component<ChartProps, State> {
  64. state: State = {
  65. seriesSelection: {},
  66. forceUpdate: false,
  67. };
  68. shouldComponentUpdate(nextProps: ChartProps, nextState: State) {
  69. if (nextState.forceUpdate) {
  70. return true;
  71. }
  72. if (!isEqual(this.state.seriesSelection, nextState.seriesSelection)) {
  73. return true;
  74. }
  75. if (nextProps.reloading || !nextProps.timeseriesData) {
  76. return false;
  77. }
  78. if (
  79. isEqual(this.props.timeseriesData, nextProps.timeseriesData) &&
  80. isEqual(this.props.releaseSeries, nextProps.releaseSeries) &&
  81. isEqual(this.props.previousTimeseriesData, nextProps.previousTimeseriesData)
  82. ) {
  83. return false;
  84. }
  85. return true;
  86. }
  87. getChartComponent():
  88. | React.ComponentType<BarChart['props']>
  89. | React.ComponentType<AreaChart['props']>
  90. | React.ComponentType<LineChart['props']> {
  91. const {showDaily, timeseriesData, yAxis, chartComponent} = this.props;
  92. if (defined(chartComponent)) {
  93. return chartComponent;
  94. }
  95. if (showDaily) {
  96. return BarChart;
  97. }
  98. if (timeseriesData.length > 1) {
  99. switch (aggregateMultiPlotType(yAxis)) {
  100. case 'line':
  101. return LineChart;
  102. case 'area':
  103. return AreaChart;
  104. default:
  105. throw new Error(`Unknown multi plot type for ${yAxis}`);
  106. }
  107. }
  108. return AreaChart;
  109. }
  110. handleLegendSelectChanged = legendChange => {
  111. const {disableableSeries = []} = this.props;
  112. const {selected} = legendChange;
  113. const seriesSelection = Object.keys(selected).reduce((state, key) => {
  114. // we only want them to be able to disable the Releases series,
  115. // and not any of the other possible series here
  116. const disableable = key === 'Releases' || disableableSeries.includes(key);
  117. state[key] = disableable ? selected[key] : true;
  118. return state;
  119. }, {});
  120. // we have to force an update here otherwise ECharts will
  121. // update its internal state and disable the series
  122. this.setState({seriesSelection, forceUpdate: true}, () =>
  123. this.setState({forceUpdate: false})
  124. );
  125. };
  126. render() {
  127. const {
  128. theme,
  129. loading: _loading,
  130. reloading: _reloading,
  131. yAxis,
  132. releaseSeries,
  133. zoomRenderProps,
  134. timeseriesData,
  135. previousTimeseriesData,
  136. showLegend,
  137. legendOptions,
  138. chartOptions: chartOptionsProp,
  139. currentSeriesName,
  140. previousSeriesName,
  141. seriesTransformer,
  142. colors,
  143. ...props
  144. } = this.props;
  145. const {seriesSelection} = this.state;
  146. const data = [currentSeriesName ?? t('Current'), previousSeriesName ?? t('Previous')];
  147. const releasesLegend = t('Releases');
  148. if (Array.isArray(releaseSeries)) {
  149. data.push(releasesLegend);
  150. }
  151. // Temporary fix to improve performance on pages with a high number of releases.
  152. const releases = releaseSeries && releaseSeries[0];
  153. const hideReleasesByDefault =
  154. Array.isArray(releaseSeries) &&
  155. (releases as any)?.markLine?.data &&
  156. (releases as any).markLine.data.length >= RELEASE_LINES_THRESHOLD;
  157. const selected = !Array.isArray(releaseSeries)
  158. ? seriesSelection
  159. : Object.keys(seriesSelection).length === 0 && hideReleasesByDefault
  160. ? {[releasesLegend]: false}
  161. : seriesSelection;
  162. const legend = showLegend
  163. ? {
  164. right: 16,
  165. top: 12,
  166. data,
  167. selected,
  168. ...(legendOptions ?? {}),
  169. }
  170. : undefined;
  171. let series = Array.isArray(releaseSeries)
  172. ? [...timeseriesData, ...releaseSeries]
  173. : timeseriesData;
  174. if (seriesTransformer) {
  175. series = seriesTransformer(series);
  176. }
  177. const chartOptions = {
  178. colors: timeseriesData.length
  179. ? colors?.slice(0, series.length) ?? [
  180. ...theme.charts.getColorPalette(timeseriesData.length - 2),
  181. ]
  182. : undefined,
  183. grid: {
  184. left: '24px',
  185. right: '24px',
  186. top: '32px',
  187. bottom: '12px',
  188. },
  189. seriesOptions: {
  190. showSymbol: false,
  191. },
  192. tooltip: {
  193. trigger: 'axis' as const,
  194. truncate: 80,
  195. valueFormatter: (value: number) => tooltipFormatter(value, yAxis),
  196. },
  197. yAxis: {
  198. axisLabel: {
  199. color: theme.chartLabel,
  200. formatter: (value: number) => axisLabelFormatter(value, yAxis),
  201. },
  202. },
  203. ...(chartOptionsProp ?? {}),
  204. };
  205. const Component = this.getChartComponent();
  206. return (
  207. <Component
  208. {...props}
  209. {...zoomRenderProps}
  210. {...chartOptions}
  211. legend={legend}
  212. onLegendSelectChanged={this.handleLegendSelectChanged}
  213. series={series}
  214. previousPeriod={previousTimeseriesData ? [previousTimeseriesData] : undefined}
  215. />
  216. );
  217. }
  218. }
  219. const ThemedChart = withTheme(Chart);
  220. export type EventsChartProps = {
  221. api: Client;
  222. router: InjectedRouter;
  223. organization: OrganizationSummary;
  224. /**
  225. * Project ids
  226. */
  227. projects: number[];
  228. /**
  229. * Environment condition.
  230. */
  231. environments: string[];
  232. /**
  233. * The discover query string to find events with.
  234. */
  235. query: string;
  236. /**
  237. * The aggregate/metric to plot.
  238. */
  239. yAxis: string;
  240. /**
  241. * Relative datetime expression. eg. 14d
  242. */
  243. period?: string;
  244. /**
  245. * Absolute start date.
  246. */
  247. start: DateString;
  248. /**
  249. * Absolute end date.
  250. */
  251. end: DateString;
  252. /**
  253. * Should datetimes be formatted in UTC?
  254. */
  255. utc?: boolean | null;
  256. /**
  257. * Don't show the previous period's data. Will automatically disable
  258. * when start/end are used.
  259. */
  260. disablePrevious?: boolean;
  261. /**
  262. * Don't show the release marklines.
  263. */
  264. disableReleases?: boolean;
  265. /**
  266. * A list of release names to visually emphasize. Can only be used when `disableReleases` is false.
  267. */
  268. emphasizeReleases?: string[];
  269. /**
  270. * Fetch n top events as dictated by the field and orderby props.
  271. */
  272. topEvents?: number;
  273. /**
  274. * The fields that act as grouping conditions when generating a topEvents chart.
  275. */
  276. field?: string[];
  277. /**
  278. * The interval resolution for a chart e.g. 1m, 5m, 1d
  279. */
  280. interval?: string;
  281. /**
  282. * Order condition when showing topEvents
  283. */
  284. orderby?: string;
  285. /**
  286. * Override the interval calculation and show daily results.
  287. */
  288. showDaily?: boolean;
  289. confirmedQuery?: boolean;
  290. /**
  291. * Override the default color palette.
  292. */
  293. colors?: string[];
  294. /**
  295. * Markup for optional chart header
  296. */
  297. chartHeader?: React.ReactNode;
  298. releaseQueryExtra?: Query;
  299. preserveReleaseQueryParams?: boolean;
  300. } & Pick<
  301. ChartProps,
  302. | 'currentSeriesName'
  303. | 'previousSeriesName'
  304. | 'seriesTransformer'
  305. | 'showLegend'
  306. | 'disableableSeries'
  307. | 'legendOptions'
  308. | 'chartOptions'
  309. | 'chartComponent'
  310. >;
  311. type ChartDataProps = {
  312. zoomRenderProps: ZoomRenderProps;
  313. errored: boolean;
  314. loading: boolean;
  315. reloading: boolean;
  316. results?: Series[];
  317. timeseriesData?: Series[];
  318. previousTimeseriesData?: Series | null;
  319. releaseSeries?: Series[];
  320. };
  321. class EventsChart extends React.Component<EventsChartProps> {
  322. render() {
  323. const {
  324. api,
  325. period,
  326. utc,
  327. query,
  328. router,
  329. start,
  330. end,
  331. projects,
  332. environments,
  333. showLegend,
  334. yAxis,
  335. disablePrevious,
  336. disableReleases,
  337. emphasizeReleases,
  338. currentSeriesName: currentName,
  339. previousSeriesName: previousName,
  340. seriesTransformer,
  341. field,
  342. interval,
  343. showDaily,
  344. topEvents,
  345. orderby,
  346. confirmedQuery,
  347. colors,
  348. chartHeader,
  349. legendOptions,
  350. chartOptions,
  351. preserveReleaseQueryParams,
  352. releaseQueryExtra,
  353. disableableSeries,
  354. chartComponent,
  355. ...props
  356. } = this.props;
  357. // Include previous only on relative dates (defaults to relative if no start and end)
  358. const includePrevious = !disablePrevious && !start && !end;
  359. const previousSeriesName =
  360. previousName ?? (yAxis ? t('previous %s', yAxis) : undefined);
  361. const currentSeriesName = currentName ?? yAxis;
  362. const intervalVal = showDaily ? '1d' : interval || getInterval(this.props, true);
  363. let chartImplementation = ({
  364. zoomRenderProps,
  365. releaseSeries,
  366. errored,
  367. loading,
  368. reloading,
  369. results,
  370. timeseriesData,
  371. previousTimeseriesData,
  372. }: ChartDataProps) => {
  373. if (errored) {
  374. return (
  375. <ErrorPanel>
  376. <IconWarning color="gray300" size="lg" />
  377. </ErrorPanel>
  378. );
  379. }
  380. const seriesData = results ? results : timeseriesData;
  381. return (
  382. <TransitionChart loading={loading} reloading={reloading}>
  383. <TransparentLoadingMask visible={reloading} />
  384. {React.isValidElement(chartHeader) && chartHeader}
  385. <ThemedChart
  386. zoomRenderProps={zoomRenderProps}
  387. loading={loading}
  388. reloading={reloading}
  389. showLegend={showLegend}
  390. releaseSeries={releaseSeries || []}
  391. timeseriesData={seriesData ?? []}
  392. previousTimeseriesData={previousTimeseriesData}
  393. currentSeriesName={currentSeriesName}
  394. previousSeriesName={previousSeriesName}
  395. seriesTransformer={seriesTransformer}
  396. stacked={typeof topEvents === 'number' && topEvents > 0}
  397. yAxis={yAxis}
  398. showDaily={showDaily}
  399. colors={colors}
  400. legendOptions={legendOptions}
  401. chartOptions={chartOptions}
  402. disableableSeries={disableableSeries}
  403. chartComponent={chartComponent}
  404. />
  405. </TransitionChart>
  406. );
  407. };
  408. if (!disableReleases) {
  409. const previousChart = chartImplementation;
  410. chartImplementation = chartProps => (
  411. <ReleaseSeries
  412. utc={utc}
  413. period={period}
  414. start={start}
  415. end={end}
  416. projects={projects}
  417. environments={environments}
  418. emphasizeReleases={emphasizeReleases}
  419. preserveQueryParams={preserveReleaseQueryParams}
  420. queryExtra={releaseQueryExtra}
  421. >
  422. {({releaseSeries}) => previousChart({...chartProps, releaseSeries})}
  423. </ReleaseSeries>
  424. );
  425. }
  426. return (
  427. <ChartZoom
  428. router={router}
  429. period={period}
  430. start={start}
  431. end={end}
  432. utc={utc}
  433. {...props}
  434. >
  435. {zoomRenderProps => (
  436. <EventsRequest
  437. {...props}
  438. api={api}
  439. period={period}
  440. project={projects}
  441. environment={environments}
  442. start={start}
  443. end={end}
  444. interval={intervalVal}
  445. query={query}
  446. includePrevious={includePrevious}
  447. currentSeriesName={currentSeriesName}
  448. previousSeriesName={previousSeriesName}
  449. yAxis={yAxis}
  450. field={field}
  451. orderby={orderby}
  452. topEvents={topEvents}
  453. confirmedQuery={confirmedQuery}
  454. partial
  455. >
  456. {eventData =>
  457. chartImplementation({
  458. ...eventData,
  459. zoomRenderProps,
  460. })
  461. }
  462. </EventsRequest>
  463. )}
  464. </ChartZoom>
  465. );
  466. }
  467. }
  468. export default EventsChart;