index.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {openModal} from 'sentry/actionCreators/modal';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import {CompactSelect} from 'sentry/components/compactSelect';
  7. import Confirm from 'sentry/components/confirm';
  8. import EmptyMessage from 'sentry/components/emptyMessage';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import LoadingError from 'sentry/components/loadingError';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import Pagination from 'sentry/components/pagination';
  13. import Panel from 'sentry/components/panels/panel';
  14. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  15. import {DATA_CATEGORY_INFO} from 'sentry/constants';
  16. import {IconAdd, IconBroadcast} from 'sentry/icons';
  17. import {t, tct} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import {DataCategory, DataCategoryExact} from 'sentry/types/core';
  20. import type {Organization} from 'sentry/types/organization';
  21. import useApi from 'sentry/utils/useApi';
  22. import withOrganization from 'sentry/utils/withOrganization';
  23. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  24. import {OrganizationPermissionAlert} from 'sentry/views/settings/organization/organizationPermissionAlert';
  25. import LearnMoreButton from 'getsentry/components/features/learnMoreButton';
  26. import PlanFeature from 'getsentry/components/features/planFeature';
  27. import withSubscription from 'getsentry/components/withSubscription';
  28. import {
  29. ALLOCATION_SUPPORTED_CATEGORIES,
  30. AllocationTargetTypes,
  31. } from 'getsentry/constants';
  32. import type {Subscription} from 'getsentry/types';
  33. import {displayPlanName, isAmEnterprisePlan} from 'getsentry/utils/billing';
  34. import {SINGULAR_DATA_CATEGORY} from 'getsentry/utils/dataCategory';
  35. import {isDisabledByPartner} from 'getsentry/utils/partnerships';
  36. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  37. import PartnershipNote from 'getsentry/views/subscriptionPage/partnershipNote';
  38. import {hasPermissions} from 'getsentry/views/subscriptionPage/utils';
  39. import AllocationForm from './components/allocationForm';
  40. import type {SpendAllocation} from './components/types';
  41. import EnableSpendAllocations from './enableSpendAllocations';
  42. import ProjectAllocationsTable from './projectAllocationsTable';
  43. import RootAllocationCard from './rootAllocationCard';
  44. import {BigNumUnits} from './utils';
  45. type Props = {
  46. organization: Organization;
  47. subscription: Subscription;
  48. };
  49. /** @internal exported for tests only */
  50. export function SpendAllocationsRoot({organization, subscription}: Props) {
  51. const [errors, setErrors] = useState<string | null>(null);
  52. const [isLoading, setIsLoading] = useState<boolean>(false);
  53. const [orgEnabledFlag, setOrgEnabledFlag] = useState<boolean>(true);
  54. const [selectedMetric, setSelectedMetric] = useState<string>(
  55. DATA_CATEGORY_INFO[DataCategoryExact.ERROR].plural
  56. ); // NOTE: plural lowercase datacategories ex. errors
  57. const [shouldRetry, setShouldRetry] = useState<boolean>(true);
  58. const [rootAllocations, setRootAllocations] = useState<SpendAllocation[]>([]);
  59. const [spendAllocations, setSpendAllocations] = useState<SpendAllocation[]>([]); // NOTE: we default to fetching 1 period
  60. const [viewNextPeriod, _setViewNextPeriod] = useState<boolean>(false);
  61. const [currentCursor, setCurrentCursor] = useState<string | undefined>('');
  62. const [pageLinks, setPageLinks] = useState<string | null>();
  63. const {planDetails} = subscription;
  64. const api = useApi();
  65. const hasBillingPerms = hasPermissions(organization, 'org:billing');
  66. const hasOrgWritePerms = hasPermissions(organization, 'org:write');
  67. const canViewSpendAllocation = hasBillingPerms || hasOrgWritePerms;
  68. const metricUnit = useMemo(() => {
  69. return selectedMetric === DataCategory.ATTACHMENTS
  70. ? BigNumUnits.KILO_BYTES
  71. : BigNumUnits.NUMBERS;
  72. }, [selectedMetric]);
  73. const supportedCategories = ALLOCATION_SUPPORTED_CATEGORIES.filter(category =>
  74. planDetails.categories.includes(DATA_CATEGORY_INFO[category].plural)
  75. );
  76. const period = useMemo<Date[]>(() => {
  77. const {onDemandPeriodStart, onDemandPeriodEnd} = subscription;
  78. let start, end;
  79. if (viewNextPeriod) {
  80. // NOTE: this is hacky and not a proper representation of the actual subscription periods.
  81. // There's currently no better way to get billing periods though, so for now we just
  82. // derive the dates assuming each period is properly 1 month
  83. start = new Date(onDemandPeriodEnd + 'T00:00:00.000');
  84. start.setDate(start.getDate() + 1);
  85. end = new Date(start); // create new date instance
  86. end.setMonth(end.getMonth() + 1);
  87. } else {
  88. start = new Date(onDemandPeriodStart + 'T00:00:00.000');
  89. end = new Date(onDemandPeriodEnd + 'T23:59:59.999');
  90. }
  91. return [start, end];
  92. }, [viewNextPeriod, subscription]);
  93. const currentRootAllocations: SpendAllocation[] = useMemo(() => {
  94. // Return all root allocations that overlap with the selected period
  95. const [periodStart, periodEnd] = period;
  96. return rootAllocations.filter(
  97. allocation =>
  98. allocation &&
  99. ((new Date(allocation.period[0]) < periodEnd! && // allocation starts before period ends
  100. new Date(allocation.period[1]) <= periodEnd!) || // allocation ends before or equal to period end
  101. (new Date(allocation.period[1]) > periodStart! && // allocation ends after the period starts
  102. new Date(allocation.period[0]) >= periodStart!)) // allocation starts after or equal to period start
  103. );
  104. }, [rootAllocations, period]);
  105. const currentAllocations: SpendAllocation[] = useMemo(() => {
  106. // Return all project allocations that overlap with the selected period
  107. const [periodStart, periodEnd] = period;
  108. return spendAllocations.filter(
  109. allocation =>
  110. allocation &&
  111. ((new Date(allocation.period[0]) < periodEnd! && // allocation starts before period ends
  112. new Date(allocation.period[1]) <= periodEnd!) || // allocation ends before or equal to period end
  113. (new Date(allocation.period[1]) > periodStart! && // allocation ends after the period starts
  114. new Date(allocation.period[0]) >= periodStart!)) // allocation starts after or equal to period start
  115. );
  116. }, [spendAllocations, period]);
  117. const rootAllocationForMetric: SpendAllocation | undefined = useMemo(() => {
  118. const root = currentRootAllocations.find(
  119. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  120. a => a.billingMetric === SINGULAR_DATA_CATEGORY[selectedMetric]
  121. );
  122. return root;
  123. }, [currentRootAllocations, selectedMetric]);
  124. const fetchSpendAllocations = useCallback(
  125. // Target timestamp allows us to specify a period
  126. // Periods allows us to specify how many periods we want to fetch
  127. async (targetTimestamp: number | undefined = undefined, periods = 1) => {
  128. try {
  129. setIsLoading(true);
  130. // NOTE: we cannot just use the subscription period start since newly created allocations could start after the period start
  131. // we cannot use the middle of the subscription period since it's possible to have a current allocation that ends before mid period
  132. if (!targetTimestamp) {
  133. targetTimestamp = Math.max(
  134. new Date().getTime() / 1000,
  135. period[0]!.getTime() / 1000
  136. );
  137. }
  138. const SPEND_ALLOCATIONS_PATH = `/organizations/${organization.slug}/spend-allocations/`;
  139. // there should only be one root allocation per billing metric, so we don't need to pass the cursor
  140. const rootAllocationsResp = await api.requestPromise(SPEND_ALLOCATIONS_PATH, {
  141. method: 'GET',
  142. query: {
  143. timestamp: targetTimestamp,
  144. periods,
  145. target_id: organization.id,
  146. target_type: 'Organization',
  147. },
  148. });
  149. setRootAllocations(rootAllocationsResp);
  150. const [projectAllocations, _, resp] = await api.requestPromise(
  151. SPEND_ALLOCATIONS_PATH,
  152. {
  153. method: 'GET',
  154. includeAllArgs: true,
  155. query: {
  156. timestamp: targetTimestamp,
  157. periods,
  158. target_type: 'Project',
  159. cursor: currentCursor,
  160. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  161. billing_metric: SINGULAR_DATA_CATEGORY[selectedMetric],
  162. },
  163. }
  164. );
  165. setOrgEnabledFlag(true);
  166. setSpendAllocations(projectAllocations);
  167. setErrors(null);
  168. const links =
  169. (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ??
  170. undefined;
  171. setPageLinks(links);
  172. } catch (err) {
  173. if (err.status === 404) {
  174. setErrors('Error fetching spend allocations');
  175. } else if (err.status === 403) {
  176. // NOTE: If spend allocations are not enabled, API will return a 403 not found
  177. // So capture this case and set enabled to false
  178. setOrgEnabledFlag(false);
  179. } else {
  180. setErrors(err.statusText);
  181. }
  182. }
  183. setIsLoading(false);
  184. setShouldRetry(true);
  185. },
  186. [api, currentCursor, organization.id, organization.slug, period, selectedMetric]
  187. );
  188. const deleteSpendAllocation =
  189. (billingMetric: string, targetId: number, targetType: string, timestamp: number) =>
  190. async (e: React.MouseEvent) => {
  191. e.preventDefault();
  192. setErrors(null);
  193. try {
  194. const PATH = `/organizations/${organization.slug}/spend-allocations/`;
  195. await api.requestPromise(PATH, {
  196. method: 'DELETE',
  197. query: {
  198. billing_metric: billingMetric,
  199. target_id: targetId,
  200. target_type: targetType,
  201. timestamp,
  202. },
  203. });
  204. await fetchSpendAllocations();
  205. } catch (err) {
  206. setErrors(err.statusText);
  207. }
  208. };
  209. const createRootAllocation = async (e: React.MouseEvent) => {
  210. e.preventDefault();
  211. try {
  212. const PATH = `/organizations/${organization.slug}/spend-allocations/`;
  213. await api.requestPromise(PATH, {
  214. method: 'POST',
  215. data: {
  216. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  217. billing_metric: SINGULAR_DATA_CATEGORY[selectedMetric],
  218. target_id: organization.id,
  219. target_type: AllocationTargetTypes.ORGANIZATION,
  220. desired_quantity: 1,
  221. start_timestamp: period[0]!.getTime() / 1000,
  222. end_timestamp: period[1]!.getTime() / 1000,
  223. },
  224. });
  225. await fetchSpendAllocations();
  226. } catch (err) {
  227. setShouldRetry(false);
  228. setErrors(err.responseJSON.detail);
  229. }
  230. };
  231. const confirmDisableContent = () => {
  232. return (
  233. <div data-test-id="confirm-content">
  234. {t(
  235. 'This action will delete all the current allocations set. Are you sure you want to disable Spend Allocations?'
  236. )}
  237. </div>
  238. );
  239. };
  240. const disableSpendAllocations = async () => {
  241. try {
  242. // Clear all allocations
  243. await api.requestPromise(
  244. `/organizations/${organization.slug}/spend-allocations/index/`,
  245. {
  246. method: 'DELETE',
  247. }
  248. );
  249. } catch (err) {
  250. if (err.status === 409) {
  251. setErrors('Spend Allocations are already disabled');
  252. }
  253. }
  254. await fetchSpendAllocations();
  255. };
  256. useEffect(() => {
  257. fetchSpendAllocations();
  258. }, [fetchSpendAllocations, viewNextPeriod]);
  259. const openForm = (formData?: SpendAllocation) => (e: React.MouseEvent) => {
  260. e.preventDefault();
  261. trackGetsentryAnalytics('spend_allocations.open_form', {
  262. organization,
  263. subscription,
  264. create_or_edit: formData ? 'edit' : 'create',
  265. });
  266. openModal(
  267. modalProps => (
  268. <AllocationForm
  269. {...modalProps}
  270. fetchSpendAllocations={fetchSpendAllocations}
  271. initializedData={formData}
  272. organization={organization}
  273. selectedMetric={selectedMetric}
  274. rootAllocation={rootAllocationForMetric}
  275. spendAllocations={currentAllocations}
  276. subscription={subscription}
  277. />
  278. ),
  279. {
  280. closeEvents: 'escape-key',
  281. }
  282. );
  283. };
  284. if (!organization.features.includes('spend-allocations')) {
  285. return (
  286. <PlanFeature organization={organization} features={['spend-allocations']}>
  287. {({plan}) => (
  288. <Panel dashedBorder data-test-id="disabled-allocations">
  289. <EmptyMessage
  290. size="large"
  291. icon={<IconBroadcast size="xl" />}
  292. title={t(
  293. 'Allocate event resources to important projects every billing period.'
  294. )}
  295. description={tct(
  296. 'Spend Allocations prioritize important projects by guaranteeing a monthly volume of events for exclusive consumption. This ensures coverage for your important projects, even during consumption spikes. This feature [planRequirement] or above.',
  297. {
  298. planRequirement: (
  299. <strong>
  300. {t(
  301. 'requires %s %s Plan',
  302. isAmEnterprisePlan(plan?.id) ? 'an' : 'a',
  303. displayPlanName(plan)
  304. )}
  305. </strong>
  306. ),
  307. }
  308. )}
  309. action={
  310. <ButtonBar>
  311. <StyledLearnMoreButton
  312. organization={organization}
  313. source="allocations-upsell"
  314. href="https://docs.sentry.io/product/accounts/quotas/#spend-allocation"
  315. external
  316. >
  317. {t('Documentation')}
  318. </StyledLearnMoreButton>
  319. </ButtonBar>
  320. }
  321. />
  322. </Panel>
  323. )}
  324. </PlanFeature>
  325. );
  326. }
  327. if (isDisabledByPartner(subscription)) {
  328. return <PartnershipNote subscription={subscription} />;
  329. }
  330. return (
  331. <Fragment>
  332. <SentryDocumentTitle title={t('Spend Allocations')} orgSlug={organization.slug} />
  333. <SettingsPageHeader
  334. title={t('Spend Allocations')}
  335. action={
  336. !isLoading &&
  337. orgEnabledFlag && (
  338. <div>
  339. {subscription.canSelfServe && hasBillingPerms && (
  340. <Button
  341. aria-label={t('Manage Subscription')}
  342. size="sm"
  343. style={{marginRight: space(1)}}
  344. to={`/settings/${organization.slug}/billing/checkout/?referrer=spend_allocations`}
  345. >
  346. {t('Manage Subscription')}
  347. </Button>
  348. )}
  349. <Button
  350. aria-label={t('New Allocation')}
  351. priority="primary"
  352. size="sm"
  353. data-test-id="new-allocation"
  354. icon={<IconAdd size="xs" isCircled />}
  355. onClick={openForm()}
  356. >
  357. {t('New Allocation')}
  358. </Button>
  359. </div>
  360. )
  361. }
  362. />
  363. <div>
  364. {tct(
  365. `Allocate a portion of your subscription's reserved quota to your projects and guarantee a minimum volume for them. Read the [docsLink: docs]`,
  366. {
  367. docsLink: (
  368. <ExternalLink href="https://docs.sentry.io/pricing/quotas/spend-allocation/" />
  369. ),
  370. }
  371. )}
  372. </div>
  373. {!isLoading && !canViewSpendAllocation && (
  374. <StyledPermissionAlert
  375. data-test-id="permission-alert"
  376. message={t(
  377. 'Only users with billing or write permissions can view spend allocation details.'
  378. )}
  379. />
  380. )}
  381. {canViewSpendAllocation && (
  382. <PageGrid data-test-id="subhead-actions">
  383. <StyledButtonBar gap={1}>
  384. <Dates>
  385. <strong>
  386. {!viewNextPeriod && 'Current Period'}
  387. {viewNextPeriod && 'Next Period'}
  388. </strong>
  389. <div>
  390. {new Date(period[0]!).toLocaleDateString('en-US', {
  391. month: 'short',
  392. day: 'numeric',
  393. year: 'numeric',
  394. })}
  395. {' — '}
  396. {new Date(period[1]!).toLocaleDateString('en-US', {
  397. month: 'short',
  398. day: 'numeric',
  399. year: 'numeric',
  400. })}
  401. </div>
  402. </Dates>
  403. </StyledButtonBar>
  404. <DropdownDataCategory
  405. triggerProps={{prefix: t('Category')}}
  406. value={selectedMetric}
  407. options={supportedCategories
  408. .filter(category =>
  409. subscription.planDetails.categories.includes(
  410. DATA_CATEGORY_INFO[category].plural
  411. )
  412. )
  413. .map(category => ({
  414. value: DATA_CATEGORY_INFO[category].plural,
  415. label: DATA_CATEGORY_INFO[category].titleName,
  416. }))}
  417. onChange={opt => {
  418. setSelectedMetric(String(opt?.value));
  419. setCurrentCursor('');
  420. }}
  421. />
  422. </PageGrid>
  423. )}
  424. {isLoading && <LoadingIndicator />}
  425. {errors && (
  426. <LoadingError
  427. onRetry={shouldRetry ? fetchSpendAllocations : undefined}
  428. message={errors}
  429. />
  430. )}
  431. {!isLoading && !orgEnabledFlag && canViewSpendAllocation && (
  432. <EnableSpendAllocations
  433. api={api}
  434. fetchSpendAllocations={fetchSpendAllocations}
  435. hasScope={hasBillingPerms || hasOrgWritePerms}
  436. orgSlug={organization.slug}
  437. setErrors={setErrors}
  438. />
  439. )}
  440. {!isLoading && orgEnabledFlag && canViewSpendAllocation && (
  441. <RootAllocationCard
  442. createRootAllocation={createRootAllocation}
  443. rootAllocation={rootAllocationForMetric}
  444. selectedMetric={selectedMetric}
  445. subscription={subscription}
  446. />
  447. )}
  448. {!isLoading &&
  449. orgEnabledFlag &&
  450. rootAllocationForMetric &&
  451. canViewSpendAllocation && (
  452. <Fragment>
  453. <ProjectAllocationsTable
  454. deleteSpendAllocation={deleteSpendAllocation}
  455. metricUnit={metricUnit}
  456. openForm={openForm}
  457. selectedMetric={selectedMetric}
  458. spendAllocations={currentAllocations}
  459. />
  460. {pageLinks && (
  461. <Pagination pageLinks={pageLinks} onCursor={setCurrentCursor} />
  462. )}
  463. </Fragment>
  464. )}
  465. {!isLoading && orgEnabledFlag && canViewSpendAllocation && (
  466. <Confirm
  467. onConfirm={disableSpendAllocations}
  468. renderMessage={confirmDisableContent}
  469. >
  470. <Button
  471. aria-label={t('Disable Spend Allocations')}
  472. size="sm"
  473. priority="danger"
  474. data-test-id="disable"
  475. disabled={!orgEnabledFlag}
  476. >
  477. {t('Disable Spend Allocations')}
  478. </Button>
  479. </Confirm>
  480. )}
  481. </Fragment>
  482. );
  483. }
  484. export default withOrganization(withSubscription(SpendAllocationsRoot));
  485. const PageGrid = styled('div')`
  486. display: grid;
  487. grid-template-columns: 1fr;
  488. gap: ${space(2)};
  489. margin: ${space(2)} 0;
  490. @media (min-width: 0) {
  491. grid-template-columns: repeat(3, 1fr);
  492. grid-template-areas: 'bb bb dd';
  493. }
  494. @media (min-width: ${p => p.theme.breakpoints.large}) {
  495. grid-template-columns: repeat(5, 1fr);
  496. grid-template-areas: 'bb bb dd . .';
  497. }
  498. `;
  499. const DropdownDataCategory = styled(CompactSelect)`
  500. grid-column: auto / span 1;
  501. grid-area: dd;
  502. button[aria-haspopup='listbox'] {
  503. width: 100%;
  504. height: 100%;
  505. }
  506. `;
  507. const StyledPermissionAlert = styled(OrganizationPermissionAlert)`
  508. margin-top: 30px;
  509. `;
  510. const StyledButtonBar = styled(ButtonBar)`
  511. grid-column: auto / span 1;
  512. grid-area: bb;
  513. `;
  514. const Dates = styled('div')`
  515. display: flex;
  516. flex-direction: column;
  517. align-items: center;
  518. grid-column: 2 / 5;
  519. `;
  520. const StyledLearnMoreButton = styled(LearnMoreButton)`
  521. margin: ${space(0.75)};
  522. `;