index.tsx 14 KB

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