index.tsx 13 KB

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