index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import {Component, Fragment} from 'react';
  2. import type {Theme} from '@emotion/react';
  3. import {withTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import Color from 'color';
  6. import type {
  7. BarSeriesOption,
  8. LegendComponentOption,
  9. SeriesOption,
  10. TooltipComponentOption,
  11. } from 'echarts';
  12. import BaseChart from 'sentry/components/charts/baseChart';
  13. import Legend from 'sentry/components/charts/components/legend';
  14. import xAxis from 'sentry/components/charts/components/xAxis';
  15. import barSeries from 'sentry/components/charts/series/barSeries';
  16. import {ChartContainer, HeaderTitleLegend} from 'sentry/components/charts/styles';
  17. import LoadingIndicator from 'sentry/components/loadingIndicator';
  18. import Panel from 'sentry/components/panels/panel';
  19. import Placeholder from 'sentry/components/placeholder';
  20. import {DATA_CATEGORY_INFO} from 'sentry/constants';
  21. import {IconWarning} from 'sentry/icons';
  22. import {t} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import type {DataCategoryInfo, IntervalPeriod, SelectValue} from 'sentry/types';
  25. import {parsePeriodToHours, statsPeriodToDays} from 'sentry/utils/dates';
  26. import getDynamicText from 'sentry/utils/getDynamicText';
  27. import commonTheme from 'sentry/utils/theme';
  28. import {formatUsageWithUnits} from '../utils';
  29. import {getTooltipFormatter, getXAxisDates, getXAxisLabelInterval} from './utils';
  30. const GIGABYTE = 10 ** 9;
  31. type ChartProps = React.ComponentProps<typeof BaseChart>;
  32. const COLOR_ERRORS = Color(commonTheme.dataCategory.errors).lighten(0.25).string();
  33. const COLOR_TRANSACTIONS = Color(commonTheme.dataCategory.transactions)
  34. .lighten(0.35)
  35. .string();
  36. const COLOR_ATTACHMENTS = Color(commonTheme.dataCategory.attachments)
  37. .lighten(0.65)
  38. .string();
  39. const COLOR_DROPPED = commonTheme.red300;
  40. const COLOR_FILTERED = commonTheme.pink100;
  41. export type CategoryOption = {
  42. /**
  43. * Scale of y-axis with no usage data.
  44. */
  45. yAxisMinInterval: number;
  46. } & SelectValue<DataCategoryInfo['plural']>;
  47. export const CHART_OPTIONS_DATACATEGORY: CategoryOption[] = [
  48. {
  49. label: DATA_CATEGORY_INFO.error.titleName,
  50. value: DATA_CATEGORY_INFO.error.plural,
  51. disabled: false,
  52. yAxisMinInterval: 100,
  53. },
  54. {
  55. label: DATA_CATEGORY_INFO.transaction.titleName,
  56. value: DATA_CATEGORY_INFO.transaction.plural,
  57. disabled: false,
  58. yAxisMinInterval: 100,
  59. },
  60. {
  61. label: DATA_CATEGORY_INFO.replay.titleName,
  62. value: DATA_CATEGORY_INFO.replay.plural,
  63. disabled: false,
  64. yAxisMinInterval: 100,
  65. },
  66. {
  67. label: DATA_CATEGORY_INFO.attachment.titleName,
  68. value: DATA_CATEGORY_INFO.attachment.plural,
  69. disabled: false,
  70. yAxisMinInterval: 0.5 * GIGABYTE,
  71. },
  72. {
  73. label: DATA_CATEGORY_INFO.profile.titleName,
  74. value: DATA_CATEGORY_INFO.profile.plural,
  75. disabled: false,
  76. yAxisMinInterval: 100,
  77. },
  78. {
  79. label: DATA_CATEGORY_INFO.monitor.titleName,
  80. value: DATA_CATEGORY_INFO.monitor.plural,
  81. disabled: false,
  82. yAxisMinInterval: 100,
  83. },
  84. {
  85. label: DATA_CATEGORY_INFO.metric_bucket.titleName,
  86. value: DATA_CATEGORY_INFO.metric_bucket.plural,
  87. disabled: false,
  88. yAxisMinInterval: 100,
  89. },
  90. ];
  91. export enum ChartDataTransform {
  92. CUMULATIVE = 'cumulative',
  93. PERIODIC = 'periodic',
  94. }
  95. export const CHART_OPTIONS_DATA_TRANSFORM: SelectValue<ChartDataTransform>[] = [
  96. {
  97. label: t('Cumulative'),
  98. value: ChartDataTransform.CUMULATIVE,
  99. disabled: false,
  100. },
  101. {
  102. label: t('Periodic'),
  103. value: ChartDataTransform.PERIODIC,
  104. disabled: false,
  105. },
  106. ];
  107. export enum SeriesTypes {
  108. ACCEPTED = 'Accepted',
  109. DROPPED = 'Dropped',
  110. PROJECTED = 'Projected',
  111. FILTERED = 'Filtered',
  112. }
  113. type DefaultProps = {
  114. /**
  115. * Config for category dropdown options
  116. */
  117. categoryOptions: CategoryOption[];
  118. /**
  119. * Modify the usageStats using the transformation method selected.
  120. * 1. This must be a pure function!
  121. * 2. If the parent component will handle the data transformation, you should
  122. * replace this prop with "(s) => {return s}"
  123. */
  124. handleDataTransformation: (
  125. stats: ChartStats,
  126. transform: ChartDataTransform
  127. ) => ChartStats;
  128. /**
  129. * Intervals between the x-axis values
  130. */
  131. usageDateInterval: IntervalPeriod;
  132. /**
  133. * Display datetime in UTC
  134. */
  135. usageDateShowUtc: boolean;
  136. };
  137. export type UsageChartProps = DefaultProps & {
  138. dataCategory: DataCategoryInfo['plural'];
  139. dataTransform: ChartDataTransform;
  140. theme: Theme;
  141. usageDateEnd: string;
  142. usageDateStart: string;
  143. /**
  144. * Usage data to draw on chart
  145. */
  146. usageStats: ChartStats;
  147. /**
  148. * Override chart colors for each outcome
  149. */
  150. categoryColors?: string[];
  151. /**
  152. * Additional data to draw on the chart alongside usage
  153. */
  154. chartSeries?: SeriesOption[];
  155. /**
  156. * Replace default tooltip
  157. */
  158. chartTooltip?: TooltipComponentOption;
  159. errors?: Record<string, Error>;
  160. footer?: React.ReactNode;
  161. isError?: boolean;
  162. isLoading?: boolean;
  163. title?: React.ReactNode;
  164. };
  165. type State = {
  166. xAxisDates: string[];
  167. };
  168. export type ChartStats = {
  169. accepted: NonNullable<BarSeriesOption['data']>;
  170. dropped: NonNullable<BarSeriesOption['data']>;
  171. projected: NonNullable<BarSeriesOption['data']>;
  172. filtered?: NonNullable<BarSeriesOption['data']>;
  173. };
  174. export class UsageChart extends Component<UsageChartProps, State> {
  175. static defaultProps: DefaultProps = {
  176. categoryOptions: CHART_OPTIONS_DATACATEGORY,
  177. usageDateShowUtc: true,
  178. usageDateInterval: '1d',
  179. handleDataTransformation: (stats, transform) => {
  180. const chartData: ChartStats = {
  181. accepted: [],
  182. dropped: [],
  183. projected: [],
  184. filtered: [],
  185. };
  186. const isCumulative = transform === ChartDataTransform.CUMULATIVE;
  187. Object.keys(stats).forEach(k => {
  188. let count = 0;
  189. chartData[k] = stats[k].map(stat => {
  190. const [x, y] = stat.value;
  191. count = isCumulative ? count + y : y;
  192. return {
  193. ...stat,
  194. value: [x, count],
  195. };
  196. });
  197. });
  198. return chartData;
  199. },
  200. };
  201. state: State = {
  202. xAxisDates: [],
  203. };
  204. /**
  205. * UsageChart needs to generate the X-Axis dates as props.usageStats may
  206. * not pass the complete range of X-Axis data points
  207. *
  208. * E.g. usageStats.accepted covers day 1-15 of a month, usageStats.projected
  209. * either covers day 16-30 or may not be available at all.
  210. */
  211. static getDerivedStateFromProps(
  212. nextProps: Readonly<UsageChartProps>,
  213. prevState: State
  214. ): State {
  215. const {usageDateStart, usageDateEnd, usageDateShowUtc, usageDateInterval} = nextProps;
  216. return {
  217. ...prevState,
  218. xAxisDates: getXAxisDates(
  219. usageDateStart,
  220. usageDateEnd,
  221. usageDateShowUtc,
  222. usageDateInterval
  223. ),
  224. };
  225. }
  226. get chartColors() {
  227. const {dataCategory, theme} = this.props;
  228. const COLOR_PROJECTED = theme.chartOther;
  229. if (dataCategory === DATA_CATEGORY_INFO.error.plural) {
  230. return [COLOR_ERRORS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
  231. }
  232. if (dataCategory === DATA_CATEGORY_INFO.attachment.plural) {
  233. return [COLOR_ATTACHMENTS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
  234. }
  235. return [COLOR_TRANSACTIONS, COLOR_FILTERED, COLOR_DROPPED, COLOR_PROJECTED];
  236. }
  237. get chartMetadata(): {
  238. chartData: ChartStats;
  239. chartLabel: React.ReactNode;
  240. tooltipValueFormatter: (val?: number) => string;
  241. xAxisData: string[];
  242. xAxisLabelInterval: number;
  243. xAxisTickInterval: number;
  244. yAxisFormatter: (val: number) => string;
  245. yAxisMinInterval: number;
  246. } {
  247. const {categoryOptions, usageDateStart, usageDateEnd} = this.props;
  248. const {
  249. usageDateInterval,
  250. usageStats,
  251. dataCategory,
  252. dataTransform,
  253. handleDataTransformation,
  254. } = this.props;
  255. const {xAxisDates} = this.state;
  256. const selectDataCategory = categoryOptions.find(o => o.value === dataCategory);
  257. if (!selectDataCategory) {
  258. throw new Error('Selected item is not supported');
  259. }
  260. // Do not assume that handleDataTransformation is a pure function
  261. const chartData: ChartStats = {
  262. ...handleDataTransformation(usageStats, dataTransform),
  263. };
  264. Object.keys(chartData).forEach(k => {
  265. const isProjected = k === SeriesTypes.PROJECTED;
  266. // Map the array and destructure elements to avoid side-effects
  267. chartData[k] = chartData[k].map(stat => {
  268. return {
  269. ...stat,
  270. tooltip: {show: false},
  271. itemStyle: {opacity: isProjected ? 0.6 : 1},
  272. };
  273. });
  274. });
  275. // Use hours as common units
  276. const dataPeriod = statsPeriodToDays(undefined, usageDateStart, usageDateEnd) * 24;
  277. const barPeriod = parsePeriodToHours(usageDateInterval);
  278. if (dataPeriod < 0 || barPeriod < 0) {
  279. throw new Error('UsageChart: Unable to parse data time period');
  280. }
  281. const {xAxisTickInterval, xAxisLabelInterval} = getXAxisLabelInterval(
  282. dataPeriod,
  283. dataPeriod / barPeriod
  284. );
  285. const {label, yAxisMinInterval} = selectDataCategory;
  286. return {
  287. chartLabel: label,
  288. chartData,
  289. xAxisData: xAxisDates,
  290. xAxisTickInterval,
  291. xAxisLabelInterval,
  292. yAxisMinInterval,
  293. yAxisFormatter: (val: number) =>
  294. formatUsageWithUnits(val, dataCategory, {
  295. isAbbreviated: true,
  296. useUnitScaling: true,
  297. }),
  298. tooltipValueFormatter: getTooltipFormatter(dataCategory),
  299. };
  300. }
  301. get chartSeries() {
  302. const {chartSeries} = this.props;
  303. const {chartData} = this.chartMetadata;
  304. let series: SeriesOption[] = [
  305. barSeries({
  306. name: SeriesTypes.ACCEPTED,
  307. data: chartData.accepted,
  308. barMinHeight: 1,
  309. stack: 'usage',
  310. legendHoverLink: false,
  311. }),
  312. barSeries({
  313. name: SeriesTypes.FILTERED,
  314. data: chartData.filtered,
  315. barMinHeight: 1,
  316. stack: 'usage',
  317. legendHoverLink: false,
  318. }),
  319. barSeries({
  320. name: SeriesTypes.DROPPED,
  321. data: chartData.dropped,
  322. stack: 'usage',
  323. legendHoverLink: false,
  324. }),
  325. barSeries({
  326. name: SeriesTypes.PROJECTED,
  327. data: chartData.projected,
  328. barMinHeight: 1,
  329. stack: 'usage',
  330. legendHoverLink: false,
  331. }),
  332. ];
  333. // Additional series passed by parent component
  334. if (chartSeries) {
  335. series = series.concat(chartSeries as SeriesOption[]);
  336. }
  337. return series;
  338. }
  339. get chartLegendData() {
  340. const {chartSeries} = this.props;
  341. const {chartData} = this.chartMetadata;
  342. const legend: LegendComponentOption['data'] = [
  343. {
  344. name: SeriesTypes.ACCEPTED,
  345. },
  346. ];
  347. if (chartData.filtered && chartData.filtered.length > 0) {
  348. legend.push({
  349. name: SeriesTypes.FILTERED,
  350. });
  351. }
  352. if (chartData.dropped.length > 0) {
  353. legend.push({
  354. name: SeriesTypes.DROPPED,
  355. });
  356. }
  357. if (chartData.projected.length > 0) {
  358. legend.push({
  359. name: SeriesTypes.PROJECTED,
  360. });
  361. }
  362. if (chartSeries) {
  363. chartSeries.forEach(chartOption => {
  364. legend.push({name: `${chartOption.name}`});
  365. });
  366. }
  367. return legend;
  368. }
  369. get chartTooltip(): ChartProps['tooltip'] {
  370. const {chartTooltip} = this.props;
  371. if (chartTooltip) {
  372. return chartTooltip;
  373. }
  374. const {tooltipValueFormatter} = this.chartMetadata;
  375. return {
  376. // Trigger to axis prevents tooltip from redrawing when hovering
  377. // over individual bars
  378. trigger: 'axis',
  379. valueFormatter: tooltipValueFormatter,
  380. };
  381. }
  382. renderChart() {
  383. const {categoryColors, theme, title, isLoading, isError, errors} = this.props;
  384. if (isLoading) {
  385. return (
  386. <Placeholder height="200px">
  387. <LoadingIndicator mini />
  388. </Placeholder>
  389. );
  390. }
  391. if (isError) {
  392. return (
  393. <Placeholder height="200px">
  394. <IconWarning size="sm" />
  395. <ErrorMessages data-test-id="error-messages">
  396. {errors &&
  397. Object.keys(errors).map(k => <span key={k}>{errors[k]?.message}</span>)}
  398. </ErrorMessages>
  399. </Placeholder>
  400. );
  401. }
  402. const {
  403. xAxisData,
  404. xAxisTickInterval,
  405. xAxisLabelInterval,
  406. yAxisMinInterval,
  407. yAxisFormatter,
  408. } = this.chartMetadata;
  409. const colors = categoryColors?.length ? categoryColors : this.chartColors;
  410. return (
  411. <Fragment>
  412. <HeaderTitleLegend>{title || t('Current Usage Period')}</HeaderTitleLegend>
  413. {getDynamicText({
  414. value: (
  415. <BaseChart
  416. colors={colors}
  417. grid={{bottom: '3px', left: '0px', right: '10px', top: '40px'}}
  418. xAxis={xAxis({
  419. show: true,
  420. type: 'category',
  421. name: 'Date',
  422. data: xAxisData,
  423. axisTick: {
  424. interval: xAxisTickInterval,
  425. alignWithLabel: true,
  426. },
  427. axisLabel: {
  428. interval: xAxisLabelInterval,
  429. formatter: (label: string) => label.slice(0, 6), // Limit label to 6 chars
  430. },
  431. theme,
  432. })}
  433. yAxis={{
  434. min: 0,
  435. minInterval: yAxisMinInterval,
  436. axisLabel: {
  437. formatter: yAxisFormatter,
  438. color: theme.chartLabel,
  439. },
  440. }}
  441. series={this.chartSeries}
  442. tooltip={this.chartTooltip}
  443. onLegendSelectChanged={() => {}}
  444. legend={Legend({
  445. right: 10,
  446. top: 5,
  447. data: this.chartLegendData,
  448. theme,
  449. })}
  450. />
  451. ),
  452. fixed: <Placeholder height="200px" />,
  453. })}
  454. </Fragment>
  455. );
  456. }
  457. render() {
  458. const {footer} = this.props;
  459. return (
  460. <Panel id="usage-chart" data-test-id="usage-chart">
  461. <ChartContainer>{this.renderChart()}</ChartContainer>
  462. {footer}
  463. </Panel>
  464. );
  465. }
  466. }
  467. export default withTheme(UsageChart);
  468. const ErrorMessages = styled('div')`
  469. display: flex;
  470. flex-direction: column;
  471. margin-top: ${space(1)};
  472. font-size: ${p => p.theme.fontSizeSmall};
  473. `;