eventsChart.tsx 16 KB

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