index.tsx 12 KB

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