usageHistory.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment-timezone';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  7. import LoadingError from 'sentry/components/loadingError';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import Pagination from 'sentry/components/pagination';
  10. import Panel from 'sentry/components/panels/panel';
  11. import PanelBody from 'sentry/components/panels/panelBody';
  12. import PanelHeader from 'sentry/components/panels/panelHeader';
  13. import PanelItem from 'sentry/components/panels/panelItem';
  14. import {IconChevron, IconDownload} from 'sentry/icons';
  15. import {t, tct} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import {DataCategory} from 'sentry/types/core';
  18. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  19. import type {Organization} from 'sentry/types/organization';
  20. import {formatPercentage} from 'sentry/utils/number/formatPercentage';
  21. import {useApiQuery} from 'sentry/utils/queryClient';
  22. import {useLocation} from 'sentry/utils/useLocation';
  23. import withOrganization from 'sentry/utils/withOrganization';
  24. import withSubscription from 'getsentry/components/withSubscription';
  25. import {GIGABYTE, UNLIMITED, UNLIMITED_ONDEMAND} from 'getsentry/constants';
  26. import type {
  27. BillingHistory,
  28. BillingMetricHistory,
  29. Plan,
  30. Subscription,
  31. } from 'getsentry/types';
  32. import {OnDemandBudgetMode, PlanTier} from 'getsentry/types';
  33. import {
  34. formatReservedWithUnits,
  35. formatUsageWithUnits,
  36. getSoftCapType,
  37. } from 'getsentry/utils/billing';
  38. import {getPlanCategoryName, sortCategories} from 'getsentry/utils/dataCategory';
  39. import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils';
  40. import ContactBillingMembers from 'getsentry/views/contactBillingMembers';
  41. import {StripedTable} from './styles';
  42. import SubscriptionHeader from './subscriptionHeader';
  43. import {trackSubscriptionView} from './utils';
  44. type Props = {
  45. organization: Organization;
  46. subscription: Subscription;
  47. } & RouteComponentProps<unknown, unknown>;
  48. function usagePercentage(usage: number, prepaid: number | null): string {
  49. if (prepaid === null || prepaid === 0) {
  50. return t('0%');
  51. }
  52. if (usage > prepaid) {
  53. return '>100%';
  54. }
  55. return formatPercentage(usage / prepaid, 0);
  56. }
  57. type DisplayProps = {
  58. metricHistory: BillingMetricHistory;
  59. hadCustomDynamicSampling?: boolean;
  60. plan?: Plan;
  61. };
  62. function getCategoryDisplay({
  63. plan,
  64. metricHistory,
  65. hadCustomDynamicSampling,
  66. }: DisplayProps): React.ReactNode {
  67. const displayName = getPlanCategoryName({
  68. plan,
  69. category: metricHistory.category,
  70. hadCustomDynamicSampling,
  71. });
  72. const softCapName = getSoftCapType(metricHistory);
  73. return softCapName
  74. ? tct('[displayName] ([softCapName])', {displayName, softCapName})
  75. : displayName;
  76. }
  77. function UsageHistory({organization, subscription}: Props) {
  78. const location = useLocation();
  79. useEffect(() => {
  80. trackSubscriptionView(organization, subscription, 'usage');
  81. }, [organization, subscription]);
  82. const {
  83. data: usageList,
  84. isPending,
  85. isError,
  86. refetch,
  87. getResponseHeader,
  88. } = useApiQuery<BillingHistory[]>(
  89. [
  90. `/customers/${organization.slug}/history/`,
  91. {
  92. query: {cursor: location.query.cursor},
  93. },
  94. ],
  95. {
  96. staleTime: 0,
  97. }
  98. );
  99. if (isPending) {
  100. return (
  101. <Fragment>
  102. <SubscriptionHeader subscription={subscription} organization={organization} />
  103. <LoadingIndicator />
  104. </Fragment>
  105. );
  106. }
  107. if (isError) {
  108. return <LoadingError onRetry={refetch} />;
  109. }
  110. const usageListPageLinks = getResponseHeader?.('Link');
  111. const hasBillingPerms = organization.access?.includes('org:billing');
  112. if (!hasBillingPerms) {
  113. return <ContactBillingMembers />;
  114. }
  115. return (
  116. <Fragment>
  117. <SubscriptionHeader subscription={subscription} organization={organization} />
  118. <Panel>
  119. <PanelHeader>{t('Usage History')}</PanelHeader>
  120. <PanelBody data-test-id="history-table">
  121. {usageList.map(row => (
  122. <UsageHistoryRow key={row.id} history={row} subscription={subscription} />
  123. ))}
  124. </PanelBody>
  125. </Panel>
  126. {usageListPageLinks && <Pagination pageLinks={usageListPageLinks} />}
  127. </Fragment>
  128. );
  129. }
  130. type RowProps = {
  131. history: BillingHistory;
  132. subscription: Subscription;
  133. };
  134. function UsageHistoryRow({history, subscription}: RowProps) {
  135. const [expanded, setExpanded] = useState<boolean>(history.isCurrent);
  136. function renderOnDemandUsage({
  137. sortedCategories,
  138. }: {
  139. sortedCategories: BillingMetricHistory[];
  140. }) {
  141. if (!history.onDemandMaxSpend) {
  142. return null;
  143. }
  144. const ondemandUsageItems: React.ReactNode[] = sortedCategories.map(metricHistory => {
  145. const onDemandBudget =
  146. history.onDemandBudgetMode === OnDemandBudgetMode.SHARED
  147. ? history.onDemandMaxSpend
  148. : metricHistory.onDemandBudget;
  149. return (
  150. <tr key={`ondemand-${metricHistory.category}`}>
  151. <td>{getCategoryDisplay({plan: history.planDetails, metricHistory})}</td>
  152. <td>{displayPriceWithCents({cents: metricHistory.onDemandSpendUsed})}</td>
  153. <td>
  154. {history.onDemandMaxSpend === UNLIMITED_ONDEMAND
  155. ? UNLIMITED
  156. : history.onDemandBudgetMode === OnDemandBudgetMode.SHARED
  157. ? '\u2014'
  158. : displayPriceWithCents({cents: onDemandBudget})}
  159. </td>
  160. <td>
  161. {history.onDemandMaxSpend === UNLIMITED_ONDEMAND || onDemandBudget === 0
  162. ? '0%'
  163. : formatPercentage(metricHistory.onDemandSpendUsed / onDemandBudget, 0)}
  164. </td>
  165. </tr>
  166. );
  167. });
  168. return (
  169. <HistoryTable key="ondemand">
  170. <thead>
  171. <tr>
  172. <th>
  173. {subscription.planTier === PlanTier.AM3
  174. ? t('Pay-as-you-go Spend')
  175. : history.onDemandBudgetMode === OnDemandBudgetMode.PER_CATEGORY
  176. ? t('On-Demand Spend (Per-Category)')
  177. : t('On-Demand Spend (Shared)')}
  178. </th>
  179. <th>{t('Amount Spent')}</th>
  180. <th>{t('Maximum')}</th>
  181. <th>{t('Used (%)')}</th>
  182. </tr>
  183. </thead>
  184. <tbody>
  185. {ondemandUsageItems}
  186. <tr>
  187. <td>{t('Total')}</td>
  188. <td>{displayPriceWithCents({cents: history.onDemandSpend})}</td>
  189. <td>
  190. {history.onDemandMaxSpend === UNLIMITED_ONDEMAND
  191. ? UNLIMITED
  192. : displayPriceWithCents({cents: history.onDemandMaxSpend})}
  193. </td>
  194. <td>
  195. {history.onDemandMaxSpend === UNLIMITED_ONDEMAND
  196. ? '0%'
  197. : formatPercentage(history.onDemandSpend / history.onDemandMaxSpend, 0)}
  198. </td>
  199. </tr>
  200. </tbody>
  201. </HistoryTable>
  202. );
  203. }
  204. const {categories} = history;
  205. // Only display categories with billing metric history
  206. const sortedCategories = sortCategories(categories);
  207. const hasGifts =
  208. Object.values(DataCategory).filter(c => {
  209. return !!categories[c]?.free;
  210. }).length > 0;
  211. return (
  212. <StyledPanelItem>
  213. <HistorySummary>
  214. <div>
  215. {moment(history.periodStart).format('ll')} ›{' '}
  216. {moment(history.periodEnd).format('ll')}
  217. <div>
  218. <small>
  219. {history.planName}
  220. {history.isCurrent && tct(' — [strong:Current]', {strong: <strong />})}
  221. </small>
  222. </div>
  223. </div>
  224. <ButtonBar gap={1}>
  225. <StyledDropdown>
  226. <DropdownMenu
  227. triggerProps={{
  228. size: 'sm',
  229. icon: <IconDownload />,
  230. }}
  231. triggerLabel={t('Reports')}
  232. items={[
  233. {
  234. key: 'summary',
  235. label: t('Summary'),
  236. onAction: () => {
  237. window.open(history.links.csv, '_blank');
  238. },
  239. },
  240. {
  241. key: 'project-breakdown',
  242. label: t('Project Breakdown'),
  243. onAction: () => {
  244. window.open(history.links.csvPerProject, '_blank');
  245. },
  246. },
  247. ]}
  248. position="bottom-end"
  249. />
  250. </StyledDropdown>
  251. <Button
  252. data-test-id="history-expand"
  253. size="sm"
  254. onClick={() => setExpanded(!expanded)}
  255. icon={<IconChevron direction={expanded ? 'up' : 'down'} />}
  256. aria-label={t('Expand history')}
  257. />
  258. </ButtonBar>
  259. </HistorySummary>
  260. {expanded && (
  261. <HistoryDetails>
  262. <HistoryTable key="usage">
  263. <thead>
  264. <tr>
  265. <th>{t('Type')}</th>
  266. <th>{t('Accepted')}</th>
  267. <th>{t('Reserved')}</th>
  268. {hasGifts && <th>{t('Gifted')}</th>}
  269. <th>{t('Used (%)')}</th>
  270. </tr>
  271. </thead>
  272. <tbody>
  273. {sortedCategories
  274. .filter(
  275. metricHistory =>
  276. metricHistory.category !== DataCategory.SPANS_INDEXED ||
  277. (metricHistory.category === DataCategory.SPANS_INDEXED &&
  278. history.hadCustomDynamicSampling)
  279. )
  280. .map(metricHistory => (
  281. <tr key={metricHistory.category}>
  282. <td>
  283. {getCategoryDisplay({
  284. plan: history.planDetails,
  285. metricHistory,
  286. hadCustomDynamicSampling: history.hadCustomDynamicSampling,
  287. })}
  288. </td>
  289. <td>
  290. {formatUsageWithUnits(metricHistory.usage, metricHistory.category, {
  291. useUnitScaling:
  292. metricHistory.category === DataCategory.ATTACHMENTS,
  293. })}
  294. </td>
  295. <td>
  296. {formatReservedWithUnits(
  297. metricHistory.reserved,
  298. metricHistory.category
  299. )}
  300. </td>
  301. {hasGifts && (
  302. <td>
  303. {formatReservedWithUnits(
  304. metricHistory.free,
  305. metricHistory.category,
  306. {isGifted: true}
  307. )}
  308. </td>
  309. )}
  310. <td>
  311. {usagePercentage(
  312. metricHistory.category === DataCategory.ATTACHMENTS
  313. ? metricHistory.usage / GIGABYTE
  314. : metricHistory.usage,
  315. metricHistory.prepaid
  316. )}
  317. </td>
  318. </tr>
  319. ))}
  320. </tbody>
  321. </HistoryTable>
  322. {renderOnDemandUsage({sortedCategories})}
  323. </HistoryDetails>
  324. )}
  325. </StyledPanelItem>
  326. );
  327. }
  328. export default withOrganization(withSubscription(UsageHistory));
  329. const StyledPanelItem = styled(PanelItem)`
  330. flex-direction: column;
  331. `;
  332. const HistorySummary = styled('div')`
  333. display: flex;
  334. justify-content: space-between;
  335. width: 100%;
  336. `;
  337. const HistoryDetails = styled('div')`
  338. padding: ${space(2)} 0;
  339. `;
  340. const HistoryTable = styled(StripedTable)`
  341. table-layout: fixed;
  342. th,
  343. td {
  344. padding: ${space(1)};
  345. text-align: right;
  346. }
  347. th:first-child,
  348. td:first-child {
  349. text-align: left;
  350. }
  351. th:first-child {
  352. padding-left: 0;
  353. }
  354. `;
  355. const StyledDropdown = styled('div')`
  356. display: inline-block;
  357. .dropdown-menu:after,
  358. .dropdown-menu:before {
  359. display: none;
  360. }
  361. `;