reservedUsageChart.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  1. import type {Theme} from '@emotion/react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import type {Location} from 'history';
  5. import moment from 'moment-timezone';
  6. import MarkLine from 'sentry/components/charts/components/markLine';
  7. import {ChartTooltip} from 'sentry/components/charts/components/tooltip';
  8. import OptionSelector from 'sentry/components/charts/optionSelector';
  9. import barSeries from 'sentry/components/charts/series/barSeries';
  10. import lineSeries from 'sentry/components/charts/series/lineSeries';
  11. import {
  12. ChartControls,
  13. InlineContainer,
  14. SectionValue,
  15. } from 'sentry/components/charts/styles';
  16. import {DATA_CATEGORY_INFO} from 'sentry/constants';
  17. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  18. import {IconCalendar} from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import {DataCategory} from 'sentry/types/core';
  21. import type {Organization} from 'sentry/types/organization';
  22. import {defined} from 'sentry/utils';
  23. import {browserHistory} from 'sentry/utils/browserHistory';
  24. import {decodeScalar} from 'sentry/utils/queryString';
  25. import {
  26. type CategoryOption,
  27. CHART_OPTIONS_DATACATEGORY,
  28. type ChartStats,
  29. } from 'sentry/views/organizationStats/usageChart';
  30. import UsageChart, {
  31. CHART_OPTIONS_DATA_TRANSFORM,
  32. ChartDataTransform,
  33. } from 'sentry/views/organizationStats/usageChart';
  34. import {
  35. getDateFromMoment,
  36. getTooltipFormatter,
  37. } from 'sentry/views/organizationStats/usageChart/utils';
  38. import {GIGABYTE} from 'getsentry/constants';
  39. import {
  40. type BillingMetricHistory,
  41. type BillingStat,
  42. type BillingStats,
  43. type CustomerUsage,
  44. type Plan,
  45. PlanTier,
  46. type ReservedBudgetForCategory,
  47. type Subscription,
  48. } from 'getsentry/types';
  49. import {formatReservedWithUnits, isUnlimitedReserved} from 'getsentry/utils/billing';
  50. import {getPlanCategoryName, hasCategoryFeature} from 'getsentry/utils/dataCategory';
  51. import formatCurrency from 'getsentry/utils/formatCurrency';
  52. import titleCase from 'getsentry/utils/titleCase';
  53. import {
  54. calculateCategoryOnDemandUsage,
  55. calculateCategoryPrepaidUsage,
  56. } from 'getsentry/views/subscriptionPage/usageTotals';
  57. const USAGE_CHART_OPTIONS_DATACATEGORY = [
  58. ...CHART_OPTIONS_DATACATEGORY,
  59. {
  60. label: DATA_CATEGORY_INFO.spanIndexed.titleName,
  61. value: DATA_CATEGORY_INFO.spanIndexed.plural,
  62. yAxisMinInterval: 100,
  63. },
  64. ];
  65. /** @internal exported for tests only */
  66. export function getCategoryOptions({
  67. plan,
  68. hadCustomDynamicSampling,
  69. }: {
  70. hadCustomDynamicSampling: boolean;
  71. plan: Plan;
  72. }): CategoryOption[] {
  73. return USAGE_CHART_OPTIONS_DATACATEGORY.filter(
  74. opt =>
  75. plan.categories.includes(opt.value as DataCategory) &&
  76. (opt.value === DataCategory.SPANS_INDEXED ? hadCustomDynamicSampling : true)
  77. );
  78. }
  79. type DroppedBreakdown = {
  80. other: number;
  81. overQuota: number;
  82. spikeProtection: number;
  83. };
  84. interface ReservedUsageChartProps {
  85. displayMode: 'usage' | 'cost';
  86. location: Location;
  87. organization: Organization;
  88. reservedBudgetCategoryInfo: Record<string, ReservedBudgetForCategory>;
  89. subscription: Subscription;
  90. usagePeriodEnd: string;
  91. usagePeriodStart: string;
  92. usageStats: CustomerUsage['stats'];
  93. }
  94. function getCategoryColors(theme: Theme) {
  95. return [
  96. theme.outcome.accepted!,
  97. theme.outcome.filtered!,
  98. theme.outcome.dropped!,
  99. theme.chartOther!, // Projected
  100. ];
  101. }
  102. function selectedCategory(location: Location, categoryOptions: CategoryOption[]) {
  103. const category = decodeScalar(location.query.category) as undefined | DataCategory;
  104. if (!category || !categoryOptions.some(cat => cat.value === category)) {
  105. return DataCategory.ERRORS;
  106. }
  107. return category;
  108. }
  109. function selectedTransform(location: Location) {
  110. const transform = decodeScalar(location.query.transform) as
  111. | undefined
  112. | ChartDataTransform;
  113. if (!transform || !Object.values(ChartDataTransform).includes(transform)) {
  114. return ChartDataTransform.CUMULATIVE;
  115. }
  116. return transform;
  117. }
  118. function chartTooltip(category: DataCategory, displayMode: 'usage' | 'cost') {
  119. const tooltipValueFormatter = getTooltipFormatter(category);
  120. return ChartTooltip({
  121. // Trigger to axis prevents tooltip from redrawing when hovering
  122. // over individual bars
  123. trigger: 'axis',
  124. // Custom tooltip implementation as we show a breakdown for dropped results.
  125. formatter(series) {
  126. const seriesList = Array.isArray(series) ? series : [series];
  127. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  128. const time = seriesList[0]?.value?.[0];
  129. return [
  130. '<div class="tooltip-series">',
  131. seriesList
  132. .map(s => {
  133. const label = s.seriesName ?? '';
  134. const value =
  135. displayMode === 'usage'
  136. ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  137. tooltipValueFormatter(s.value?.[1])
  138. : // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  139. formatCurrency(s.value?.[1] ?? 0);
  140. // @ts-expect-error TS(2339): Property 'dropped' does not exist on type 'OptionD... Remove this comment to see the full error message
  141. const dropped = s.data.dropped as DroppedBreakdown | undefined;
  142. if (typeof dropped === 'undefined' || value === '0') {
  143. return `<div><span class="tooltip-label">${s.marker} <strong>${label}</strong></span> ${value}</div>`;
  144. }
  145. const other = tooltipValueFormatter(dropped.other);
  146. const overQuota = tooltipValueFormatter(dropped.overQuota);
  147. const spikeProtection = tooltipValueFormatter(dropped.spikeProtection);
  148. // Used to shift breakdown over the same amount as series markers.
  149. const indent = '<span style="display: inline-block; width: 15px"></span>';
  150. const labels = [
  151. `<div><span class="tooltip-label">${s.marker} <strong>${t(
  152. 'Dropped'
  153. )}</strong></span> ${value}</div>`,
  154. `<div><span class="tooltip-label">${indent} <strong>${t(
  155. 'Over Quota'
  156. )}</strong></span> ${overQuota}</div>`,
  157. `<div><span class="tooltip-label">${indent} <strong>${t(
  158. 'Spike Protection'
  159. )}</strong></span> ${spikeProtection}</div>`,
  160. `<div><span class="tooltip-label">${indent} <strong>${t(
  161. 'Other'
  162. )}</strong></span> ${other}</div>`,
  163. ];
  164. return labels.join('');
  165. })
  166. .join(''),
  167. '</div>',
  168. `<div class="tooltip-footer tooltip-footer-centered">${time}</div>`,
  169. `<div class="tooltip-arrow"></div>`,
  170. ].join('');
  171. },
  172. });
  173. }
  174. function mapReservedToChart(reserved: number | null, category: string) {
  175. if (isUnlimitedReserved(reserved)) {
  176. return 0;
  177. }
  178. if (category === DataCategory.ATTACHMENTS) {
  179. return typeof reserved === 'number' ? reserved * GIGABYTE : 0;
  180. }
  181. return reserved || 0;
  182. }
  183. function defaultChartData(): ChartStats {
  184. return {
  185. accepted: [],
  186. dropped: [],
  187. projected: [],
  188. reserved: [],
  189. onDemand: [],
  190. };
  191. }
  192. /** @internal exported for tests only */
  193. export function mapStatsToChart({
  194. stats = [],
  195. transform,
  196. }: {
  197. stats: BillingStats;
  198. transform: ChartDataTransform;
  199. }) {
  200. const isCumulative = transform === ChartDataTransform.CUMULATIVE;
  201. let sumAccepted = 0;
  202. let sumDropped = 0;
  203. let sumOther = 0;
  204. let sumOverQuota = 0;
  205. let sumSpikeProtection = 0;
  206. const chartData = defaultChartData();
  207. stats.forEach(stat => {
  208. if (!stat) {
  209. return;
  210. }
  211. const date = getDateFromMoment(moment(stat.date));
  212. const isProjected = stat.isProjected ?? true;
  213. const accepted = stat.accepted ?? 0;
  214. const dropped = stat.dropped.total ?? 0;
  215. sumDropped = isCumulative ? sumDropped + dropped : dropped;
  216. sumAccepted = isCumulative ? sumAccepted + accepted : accepted;
  217. if (stat.dropped.overQuota) {
  218. sumOverQuota = isCumulative
  219. ? sumOverQuota + stat.dropped.overQuota
  220. : stat.dropped.overQuota;
  221. }
  222. if (stat.dropped.spikeProtection) {
  223. sumSpikeProtection = isCumulative
  224. ? sumSpikeProtection + stat.dropped.spikeProtection
  225. : stat.dropped.spikeProtection;
  226. }
  227. sumOther = Math.max(sumDropped - sumOverQuota - sumSpikeProtection, 0);
  228. if (isProjected) {
  229. chartData.projected.push({
  230. value: [date, sumAccepted],
  231. });
  232. } else {
  233. chartData.accepted.push({
  234. value: [date, sumAccepted],
  235. });
  236. // TODO(ts)
  237. (chartData.dropped as any[]).push({
  238. value: [date, sumDropped],
  239. dropped: {
  240. other: sumOther,
  241. overQuota: sumOverQuota,
  242. spikeProtection: sumSpikeProtection,
  243. } as DroppedBreakdown,
  244. });
  245. }
  246. });
  247. return chartData;
  248. }
  249. /** @internal exported for tests only */
  250. export function mapCostStatsToChart({
  251. stats = [],
  252. transform,
  253. subscription,
  254. category,
  255. }: {
  256. category: string;
  257. stats: BillingStats;
  258. subscription: Subscription;
  259. transform: ChartDataTransform;
  260. }) {
  261. const isCumulative = transform === ChartDataTransform.CUMULATIVE;
  262. /**
  263. * On demand is already a running total, so we'll need to subtract when not cumulative.
  264. */
  265. let previousOnDemandCostRunningTotal = 0;
  266. let sumReserved = 0;
  267. const chartData = defaultChartData();
  268. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  269. const metricHistory = subscription.categories[category];
  270. const prepaid = metricHistory.prepaid ?? 0;
  271. stats.forEach(stat => {
  272. if (!stat) {
  273. return;
  274. }
  275. const date = getDateFromMoment(moment(stat.date));
  276. const isProjected = stat.isProjected ?? true;
  277. const accepted = stat.accepted ?? 0;
  278. let onDemand = 0;
  279. if (defined(stat.onDemandCostRunningTotal)) {
  280. onDemand = isCumulative
  281. ? stat.onDemandCostRunningTotal
  282. : stat.onDemandCostRunningTotal - previousOnDemandCostRunningTotal;
  283. previousOnDemandCostRunningTotal = stat.onDemandCostRunningTotal;
  284. }
  285. const {prepaidSpend, prepaidPrice} = calculateCategoryPrepaidUsage(
  286. category,
  287. subscription,
  288. {accepted},
  289. prepaid
  290. );
  291. sumReserved = isCumulative ? sumReserved + prepaidSpend : prepaidSpend;
  292. // Ensure that the reserved amount does not exceed the prepaid amount.
  293. sumReserved = Math.min(sumReserved, prepaidPrice);
  294. if (!isProjected) {
  295. chartData.reserved!.push({
  296. value: [date, sumReserved],
  297. });
  298. chartData.onDemand!.push({
  299. value: [date, onDemand],
  300. });
  301. }
  302. });
  303. return chartData;
  304. }
  305. /** @internal exported for tests only */
  306. export function mapReservedBudgetStatsToChart({
  307. statsByDateAndCategory = {},
  308. transform,
  309. subscription,
  310. reservedBudgetCategoryInfo,
  311. }: {
  312. statsByDateAndCategory: Record<string, Record<string, BillingStats>>;
  313. subscription: Subscription;
  314. transform: ChartDataTransform;
  315. reservedBudgetCategoryInfo?: Record<string, ReservedBudgetForCategory>;
  316. }) {
  317. const isCumulative = transform === ChartDataTransform.CUMULATIVE;
  318. /**
  319. * On demand is already a running total, so we'll need to subtract when not cumulative.
  320. */
  321. let previousOnDemandCostRunningTotal = 0;
  322. let sumReserved = 0;
  323. const chartData = defaultChartData();
  324. if (!reservedBudgetCategoryInfo) {
  325. return chartData;
  326. }
  327. Object.entries(statsByDateAndCategory).forEach(([date, statsByCategory]) => {
  328. let reservedForDate = 0;
  329. let onDemandForDate = 0;
  330. Object.entries(statsByCategory).forEach(([category, stats]) => {
  331. const prepaid = reservedBudgetCategoryInfo[category]?.prepaidBudget ?? 0;
  332. const reservedCpe = reservedBudgetCategoryInfo[category]?.reservedCpe ?? 0;
  333. stats.forEach(stat => {
  334. if (!stat) {
  335. return;
  336. }
  337. const isProjected = stat.isProjected ?? true;
  338. const accepted = stat.accepted ?? 0;
  339. let onDemand = 0;
  340. if (defined(stat.onDemandCostRunningTotal)) {
  341. onDemand = isCumulative
  342. ? stat.onDemandCostRunningTotal
  343. : stat.onDemandCostRunningTotal - previousOnDemandCostRunningTotal;
  344. previousOnDemandCostRunningTotal = stat.onDemandCostRunningTotal;
  345. }
  346. const {prepaidSpend, prepaidPrice} = calculateCategoryPrepaidUsage(
  347. category,
  348. subscription,
  349. {accepted},
  350. prepaid,
  351. reservedCpe
  352. );
  353. sumReserved = isCumulative ? sumReserved + prepaidSpend : prepaidSpend;
  354. sumReserved = Math.min(sumReserved, prepaidPrice);
  355. if (!isProjected) {
  356. // if cumulative, sumReserved is the prepaid amount used so far, otherwise it's the amount used for this date
  357. if (isCumulative) {
  358. reservedForDate = sumReserved;
  359. } else {
  360. reservedForDate += sumReserved;
  361. }
  362. // when cumulative, onDemand is the running total for the category
  363. // otherwise, onDemand is the amount used for the category for this date
  364. // either way we need to add them together to get the on-demand amount across the categories
  365. onDemandForDate += onDemand;
  366. }
  367. });
  368. });
  369. const dateKey = getDateFromMoment(moment(date));
  370. chartData.reserved!.push({
  371. value: [dateKey, reservedForDate],
  372. });
  373. chartData.onDemand!.push({
  374. value: [dateKey, onDemandForDate],
  375. });
  376. });
  377. return chartData;
  378. }
  379. function ReservedUsageChart({
  380. location,
  381. organization,
  382. subscription,
  383. usagePeriodStart,
  384. usagePeriodEnd,
  385. usageStats,
  386. displayMode,
  387. reservedBudgetCategoryInfo,
  388. }: ReservedUsageChartProps) {
  389. const theme = useTheme();
  390. const categoryOptions = getCategoryOptions({
  391. plan: subscription.planDetails,
  392. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  393. });
  394. const category = selectedCategory(location, categoryOptions);
  395. const transform = selectedTransform(location);
  396. const currentHistory: BillingMetricHistory | undefined =
  397. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  398. subscription.categories[category];
  399. const categoryStats = usageStats[category];
  400. const isReservedBudgetCategory =
  401. subscription.reservedBudgetCategories?.includes(category) ?? false;
  402. if (isReservedBudgetCategory) {
  403. displayMode = 'cost';
  404. }
  405. function chartMetadata() {
  406. let dataCategoryMetadata: {
  407. chartData: ChartStats;
  408. isUnlimitedQuota: boolean;
  409. yAxisQuotaLine: number;
  410. yAxisQuotaLineLabel: string;
  411. } = {
  412. isUnlimitedQuota: false,
  413. chartData: {
  414. accepted: [],
  415. dropped: [],
  416. projected: [],
  417. reserved: [],
  418. onDemand: [],
  419. },
  420. yAxisQuotaLine: 0,
  421. yAxisQuotaLineLabel: '',
  422. };
  423. if (categoryStats) {
  424. if (isReservedBudgetCategory) {
  425. if ([DataCategory.SPANS, DataCategory.SPANS_INDEXED].includes(category)) {
  426. if (subscription.hadCustomDynamicSampling) {
  427. const statsByDateAndCategory = categoryStats.reduce(
  428. (acc, stat) => {
  429. if (stat) {
  430. acc[stat.date] = {[category]: [stat]};
  431. }
  432. return acc;
  433. },
  434. {} as Record<string, Record<string, BillingStats>>
  435. );
  436. dataCategoryMetadata.chartData = mapReservedBudgetStatsToChart({
  437. statsByDateAndCategory,
  438. transform,
  439. subscription,
  440. reservedBudgetCategoryInfo,
  441. });
  442. } else {
  443. const otherCategory =
  444. category === DataCategory.SPANS
  445. ? DataCategory.SPANS_INDEXED
  446. : DataCategory.SPANS;
  447. const otherCategoryStats = usageStats[otherCategory] ?? [];
  448. const statsByCategory = {
  449. [category]: categoryStats,
  450. [otherCategory]: otherCategoryStats,
  451. };
  452. const statsByDateAndCategory = Object.entries(statsByCategory).reduce(
  453. (acc, [budgetCategory, stats]) => {
  454. stats.forEach(stat => {
  455. if (stat) {
  456. acc[stat.date] = {...acc[stat.date], [budgetCategory]: [stat]};
  457. }
  458. });
  459. return acc;
  460. },
  461. {} as Record<string, Record<string, BillingStats>>
  462. );
  463. dataCategoryMetadata.chartData = mapReservedBudgetStatsToChart({
  464. statsByDateAndCategory,
  465. transform,
  466. subscription,
  467. reservedBudgetCategoryInfo,
  468. });
  469. }
  470. }
  471. } else if (displayMode === 'cost') {
  472. dataCategoryMetadata.chartData = mapCostStatsToChart({
  473. stats: categoryStats,
  474. transform,
  475. category,
  476. subscription,
  477. });
  478. } else {
  479. dataCategoryMetadata.chartData = mapStatsToChart({
  480. stats: categoryStats,
  481. transform,
  482. });
  483. }
  484. }
  485. if (currentHistory) {
  486. dataCategoryMetadata = {
  487. ...dataCategoryMetadata,
  488. isUnlimitedQuota: isUnlimitedReserved(currentHistory.reserved),
  489. yAxisQuotaLine: mapReservedToChart(currentHistory.reserved, category),
  490. yAxisQuotaLineLabel: formatReservedWithUnits(currentHistory.reserved, category, {
  491. isAbbreviated: true,
  492. }),
  493. };
  494. if (displayMode === 'cost') {
  495. const {prepaidPrice} = calculateCategoryPrepaidUsage(
  496. category,
  497. subscription,
  498. {accepted: 0},
  499. reservedBudgetCategoryInfo[category]?.prepaidBudget ?? currentHistory.prepaid
  500. );
  501. const {onDemandCategoryMax} = calculateCategoryOnDemandUsage(
  502. category,
  503. subscription
  504. );
  505. dataCategoryMetadata.yAxisQuotaLine = prepaidPrice + onDemandCategoryMax;
  506. }
  507. }
  508. return {
  509. isCumulative: transform === ChartDataTransform.CUMULATIVE,
  510. ...dataCategoryMetadata,
  511. };
  512. }
  513. function handleSelectDataCategory(value: ChartDataTransform) {
  514. browserHistory.push({
  515. pathname: location.pathname,
  516. query: {...location.query, transform: value},
  517. });
  518. }
  519. function handleSelectDataTransform(value: DataCategory) {
  520. browserHistory.push({
  521. pathname: location.pathname,
  522. query: {...location.query, category: value},
  523. });
  524. }
  525. /**
  526. * Whether the account has access to the data category
  527. * or tracked usage in the current billing period.
  528. */
  529. function hasOrUsedCategory(dataCategory: string) {
  530. return (
  531. hasCategoryFeature(dataCategory, subscription, organization) ||
  532. usageStats[dataCategory]?.some(
  533. (item: BillingStat) => item.total > 0 && !item.isProjected
  534. )
  535. );
  536. }
  537. function renderFooter() {
  538. const {planDetails} = subscription;
  539. const displayOptions = getCategoryOptions({
  540. plan: planDetails,
  541. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  542. }).reduce((acc, option) => {
  543. if (hasOrUsedCategory(option.value)) {
  544. if (
  545. option.value === DataCategory.SPANS &&
  546. subscription.hadCustomDynamicSampling
  547. ) {
  548. option.label = t('Accepted Spans');
  549. }
  550. acc.push(option);
  551. // Display upsell if the category is available
  552. } else if (planDetails.availableCategories?.includes(option.value)) {
  553. acc.push({
  554. ...option,
  555. tooltip: t(
  556. 'Your plan does not include %s. Migrate to our latest plans to access new features.',
  557. option.value
  558. ),
  559. disabled: true,
  560. });
  561. }
  562. return acc;
  563. }, [] as CategoryOption[]);
  564. return (
  565. <ChartControls>
  566. <InlineContainer>
  567. <SectionValue>
  568. <IconCalendar />
  569. </SectionValue>
  570. <SectionValue>
  571. {moment(usagePeriodStart).format('ll')}
  572. {' — '}
  573. {moment(usagePeriodEnd).format('ll')}
  574. </SectionValue>
  575. </InlineContainer>
  576. <InlineContainer>
  577. <OptionSelector
  578. title={t('Display')}
  579. selected={category}
  580. options={displayOptions}
  581. onChange={(val: string) => handleSelectDataTransform(val as DataCategory)}
  582. />
  583. <OptionSelector
  584. title={t('Type')}
  585. selected={transform}
  586. options={CHART_OPTIONS_DATA_TRANSFORM}
  587. onChange={(val: string) =>
  588. handleSelectDataCategory(val as ChartDataTransform)
  589. }
  590. />
  591. </InlineContainer>
  592. </ChartControls>
  593. );
  594. }
  595. const {isCumulative, isUnlimitedQuota, chartData, yAxisQuotaLine, yAxisQuotaLineLabel} =
  596. chartMetadata();
  597. return (
  598. <UsageChart
  599. footer={renderFooter()}
  600. dataCategory={category}
  601. dataTransform={transform}
  602. handleDataTransformation={s => s}
  603. usageDateStart={usagePeriodStart}
  604. usageDateEnd={usagePeriodEnd}
  605. usageStats={chartData}
  606. usageDateShowUtc={false}
  607. categoryOptions={categoryOptions}
  608. categoryColors={getCategoryColors(theme)}
  609. chartSeries={[
  610. ...(displayMode === 'cost' && chartData.reserved
  611. ? [
  612. barSeries({
  613. // Reserved spend
  614. name: 'Included in Subscription',
  615. data: chartData.reserved,
  616. barMinHeight: 1,
  617. stack: 'usage',
  618. legendHoverLink: false,
  619. color: CHART_PALETTE[5]![0]!,
  620. }),
  621. barSeries({
  622. name:
  623. subscription.planTier === PlanTier.AM3 ? 'Pay-as-you-go' : 'On-Demand',
  624. data: chartData.onDemand,
  625. barMinHeight: 1,
  626. stack: 'usage',
  627. legendHoverLink: false,
  628. color: CHART_PALETTE[5]![1]!,
  629. }),
  630. ]
  631. : []),
  632. lineSeries({
  633. markLine: MarkLine({
  634. silent: true,
  635. lineStyle: {
  636. color: !isCumulative || isUnlimitedQuota ? 'transparent' : theme.gray300,
  637. type: 'dashed',
  638. },
  639. data: [{yAxis: isCumulative ? yAxisQuotaLine : 0}],
  640. precision: 1,
  641. label: {
  642. show: isCumulative ? true : false,
  643. position: 'insideStartBottom',
  644. formatter:
  645. displayMode === 'usage'
  646. ? t(`Plan Quota (%s)`, yAxisQuotaLineLabel)
  647. : t('Max Spend'),
  648. color: theme.chartLabel,
  649. backgroundColor: theme.background,
  650. borderRadius: 2,
  651. padding: 2,
  652. fontSize: 10,
  653. },
  654. }),
  655. }),
  656. ]}
  657. yAxisFormatter={displayMode === 'usage' ? undefined : formatCurrency}
  658. chartTooltip={chartTooltip(category, displayMode)}
  659. title={
  660. <Title>
  661. {displayMode === 'usage'
  662. ? t('Current Usage Period')
  663. : t(
  664. 'Estimated %s Spend This Period',
  665. titleCase(
  666. getPlanCategoryName({
  667. plan: subscription.planDetails,
  668. category,
  669. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  670. })
  671. )
  672. )}
  673. </Title>
  674. }
  675. />
  676. );
  677. }
  678. export default ReservedUsageChart;
  679. const Title = styled('div')`
  680. font-size: ${p => p.theme.fontSizeExtraLarge};
  681. font-weight: normal;
  682. `;