chart.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. import {Component} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import type {Theme} from '@emotion/react';
  4. import {withTheme} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import type {DataZoomComponentOption, 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 SimpleTableChart from 'sentry/components/charts/simpleTableChart';
  16. import TransitionChart from 'sentry/components/charts/transitionChart';
  17. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  18. import {getSeriesSelection} from 'sentry/components/charts/utils';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import type {PlaceholderProps} from 'sentry/components/placeholder';
  21. import Placeholder from 'sentry/components/placeholder';
  22. import {Tooltip} from 'sentry/components/tooltip';
  23. import {IconWarning} from 'sentry/icons';
  24. import {space} from 'sentry/styles/space';
  25. import type {Organization, PageFilters} from 'sentry/types';
  26. import type {EChartDataZoomHandler, EChartEventHandler} from 'sentry/types/echarts';
  27. import {
  28. axisLabelFormatter,
  29. axisLabelFormatterUsingAggregateOutputType,
  30. getDurationUnit,
  31. tooltipFormatter,
  32. } from 'sentry/utils/discover/charts';
  33. import {getFieldFormatter} from 'sentry/utils/discover/fieldRenderers';
  34. import type {AggregationOutputType} from 'sentry/utils/discover/fields';
  35. import {
  36. aggregateOutputType,
  37. getAggregateArg,
  38. getEquation,
  39. getMeasurementSlug,
  40. isEquation,
  41. maybeEquationAlias,
  42. stripDerivedMetricsPrefix,
  43. stripEquationPrefix,
  44. } from 'sentry/utils/discover/fields';
  45. import getDynamicText from 'sentry/utils/getDynamicText';
  46. import {
  47. formatMetricAxisValue,
  48. renderMetricField,
  49. } from 'sentry/views/dashboards/datasetConfig/metrics';
  50. import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
  51. import {getDatasetConfig} from '../datasetConfig/base';
  52. import type {Widget} from '../types';
  53. import {DisplayType, WidgetType} from '../types';
  54. import type {GenericWidgetQueriesChildrenProps} from './genericWidgetQueries';
  55. const OTHER = 'Other';
  56. export const SLIDER_HEIGHT = 60;
  57. export type AugmentedEChartDataZoomHandler = (
  58. params: Parameters<EChartDataZoomHandler>[0] & {
  59. seriesEnd: string | number;
  60. seriesStart: string | number;
  61. },
  62. instance: Parameters<EChartDataZoomHandler>[1]
  63. ) => void;
  64. type TableResultProps = Pick<
  65. GenericWidgetQueriesChildrenProps,
  66. 'errorMessage' | 'loading' | 'tableResults'
  67. >;
  68. type WidgetCardChartProps = Pick<
  69. GenericWidgetQueriesChildrenProps,
  70. 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'loading'
  71. > & {
  72. location: Location;
  73. organization: Organization;
  74. router: InjectedRouter;
  75. selection: PageFilters;
  76. theme: Theme;
  77. widget: Widget;
  78. chartZoomOptions?: DataZoomComponentOption;
  79. expandNumbers?: boolean;
  80. isMobile?: boolean;
  81. legendOptions?: LegendComponentOption;
  82. noPadding?: boolean;
  83. onLegendSelectChanged?: EChartEventHandler<{
  84. name: string;
  85. selected: Record<string, boolean>;
  86. type: 'legendselectchanged';
  87. }>;
  88. onZoom?: AugmentedEChartDataZoomHandler;
  89. showSlider?: boolean;
  90. timeseriesResultsTypes?: Record<string, AggregationOutputType>;
  91. windowWidth?: number;
  92. };
  93. type State = {
  94. // For tracking height of the container wrapping BigNumber widgets
  95. // so we can dynamically scale font-size
  96. containerHeight: number;
  97. };
  98. class WidgetCardChart extends Component<WidgetCardChartProps, State> {
  99. state = {containerHeight: 0};
  100. shouldComponentUpdate(nextProps: WidgetCardChartProps, nextState: State): boolean {
  101. if (
  102. this.props.widget.displayType === DisplayType.BIG_NUMBER &&
  103. nextProps.widget.displayType === DisplayType.BIG_NUMBER &&
  104. (this.props.windowWidth !== nextProps.windowWidth ||
  105. !isEqual(this.props.widget?.layout, nextProps.widget?.layout))
  106. ) {
  107. return true;
  108. }
  109. // Widget title changes should not update the WidgetCardChart component tree
  110. const currentProps = {
  111. ...omit(this.props, ['windowWidth']),
  112. widget: {
  113. ...this.props.widget,
  114. title: '',
  115. },
  116. };
  117. nextProps = {
  118. ...omit(nextProps, ['windowWidth']),
  119. widget: {
  120. ...nextProps.widget,
  121. title: '',
  122. },
  123. };
  124. return !isEqual(currentProps, nextProps) || !isEqual(this.state, nextState);
  125. }
  126. tableResultComponent({
  127. loading,
  128. errorMessage,
  129. tableResults,
  130. }: TableResultProps): React.ReactNode {
  131. const {location, widget, selection} = this.props;
  132. if (errorMessage) {
  133. return (
  134. <StyledErrorPanel>
  135. <IconWarning color="gray500" size="lg" />
  136. </StyledErrorPanel>
  137. );
  138. }
  139. if (typeof tableResults === 'undefined') {
  140. // Align height to other charts.
  141. return <LoadingPlaceholder />;
  142. }
  143. const datasetConfig = getDatasetConfig(widget.widgetType);
  144. return tableResults.map((result, i) => {
  145. const fields = widget.queries[i]?.fields?.map(stripDerivedMetricsPrefix) ?? [];
  146. const fieldAliases = widget.queries[i]?.fieldAliases ?? [];
  147. const eventView = eventViewFromWidget(widget.title, widget.queries[0], selection);
  148. return (
  149. <StyledSimpleTableChart
  150. key={`table:${result.title}`}
  151. eventView={eventView}
  152. fieldAliases={fieldAliases}
  153. location={location}
  154. fields={fields}
  155. title={tableResults.length > 1 ? result.title : ''}
  156. loading={loading}
  157. loader={<LoadingPlaceholder />}
  158. metadata={result.meta}
  159. data={result.data}
  160. stickyHeaders
  161. fieldHeaderMap={datasetConfig.getFieldHeaderMap?.(widget.queries[i])}
  162. getCustomFieldRenderer={datasetConfig.getCustomFieldRenderer}
  163. />
  164. );
  165. });
  166. }
  167. bigNumberComponent({
  168. loading,
  169. errorMessage,
  170. tableResults,
  171. }: TableResultProps): React.ReactNode {
  172. if (errorMessage) {
  173. return (
  174. <StyledErrorPanel>
  175. <IconWarning color="gray500" size="lg" />
  176. </StyledErrorPanel>
  177. );
  178. }
  179. if (typeof tableResults === 'undefined' || loading) {
  180. return <BigNumber>{'\u2014'}</BigNumber>;
  181. }
  182. const {containerHeight} = this.state;
  183. const {location, organization, widget, isMobile, expandNumbers} = this.props;
  184. return tableResults.map(result => {
  185. const tableMeta = {...result.meta};
  186. const fields = Object.keys(tableMeta);
  187. const field = fields[0];
  188. // Change tableMeta for the field from integer to string since we will be rendering with toLocaleString
  189. const shouldExpandInteger = !!expandNumbers && tableMeta[field] === 'integer';
  190. if (shouldExpandInteger) {
  191. tableMeta[field] = 'string';
  192. }
  193. if (!field || !result.data?.length) {
  194. return <BigNumber key={`big_number:${result.title}`}>{'\u2014'}</BigNumber>;
  195. }
  196. const dataRow = result.data[0];
  197. const fieldRenderer = getFieldFormatter(field, tableMeta, false);
  198. const unit = tableMeta.units?.[field];
  199. const rendered =
  200. widget.widgetType === WidgetType.METRICS
  201. ? renderMetricField(field, dataRow[field])
  202. : fieldRenderer(
  203. shouldExpandInteger ? {[field]: dataRow[field].toLocaleString()} : dataRow,
  204. {location, organization, unit}
  205. );
  206. const isModalWidget = !(widget.id || widget.tempId);
  207. if (isModalWidget || isMobile) {
  208. return <BigNumber key={`big_number:${result.title}`}>{rendered}</BigNumber>;
  209. }
  210. // The font size is the container height, minus the top and bottom padding
  211. const fontSize = !expandNumbers
  212. ? containerHeight - parseInt(space(1), 10) - parseInt(space(3), 10)
  213. : `max(min(8vw, 90px), ${space(4)})`;
  214. return (
  215. <BigNumber
  216. key={`big_number:${result.title}`}
  217. style={{
  218. fontSize,
  219. ...(expandNumbers ? {padding: `${space(1)} ${space(3)} 0 ${space(3)}`} : {}),
  220. }}
  221. >
  222. <Tooltip title={rendered} showOnlyOnOverflow>
  223. {rendered}
  224. </Tooltip>
  225. </BigNumber>
  226. );
  227. });
  228. }
  229. chartComponent(chartProps): React.ReactNode {
  230. const {widget} = this.props;
  231. const stacked = widget.queries[0]?.columns.length > 0;
  232. switch (widget.displayType) {
  233. case 'bar':
  234. return <BarChart {...chartProps} stacked={stacked} />;
  235. case 'area':
  236. case 'top_n':
  237. return <AreaChart stacked {...chartProps} />;
  238. case 'line':
  239. default:
  240. return <LineChart {...chartProps} />;
  241. }
  242. }
  243. render() {
  244. const {
  245. theme,
  246. tableResults,
  247. timeseriesResults,
  248. errorMessage,
  249. loading,
  250. widget,
  251. onZoom,
  252. legendOptions,
  253. expandNumbers,
  254. showSlider,
  255. noPadding,
  256. chartZoomOptions,
  257. timeseriesResultsTypes,
  258. } = this.props;
  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, errorMessage})}
  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. ref={el => {
  276. if (el !== null && !expandNumbers) {
  277. const {height} = el.getBoundingClientRect();
  278. if (height !== this.state.containerHeight) {
  279. this.setState({containerHeight: height});
  280. }
  281. }
  282. }}
  283. >
  284. {this.bigNumberComponent({tableResults, loading, errorMessage})}
  285. </BigNumberResizeWrapper>
  286. </TransitionChart>
  287. );
  288. }
  289. if (errorMessage) {
  290. return (
  291. <StyledErrorPanel>
  292. <IconWarning color="gray500" size="lg" />
  293. </StyledErrorPanel>
  294. );
  295. }
  296. const {location, router, selection, onLegendSelectChanged} = this.props;
  297. const {start, end, period, utc} = selection.datetime;
  298. // Only allow height resizing for widgets that are on a dashboard
  299. const autoHeightResize = Boolean(widget.id || widget.tempId);
  300. const legend = {
  301. left: 0,
  302. top: 0,
  303. selected: getSeriesSelection(location),
  304. formatter: (seriesName: string) => {
  305. const arg = getAggregateArg(seriesName);
  306. if (arg !== null) {
  307. const slug = getMeasurementSlug(arg);
  308. if (slug !== null) {
  309. seriesName = slug.toUpperCase();
  310. }
  311. }
  312. if (maybeEquationAlias(seriesName)) {
  313. seriesName = stripEquationPrefix(seriesName);
  314. }
  315. return seriesName;
  316. },
  317. ...legendOptions,
  318. };
  319. const axisField = widget.queries[0]?.aggregates?.[0] ?? 'count()';
  320. const axisLabel = isEquation(axisField) ? getEquation(axisField) : axisField;
  321. // Check to see if all series output types are the same. If not, then default to number.
  322. const outputType =
  323. timeseriesResultsTypes && new Set(Object.values(timeseriesResultsTypes)).size === 1
  324. ? timeseriesResultsTypes[axisLabel]
  325. : 'number';
  326. const isDurationChart = outputType === 'duration';
  327. const durationUnit = isDurationChart
  328. ? timeseriesResults && getDurationUnit(timeseriesResults, legendOptions)
  329. : undefined;
  330. const chartOptions = {
  331. autoHeightResize,
  332. grid: {
  333. left: 0,
  334. right: 4,
  335. top: '40px',
  336. bottom: showSlider ? SLIDER_HEIGHT : 0,
  337. },
  338. seriesOptions: {
  339. showSymbol: false,
  340. },
  341. tooltip: {
  342. trigger: 'axis',
  343. valueFormatter: (value: number, seriesName: string) => {
  344. if (widget.widgetType === WidgetType.METRICS) {
  345. return formatMetricAxisValue(axisField, value);
  346. }
  347. const aggregateName = seriesName?.split(':').pop()?.trim();
  348. if (aggregateName) {
  349. return timeseriesResultsTypes
  350. ? tooltipFormatter(value, timeseriesResultsTypes[aggregateName])
  351. : tooltipFormatter(value, aggregateOutputType(aggregateName));
  352. }
  353. return tooltipFormatter(value, 'number');
  354. },
  355. },
  356. yAxis: {
  357. axisLabel: {
  358. color: theme.chartLabel,
  359. formatter: (value: number) => {
  360. if (widget.widgetType === WidgetType.METRICS) {
  361. return formatMetricAxisValue(axisField, value);
  362. }
  363. if (timeseriesResultsTypes) {
  364. return axisLabelFormatterUsingAggregateOutputType(
  365. value,
  366. outputType,
  367. undefined,
  368. durationUnit
  369. );
  370. }
  371. return axisLabelFormatter(value, aggregateOutputType(axisLabel));
  372. },
  373. },
  374. minInterval: durationUnit ?? 0,
  375. },
  376. };
  377. return (
  378. <ChartZoom
  379. router={router}
  380. period={period}
  381. start={start}
  382. end={end}
  383. utc={utc}
  384. showSlider={showSlider}
  385. chartZoomOptions={chartZoomOptions}
  386. >
  387. {zoomRenderProps => {
  388. if (errorMessage) {
  389. return (
  390. <StyledErrorPanel>
  391. <IconWarning color="gray500" size="lg" />
  392. </StyledErrorPanel>
  393. );
  394. }
  395. const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`);
  396. const shouldColorOther = timeseriesResults?.some(
  397. ({seriesName}) => seriesName && seriesName.match(otherRegex)
  398. );
  399. const colors = timeseriesResults
  400. ? theme.charts.getColorPalette(
  401. timeseriesResults.length - (shouldColorOther ? 3 : 2)
  402. )
  403. : [];
  404. // TODO(wmak): Need to change this when updating dashboards to support variable topEvents
  405. if (shouldColorOther) {
  406. colors[colors.length] = theme.chartOther;
  407. }
  408. // Create a list of series based on the order of the fields,
  409. const series = timeseriesResults
  410. ? timeseriesResults.map((values, i: number) => {
  411. let seriesName = '';
  412. if (values.seriesName !== undefined) {
  413. seriesName = isEquation(values.seriesName)
  414. ? getEquation(values.seriesName)
  415. : values.seriesName;
  416. }
  417. return {
  418. ...values,
  419. seriesName,
  420. color: colors[i],
  421. };
  422. })
  423. : [];
  424. const seriesStart = series[0]?.data[0]?.name;
  425. const seriesEnd = series[0]?.data[series[0].data.length - 1]?.name;
  426. return (
  427. <TransitionChart loading={loading} reloading={loading}>
  428. <LoadingScreen loading={loading} />
  429. <ChartWrapper autoHeightResize={autoHeightResize} noPadding={noPadding}>
  430. {getDynamicText({
  431. value: this.chartComponent({
  432. ...zoomRenderProps,
  433. ...chartOptions,
  434. // Override default datazoom behaviour for updating Global Selection Header
  435. ...(onZoom
  436. ? {
  437. onDataZoom: (evt, chartProps) =>
  438. // Need to pass seriesStart and seriesEnd to onZoom since slider zooms
  439. // callback with percentage instead of datetime values. Passing seriesStart
  440. // and seriesEnd allows calculating datetime values with percentage.
  441. onZoom({...evt, seriesStart, seriesEnd}, chartProps),
  442. }
  443. : {}),
  444. legend,
  445. series,
  446. onLegendSelectChanged,
  447. }),
  448. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  449. })}
  450. </ChartWrapper>
  451. </TransitionChart>
  452. );
  453. }}
  454. </ChartZoom>
  455. );
  456. }
  457. }
  458. export default withTheme(WidgetCardChart);
  459. const StyledTransparentLoadingMask = styled(props => (
  460. <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
  461. ))`
  462. display: flex;
  463. justify-content: center;
  464. align-items: center;
  465. `;
  466. function LoadingScreen({loading}: {loading: boolean}) {
  467. if (!loading) {
  468. return null;
  469. }
  470. return (
  471. <StyledTransparentLoadingMask visible={loading}>
  472. <LoadingIndicator mini />
  473. </StyledTransparentLoadingMask>
  474. );
  475. }
  476. const LoadingPlaceholder = styled(({className}: PlaceholderProps) => (
  477. <Placeholder height="200px" className={className} />
  478. ))`
  479. background-color: ${p => p.theme.surface300};
  480. `;
  481. const BigNumberResizeWrapper = styled('div')`
  482. height: 100%;
  483. width: 100%;
  484. overflow: hidden;
  485. `;
  486. const BigNumber = styled('div')`
  487. line-height: 1;
  488. display: inline-flex;
  489. flex: 1;
  490. width: 100%;
  491. min-height: 0;
  492. font-size: 32px;
  493. color: ${p => p.theme.headingColor};
  494. padding: ${space(1)} ${space(3)} ${space(3)} ${space(3)};
  495. * {
  496. text-align: left !important;
  497. }
  498. `;
  499. const ChartWrapper = styled('div')<{autoHeightResize: boolean; noPadding?: boolean}>`
  500. ${p => p.autoHeightResize && 'height: 100%;'}
  501. padding: ${p => (p.noPadding ? `0` : `0 ${space(3)} ${space(3)}`)};
  502. `;
  503. const StyledSimpleTableChart = styled(SimpleTableChart)`
  504. margin-top: ${space(1.5)};
  505. border-bottom-left-radius: ${p => p.theme.borderRadius};
  506. border-bottom-right-radius: ${p => p.theme.borderRadius};
  507. font-size: ${p => p.theme.fontSizeMedium};
  508. box-shadow: none;
  509. `;
  510. const StyledErrorPanel = styled(ErrorPanel)`
  511. padding: ${space(2)};
  512. `;