index.tsx 13 KB

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