chart.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. import type React from 'react';
  2. import {Component} from 'react';
  3. import type {Theme} from '@emotion/react';
  4. import {withTheme} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import type {LegendComponentOption} from 'echarts';
  7. import type {Location} from 'history';
  8. import isEqual from 'lodash/isEqual';
  9. import omit from 'lodash/omit';
  10. import {AreaChart} from 'sentry/components/charts/areaChart';
  11. import {BarChart} from 'sentry/components/charts/barChart';
  12. import ChartZoom from 'sentry/components/charts/chartZoom';
  13. import ErrorPanel from 'sentry/components/charts/errorPanel';
  14. import {LineChart} from 'sentry/components/charts/lineChart';
  15. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  16. import SimpleTableChart from 'sentry/components/charts/simpleTableChart';
  17. import TransitionChart from 'sentry/components/charts/transitionChart';
  18. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  19. import {getSeriesSelection, isChartHovered} from 'sentry/components/charts/utils';
  20. import LoadingIndicator from 'sentry/components/loadingIndicator';
  21. import type {PlaceholderProps} from 'sentry/components/placeholder';
  22. import Placeholder from 'sentry/components/placeholder';
  23. import {IconWarning} from 'sentry/icons';
  24. import {space} from 'sentry/styles/space';
  25. import type {PageFilters} from 'sentry/types/core';
  26. import type {
  27. EChartDataZoomHandler,
  28. EChartEventHandler,
  29. ReactEchartsRef,
  30. } from 'sentry/types/echarts';
  31. import type {Confidence, Organization} from 'sentry/types/organization';
  32. import {defined} from 'sentry/utils';
  33. import {
  34. axisLabelFormatter,
  35. axisLabelFormatterUsingAggregateOutputType,
  36. getDurationUnit,
  37. tooltipFormatter,
  38. } from 'sentry/utils/discover/charts';
  39. import type {EventsMetaType, MetaType} from 'sentry/utils/discover/eventView';
  40. import type {AggregationOutputType} from 'sentry/utils/discover/fields';
  41. import {
  42. aggregateOutputType,
  43. getAggregateArg,
  44. getEquation,
  45. getMeasurementSlug,
  46. isEquation,
  47. maybeEquationAlias,
  48. stripDerivedMetricsPrefix,
  49. stripEquationPrefix,
  50. } from 'sentry/utils/discover/fields';
  51. import getDynamicText from 'sentry/utils/getDynamicText';
  52. import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
  53. import ConfidenceWarning from 'sentry/views/dashboards/widgetCard/confidenceWarning';
  54. import {getBucketSize} from 'sentry/views/dashboards/widgetCard/utils';
  55. import WidgetLegendNameEncoderDecoder from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
  56. import {getFormatter} from '../../../components/charts/components/tooltip';
  57. import {getDatasetConfig} from '../datasetConfig/base';
  58. import type {Widget} from '../types';
  59. import {DisplayType} from '../types';
  60. import type WidgetLegendSelectionState from '../widgetLegendSelectionState';
  61. import {BigNumberWidgetVisualization} from '../widgets/bigNumberWidget/bigNumberWidgetVisualization';
  62. import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
  63. const OTHER = 'Other';
  64. const PERCENTAGE_DECIMAL_POINTS = 3;
  65. type TableResultProps = Pick<
  66. GenericWidgetQueriesChildrenProps,
  67. 'errorMessage' | 'loading' | 'tableResults'
  68. >;
  69. type WidgetCardChartProps = Pick<
  70. GenericWidgetQueriesChildrenProps,
  71. 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'loading'
  72. > & {
  73. location: Location;
  74. organization: Organization;
  75. selection: PageFilters;
  76. theme: Theme;
  77. widget: Widget;
  78. widgetLegendState: WidgetLegendSelectionState;
  79. chartGroup?: string;
  80. confidence?: Confidence;
  81. expandNumbers?: boolean;
  82. isMobile?: boolean;
  83. legendOptions?: LegendComponentOption;
  84. minTableColumnWidth?: string;
  85. noPadding?: boolean;
  86. onLegendSelectChanged?: EChartEventHandler<{
  87. name: string;
  88. selected: Record<string, boolean>;
  89. type: 'legendselectchanged';
  90. }>;
  91. onZoom?: EChartDataZoomHandler;
  92. shouldResize?: boolean;
  93. showConfidenceWarning?: boolean;
  94. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  95. windowWidth?: number;
  96. };
  97. class WidgetCardChart extends Component<WidgetCardChartProps> {
  98. shouldComponentUpdate(nextProps: WidgetCardChartProps): boolean {
  99. if (
  100. this.props.widget.displayType === DisplayType.BIG_NUMBER &&
  101. nextProps.widget.displayType === DisplayType.BIG_NUMBER &&
  102. (this.props.windowWidth !== nextProps.windowWidth ||
  103. !isEqual(this.props.widget?.layout, nextProps.widget?.layout))
  104. ) {
  105. return true;
  106. }
  107. // Widget title changes should not update the WidgetCardChart component tree
  108. const currentProps = {
  109. ...omit(this.props, ['windowWidth']),
  110. widget: {
  111. ...this.props.widget,
  112. title: '',
  113. },
  114. };
  115. nextProps = {
  116. ...omit(nextProps, ['windowWidth']),
  117. widget: {
  118. ...nextProps.widget,
  119. title: '',
  120. },
  121. };
  122. return !isEqual(currentProps, nextProps);
  123. }
  124. tableResultComponent({loading, tableResults}: TableResultProps): React.ReactNode {
  125. const {location, widget, selection, minTableColumnWidth} = this.props;
  126. if (typeof tableResults === 'undefined') {
  127. // Align height to other charts.
  128. return <LoadingPlaceholder />;
  129. }
  130. const datasetConfig = getDatasetConfig(widget.widgetType);
  131. const getCustomFieldRenderer = (
  132. field: string,
  133. meta: MetaType,
  134. organization?: Organization
  135. ) => {
  136. return (
  137. datasetConfig.getCustomFieldRenderer?.(field, meta, widget, organization) || null
  138. );
  139. };
  140. return tableResults.map((result, i) => {
  141. const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
  142. const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
  143. const eventView = eventViewFromWidget(widget.title, widget.queries[0]!, selection);
  144. return (
  145. <TableWrapper key={`table:${result.title}`}>
  146. <StyledSimpleTableChart
  147. eventView={eventView}
  148. fieldAliases={fieldAliases}
  149. location={location}
  150. fields={fields}
  151. title={tableResults.length > 1 ? result.title : ''}
  152. loading={loading}
  153. loader={<LoadingPlaceholder />}
  154. metadata={result.meta}
  155. data={result.data}
  156. stickyHeaders
  157. fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
  158. getCustomFieldRenderer={getCustomFieldRenderer}
  159. minColumnWidth={minTableColumnWidth}
  160. />
  161. </TableWrapper>
  162. );
  163. });
  164. }
  165. bigNumberComponent({loading, tableResults}: TableResultProps): React.ReactNode {
  166. if (typeof tableResults === 'undefined' || loading) {
  167. return <BigNumber>{'\u2014'}</BigNumber>;
  168. }
  169. const {widget} = this.props;
  170. return tableResults.map((result, i) => {
  171. const tableMeta = {...result.meta};
  172. const fields = Object.keys(tableMeta?.fields ?? {});
  173. let field = fields[0]!;
  174. let selectedField = field;
  175. if (defined(widget.queries[0]!.selectedAggregate)) {
  176. const index = widget.queries[0]!.selectedAggregate;
  177. selectedField = widget.queries[0]!.aggregates[index]!;
  178. if (fields.includes(selectedField)) {
  179. field = selectedField;
  180. }
  181. }
  182. const data = result?.data;
  183. const meta = result?.meta as EventsMetaType;
  184. const value = data?.[0]?.[selectedField];
  185. if (
  186. !field ||
  187. !result.data?.length ||
  188. selectedField === 'equation|' ||
  189. selectedField === '' ||
  190. !defined(value) ||
  191. !Number.isFinite(value) ||
  192. Number.isNaN(value)
  193. ) {
  194. return <BigNumber key={`big_number:${result.title}`}>{'\u2014'}</BigNumber>;
  195. }
  196. return (
  197. <BigNumberWidgetVisualization
  198. key={i}
  199. field={field}
  200. value={value}
  201. meta={meta}
  202. thresholds={widget.thresholds ?? undefined}
  203. preferredPolarity="-"
  204. />
  205. );
  206. });
  207. }
  208. chartRef: ReactEchartsRef | null = null;
  209. handleRef = (chartRef: ReactEchartsRef): void => {
  210. if (chartRef && !this.chartRef) {
  211. this.chartRef = chartRef;
  212. // add chart to the group so that it has synced cursors
  213. const instance = chartRef.getEchartsInstance?.();
  214. if (instance && !instance.group && this.props.chartGroup) {
  215. instance.group = this.props.chartGroup;
  216. }
  217. }
  218. if (!chartRef) {
  219. this.chartRef = null;
  220. }
  221. };
  222. chartComponent(chartProps: any): React.ReactNode {
  223. const {widget} = this.props;
  224. const stacked = widget.queries[0]?.columns.length! > 0;
  225. switch (widget.displayType) {
  226. case 'bar':
  227. return <BarChart {...chartProps} stacked={stacked} animation={false} />;
  228. case 'area':
  229. case 'top_n':
  230. return <AreaChart stacked {...chartProps} />;
  231. case 'line':
  232. default:
  233. return <LineChart {...chartProps} />;
  234. }
  235. }
  236. render() {
  237. const {
  238. theme,
  239. tableResults,
  240. timeseriesResults,
  241. errorMessage,
  242. loading,
  243. widget,
  244. onZoom,
  245. legendOptions,
  246. noPadding,
  247. timeseriesResultsTypes,
  248. shouldResize,
  249. confidence,
  250. showConfidenceWarning,
  251. } = this.props;
  252. if (errorMessage) {
  253. return (
  254. <StyledErrorPanel>
  255. <IconWarning color="gray500" size="lg" />
  256. </StyledErrorPanel>
  257. );
  258. }
  259. if (widget.displayType === 'table') {
  260. return getDynamicText({
  261. value: (
  262. <TransitionChart loading={loading} reloading={loading}>
  263. <LoadingScreen loading={loading} />
  264. {this.tableResultComponent({tableResults, loading})}
  265. </TransitionChart>
  266. ),
  267. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  268. });
  269. }
  270. if (widget.displayType === 'big_number') {
  271. return (
  272. <TransitionChart loading={loading} reloading={loading}>
  273. <LoadingScreen loading={loading} />
  274. <BigNumberResizeWrapper>
  275. {this.bigNumberComponent({tableResults, loading})}
  276. </BigNumberResizeWrapper>
  277. </TransitionChart>
  278. );
  279. }
  280. const {location, selection, onLegendSelectChanged, widgetLegendState} = this.props;
  281. const {start, end, period, utc} = selection.datetime;
  282. const {projects, environments} = selection;
  283. const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`);
  284. const shouldColorOther = timeseriesResults?.some(({seriesName}) =>
  285. seriesName?.match(otherRegex)
  286. );
  287. const colors = timeseriesResults
  288. ? (theme.charts
  289. .getColorPalette(timeseriesResults.length - (shouldColorOther ? 3 : 2))
  290. ?.slice() as string[])
  291. : [];
  292. // TODO(wmak): Need to change this when updating dashboards to support variable topEvents
  293. if (shouldColorOther) {
  294. colors[colors.length] = theme.chartOther;
  295. }
  296. // Create a list of series based on the order of the fields,
  297. const series = timeseriesResults
  298. ? timeseriesResults
  299. .map((values, i: number) => {
  300. let seriesName = '';
  301. if (values.seriesName !== undefined) {
  302. seriesName = isEquation(values.seriesName)
  303. ? getEquation(values.seriesName)
  304. : values.seriesName;
  305. }
  306. return {
  307. ...values,
  308. seriesName,
  309. fieldName: seriesName,
  310. color: colors[i],
  311. };
  312. })
  313. .filter(Boolean) // NOTE: `timeseriesResults` is a sparse array! We have to filter out the empty slots after the colors are assigned, since the colors are assigned based on sparse array index
  314. : [];
  315. const legend = {
  316. left: 0,
  317. top: 0,
  318. selected: getSeriesSelection(location),
  319. formatter: (seriesName: string) => {
  320. seriesName =
  321. WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName)!;
  322. const arg = getAggregateArg(seriesName);
  323. if (arg !== null) {
  324. const slug = getMeasurementSlug(arg);
  325. if (slug !== null) {
  326. seriesName = slug.toUpperCase();
  327. }
  328. }
  329. if (maybeEquationAlias(seriesName)) {
  330. seriesName = stripEquationPrefix(seriesName);
  331. }
  332. return seriesName;
  333. },
  334. ...legendOptions,
  335. };
  336. const axisField = widget.queries[0]?.aggregates?.[0] ?? 'count()';
  337. const axisLabel = isEquation(axisField) ? getEquation(axisField) : axisField;
  338. // Check to see if all series output types are the same. If not, then default to number.
  339. const outputType =
  340. timeseriesResultsTypes && new Set(Object.values(timeseriesResultsTypes)).size === 1
  341. ? timeseriesResultsTypes[axisLabel]!
  342. : 'number';
  343. const isDurationChart = outputType === 'duration';
  344. const durationUnit = isDurationChart
  345. ? timeseriesResults && getDurationUnit(timeseriesResults, legendOptions)
  346. : undefined;
  347. const bucketSize = getBucketSize(timeseriesResults);
  348. const valueFormatter = (value: number, seriesName?: string) => {
  349. const decodedSeriesName = seriesName
  350. ? WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName)
  351. : seriesName;
  352. const aggregateName = decodedSeriesName?.split(':').pop()?.trim();
  353. if (aggregateName) {
  354. return timeseriesResultsTypes
  355. ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
  356. : tooltipFormatter(value, aggregateOutputType(aggregateName));
  357. }
  358. return tooltipFormatter(value, 'number');
  359. };
  360. const nameFormatter = (name: string) => {
  361. return WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(name)!;
  362. };
  363. const chartOptions = {
  364. autoHeightResize: shouldResize ?? true,
  365. useMultilineDate: true,
  366. grid: {
  367. left: 0,
  368. right: 4,
  369. top: '40px',
  370. bottom: 0,
  371. },
  372. seriesOptions: {
  373. showSymbol: false,
  374. },
  375. tooltip: {
  376. trigger: 'axis',
  377. axisPointer: {
  378. type: 'cross',
  379. },
  380. formatter: (params: any, asyncTicket: any) => {
  381. const {chartGroup} = this.props;
  382. const isInGroup =
  383. chartGroup && chartGroup === this.chartRef?.getEchartsInstance().group;
  384. // tooltip is triggered whenever any chart in the group is hovered,
  385. // so we need to check if the mouse is actually over this chart
  386. if (isInGroup && !isChartHovered(this.chartRef)) {
  387. return '';
  388. }
  389. return getFormatter({
  390. valueFormatter,
  391. nameFormatter,
  392. isGroupedByDate: true,
  393. bucketSize,
  394. addSecondsToTimeFormat: false,
  395. showTimeInTooltip: true,
  396. })(params, asyncTicket);
  397. },
  398. },
  399. yAxis: {
  400. axisLabel: {
  401. color: theme.chartLabel,
  402. formatter: (value: number) => {
  403. if (timeseriesResultsTypes) {
  404. return axisLabelFormatterUsingAggregateOutputType(
  405. value,
  406. outputType,
  407. true,
  408. durationUnit,
  409. undefined,
  410. PERCENTAGE_DECIMAL_POINTS
  411. );
  412. }
  413. return axisLabelFormatter(
  414. value,
  415. aggregateOutputType(axisLabel),
  416. true,
  417. undefined,
  418. undefined,
  419. PERCENTAGE_DECIMAL_POINTS
  420. );
  421. },
  422. },
  423. axisPointer: {
  424. type: 'line',
  425. snap: false,
  426. lineStyle: {
  427. type: 'solid',
  428. width: 0.5,
  429. },
  430. label: {
  431. show: false,
  432. },
  433. },
  434. minInterval: durationUnit ?? 0,
  435. },
  436. xAxis: {
  437. axisPointer: {
  438. snap: true,
  439. },
  440. },
  441. };
  442. const forwardedRef = this.props.chartGroup ? this.handleRef : undefined;
  443. return (
  444. <ChartZoom period={period} start={start} end={end} utc={utc}>
  445. {zoomRenderProps => {
  446. return (
  447. <ReleaseSeries
  448. end={end}
  449. start={start}
  450. period={period}
  451. environments={environments}
  452. projects={projects}
  453. memoized
  454. enabled={widgetLegendState.widgetRequiresLegendUnselection(widget)}
  455. >
  456. {({releaseSeries}) => {
  457. // make series name into seriesName:widgetId form for individual widget legend control
  458. // NOTE: e-charts legends control all charts that have the same series name so attaching
  459. // widget id will differentiate the charts allowing them to be controlled individually
  460. const modifiedReleaseSeriesResults =
  461. WidgetLegendNameEncoderDecoder.modifyTimeseriesNames(
  462. widget,
  463. releaseSeries
  464. );
  465. return (
  466. <TransitionChart loading={loading} reloading={loading}>
  467. <LoadingScreen loading={loading} />
  468. <ChartWrapper
  469. autoHeightResize={shouldResize ?? true}
  470. noPadding={noPadding}
  471. >
  472. <RenderedChartContainer>
  473. {getDynamicText({
  474. value: this.chartComponent({
  475. ...zoomRenderProps,
  476. ...chartOptions,
  477. // Override default datazoom behaviour for updating Global Selection Header
  478. ...(onZoom ? {onDataZoom: onZoom} : {}),
  479. legend,
  480. series: [...series, ...(modifiedReleaseSeriesResults ?? [])],
  481. onLegendSelectChanged,
  482. forwardedRef,
  483. }),
  484. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  485. })}
  486. </RenderedChartContainer>
  487. {showConfidenceWarning && confidence && (
  488. <ConfidenceWarning
  489. query={widget.queries[0]?.conditions ?? ''}
  490. confidence={confidence}
  491. />
  492. )}
  493. </ChartWrapper>
  494. </TransitionChart>
  495. );
  496. }}
  497. </ReleaseSeries>
  498. );
  499. }}
  500. </ChartZoom>
  501. );
  502. }
  503. }
  504. export default withTheme(WidgetCardChart);
  505. const StyledTransparentLoadingMask = styled((props: any) => (
  506. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  507. ))`
  508. display: flex;
  509. justify-content: center;
  510. align-items: center;
  511. `;
  512. function LoadingScreen({loading}: {loading: boolean}) {
  513. if (!loading) {
  514. return null;
  515. }
  516. return (
  517. <StyledTransparentLoadingMask visible={loading}>
  518. <LoadingIndicator mini />
  519. </StyledTransparentLoadingMask>
  520. );
  521. }
  522. const LoadingPlaceholder = styled(({className}: PlaceholderProps) => (
  523. <Placeholder height="200px" className={className} />
  524. ))`
  525. background-color: ${p => p.theme.surface300};
  526. `;
  527. const BigNumberResizeWrapper = styled('div')`
  528. flex-grow: 1;
  529. overflow: hidden;
  530. position: relative;
  531. `;
  532. const BigNumber = styled('div')`
  533. line-height: 1;
  534. display: inline-flex;
  535. flex: 1;
  536. width: 100%;
  537. min-height: 0;
  538. font-size: 32px;
  539. color: ${p => p.theme.headingColor};
  540. padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
  541. * {
  542. text-align: left !important;
  543. }
  544. `;
  545. const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
  546. ${p => p.autoHeightResize && 'height: 100%;'}
  547. width: 100%;
  548. padding: ${p => (p.noPadding ? `0` : `0 ${space(2)} ${space(2)}`)};
  549. display: flex;
  550. flex-direction: column;
  551. gap: ${space(1)};
  552. `;
  553. const TableWrapper = styled('div')`
  554. margin-top: ${space(1.5)};
  555. min-height: 0;
  556. border-bottom-left-radius: ${p => p.theme.borderRadius};
  557. border-bottom-right-radius: ${p => p.theme.borderRadius};
  558. `;
  559. const StyledSimpleTableChart = styled(SimpleTableChart)`
  560. overflow: auto;
  561. height: 100%;
  562. `;
  563. const StyledErrorPanel = styled(ErrorPanel)`
  564. padding: ${space(2)};
  565. `;
  566. const RenderedChartContainer = styled('div')`
  567. flex: 1;
  568. `;