index.tsx 14 KB

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