eventsChart.tsx 15 KB

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