index.tsx 13 KB

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