eventsChart.tsx 16 KB

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