index.tsx 14 KB

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