usageTotalsTable.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import {Fragment, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import capitalize from 'lodash/capitalize';
  5. import {Button} from 'sentry/components/button';
  6. import QuestionTooltip from 'sentry/components/questionTooltip';
  7. import TextOverflow from 'sentry/components/textOverflow';
  8. import type {Tooltip} from 'sentry/components/tooltip';
  9. import {IconStack} from 'sentry/icons';
  10. import {t, tct} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {BillingStatTotal, Subscription} from 'getsentry/types';
  13. import {formatUsageWithUnits} from 'getsentry/utils/billing';
  14. import {getPlanCategoryName, SINGULAR_DATA_CATEGORY} from 'getsentry/utils/dataCategory';
  15. import titleCase from 'getsentry/utils/titleCase';
  16. import {StripedTable} from 'getsentry/views/subscriptionPage/styles';
  17. import {displayPercentage} from 'getsentry/views/subscriptionPage/usageTotals';
  18. type RowProps = {
  19. category: string;
  20. /**
  21. * Name of outcome reason (e.g. Over Quota, Spike Protection, etc.)
  22. */
  23. name: string;
  24. /**
  25. * Number of events or bytes
  26. */
  27. quantity: number;
  28. totals: BillingStatTotal;
  29. /**
  30. * Button to expand outcome section
  31. */
  32. expandButton?: React.ReactNode;
  33. /**
  34. * If the row should be indented
  35. */
  36. indent?: boolean;
  37. /**
  38. * Adds an info tooltip to `name`
  39. */
  40. tooltipTitle?: React.ComponentProps<typeof Tooltip>['title'];
  41. };
  42. function OutcomeRow({
  43. name,
  44. quantity,
  45. category,
  46. totals,
  47. tooltipTitle,
  48. expandButton,
  49. indent,
  50. }: RowProps) {
  51. const amount = Math.max(quantity, 0);
  52. const totalUsage = totals.accepted + totals.dropped;
  53. return (
  54. <tr>
  55. {tooltipTitle ? (
  56. <td>
  57. <OutcomeType indent={indent}>
  58. <TextWithQuestionTooltip>
  59. {expandButton}
  60. {name}
  61. <QuestionTooltip size="xs" position="top" title={tooltipTitle} />
  62. </TextWithQuestionTooltip>
  63. </OutcomeType>
  64. </td>
  65. ) : (
  66. <td>
  67. <OutcomeType indent={indent}>
  68. {expandButton}
  69. {name}
  70. </OutcomeType>
  71. </td>
  72. )}
  73. <td>
  74. <TextOverflow>
  75. {formatUsageWithUnits(amount, category, {useUnitScaling: true})}
  76. </TextOverflow>
  77. </td>
  78. <td>
  79. <TextOverflow>{displayPercentage(amount, totalUsage)}</TextOverflow>
  80. </td>
  81. </tr>
  82. );
  83. }
  84. type OutcomeSectionProps = {
  85. category: string;
  86. children: React.ReactNode;
  87. name: string;
  88. quantity: number;
  89. totals: BillingStatTotal;
  90. expanded?: boolean;
  91. isEventBreakdown?: boolean;
  92. };
  93. type State = {expanded: boolean};
  94. function OutcomeSection({
  95. name,
  96. quantity,
  97. isEventBreakdown,
  98. category,
  99. totals,
  100. children,
  101. }: OutcomeSectionProps) {
  102. const [state, setState] = useState<State>({expanded: !isEventBreakdown});
  103. const expandButton = (
  104. <StyledButton
  105. data-test-id="expand-dropped-totals"
  106. size="zero"
  107. onClick={() => setState({expanded: !state.expanded})}
  108. icon={<IconStack size="xs" direction={state.expanded ? 'up' : 'down'} />}
  109. aria-label={t('Expand dropped totals')}
  110. />
  111. );
  112. return (
  113. <Fragment>
  114. <OutcomeRow
  115. name={name}
  116. quantity={quantity}
  117. expandButton={expandButton}
  118. category={category}
  119. totals={totals}
  120. />
  121. {state.expanded && children}
  122. </Fragment>
  123. );
  124. }
  125. type Props = {
  126. category: string;
  127. subscription: Subscription;
  128. totals: BillingStatTotal;
  129. isEventBreakdown?: boolean;
  130. };
  131. function UsageTotalsTable({category, isEventBreakdown, totals, subscription}: Props) {
  132. function OutcomeTable({children}: {children: React.ReactNode}) {
  133. const categoryName = isEventBreakdown
  134. ? titleCase(category)
  135. : titleCase(
  136. getPlanCategoryName({
  137. plan: subscription.planDetails,
  138. category,
  139. hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
  140. })
  141. );
  142. return (
  143. <StyledTable>
  144. <thead>
  145. <tr>
  146. <th>
  147. <TextOverflow>
  148. {isEventBreakdown
  149. ? tct('[singularName] Events', {
  150. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  151. singularName: capitalize(SINGULAR_DATA_CATEGORY[category]),
  152. })
  153. : categoryName}
  154. </TextOverflow>
  155. </th>
  156. <th>
  157. <TextOverflow>{t('Quantity')}</TextOverflow>
  158. </th>
  159. <th>
  160. <TextOverflow>{tct('% of [categoryName]', {categoryName})}</TextOverflow>
  161. </th>
  162. </tr>
  163. </thead>
  164. <tbody>{children}</tbody>
  165. </StyledTable>
  166. );
  167. }
  168. return (
  169. <UsageTableWrapper>
  170. <OutcomeTable>
  171. <OutcomeRow
  172. name={t('Accepted')}
  173. quantity={totals.accepted}
  174. category={category}
  175. totals={totals}
  176. />
  177. <OutcomeSection
  178. isEventBreakdown={isEventBreakdown}
  179. name={t('Total Dropped')}
  180. quantity={totals.dropped}
  181. category={category}
  182. totals={totals}
  183. >
  184. <OutcomeRow
  185. indent
  186. name={t('Over Quota')}
  187. quantity={totals.droppedOverQuota}
  188. category={category}
  189. totals={totals}
  190. />
  191. <OutcomeRow
  192. indent
  193. name={t('Spike Protection')}
  194. quantity={totals.droppedSpikeProtection}
  195. category={category}
  196. totals={totals}
  197. />
  198. <OutcomeRow
  199. indent
  200. name={t('Other')}
  201. quantity={totals.droppedOther}
  202. category={category}
  203. totals={totals}
  204. tooltipTitle={t(
  205. 'The dropped other category is for all uncategorized dropped events. This is commonly due to user configured rate limits.'
  206. )}
  207. />
  208. </OutcomeSection>
  209. </OutcomeTable>
  210. </UsageTableWrapper>
  211. );
  212. }
  213. export default UsageTotalsTable;
  214. const StyledButton = styled(Button)`
  215. border-radius: 20px;
  216. padding: ${space(0.25)} ${space(1)};
  217. margin-right: ${space(1)};
  218. `;
  219. const OutcomeType = styled(TextOverflow)<{indent?: boolean}>`
  220. display: grid;
  221. grid-template-columns: max-content min-content;
  222. align-items: center;
  223. ${p =>
  224. p.indent &&
  225. css`
  226. padding-left: 38px;
  227. `};
  228. `;
  229. const TextWithQuestionTooltip = styled('div')`
  230. display: grid;
  231. grid-template-columns: max-content min-content;
  232. align-items: center;
  233. gap: ${space(1)};
  234. `;
  235. const UsageTableWrapper = styled('div')`
  236. display: grid;
  237. grid-auto-flow: row;
  238. gap: ${space(3)};
  239. padding: ${space(1)} 0;
  240. `;
  241. const StyledTable = styled(StripedTable)`
  242. table-layout: fixed;
  243. th,
  244. td {
  245. padding: ${space(1)};
  246. text-align: right;
  247. }
  248. th:first-child,
  249. td:first-child {
  250. text-align: left;
  251. }
  252. th:first-child {
  253. padding-left: 0;
  254. }
  255. `;