chart.tsx 17 KB

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