chart.tsx 18 KB

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