index.tsx 13 KB

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