import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {openModal} from 'sentry/actionCreators/modal'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import {CompactSelect} from 'sentry/components/compactSelect'; import Confirm from 'sentry/components/confirm'; import EmptyMessage from 'sentry/components/emptyMessage'; import ExternalLink from 'sentry/components/links/externalLink'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import Pagination from 'sentry/components/pagination'; import Panel from 'sentry/components/panels/panel'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {DATA_CATEGORY_INFO} from 'sentry/constants'; import {IconAdd, IconBroadcast} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {DataCategory, DataCategoryExact} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import useApi from 'sentry/utils/useApi'; import withOrganization from 'sentry/utils/withOrganization'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import {OrganizationPermissionAlert} from 'sentry/views/settings/organization/organizationPermissionAlert'; import LearnMoreButton from 'getsentry/components/features/learnMoreButton'; import PlanFeature from 'getsentry/components/features/planFeature'; import withSubscription from 'getsentry/components/withSubscription'; import { ALLOCATION_SUPPORTED_CATEGORIES, AllocationTargetTypes, } from 'getsentry/constants'; import type {Subscription} from 'getsentry/types'; import {displayPlanName, isAmEnterprisePlan} from 'getsentry/utils/billing'; import {SINGULAR_DATA_CATEGORY} from 'getsentry/utils/dataCategory'; import {isDisabledByPartner} from 'getsentry/utils/partnerships'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import PartnershipNote from 'getsentry/views/subscriptionPage/partnershipNote'; import {hasPermissions} from 'getsentry/views/subscriptionPage/utils'; import AllocationForm from './components/allocationForm'; import type {SpendAllocation} from './components/types'; import EnableSpendAllocations from './enableSpendAllocations'; import ProjectAllocationsTable from './projectAllocationsTable'; import RootAllocationCard from './rootAllocationCard'; import {BigNumUnits} from './utils'; type Props = { organization: Organization; subscription: Subscription; }; /** @internal exported for tests only */ export function SpendAllocationsRoot({organization, subscription}: Props) { const [errors, setErrors] = useState(null); const [isLoading, setIsLoading] = useState(false); const [orgEnabledFlag, setOrgEnabledFlag] = useState(true); const [selectedMetric, setSelectedMetric] = useState( DATA_CATEGORY_INFO[DataCategoryExact.ERROR].plural ); // NOTE: plural lowercase datacategories ex. errors const [shouldRetry, setShouldRetry] = useState(true); const [rootAllocations, setRootAllocations] = useState([]); const [spendAllocations, setSpendAllocations] = useState([]); // NOTE: we default to fetching 1 period const [viewNextPeriod, _setViewNextPeriod] = useState(false); const [currentCursor, setCurrentCursor] = useState(''); const [pageLinks, setPageLinks] = useState(); const {planDetails} = subscription; const api = useApi(); const hasBillingPerms = hasPermissions(organization, 'org:billing'); const hasOrgWritePerms = hasPermissions(organization, 'org:write'); const canViewSpendAllocation = hasBillingPerms || hasOrgWritePerms; const metricUnit = useMemo(() => { return selectedMetric === DataCategory.ATTACHMENTS ? BigNumUnits.KILO_BYTES : BigNumUnits.NUMBERS; }, [selectedMetric]); const supportedCategories = ALLOCATION_SUPPORTED_CATEGORIES.filter(category => planDetails.categories.includes(DATA_CATEGORY_INFO[category].plural) ); const period = useMemo(() => { const {onDemandPeriodStart, onDemandPeriodEnd} = subscription; let start, end; if (viewNextPeriod) { // NOTE: this is hacky and not a proper representation of the actual subscription periods. // There's currently no better way to get billing periods though, so for now we just // derive the dates assuming each period is properly 1 month start = new Date(onDemandPeriodEnd + 'T00:00:00.000'); start.setDate(start.getDate() + 1); end = new Date(start); // create new date instance end.setMonth(end.getMonth() + 1); } else { start = new Date(onDemandPeriodStart + 'T00:00:00.000'); end = new Date(onDemandPeriodEnd + 'T23:59:59.999'); } return [start, end]; }, [viewNextPeriod, subscription]); const currentRootAllocations: SpendAllocation[] = useMemo(() => { // Return all root allocations that overlap with the selected period const [periodStart, periodEnd] = period; return rootAllocations.filter( allocation => allocation && ((new Date(allocation.period[0]) < periodEnd! && // allocation starts before period ends new Date(allocation.period[1]) <= periodEnd!) || // allocation ends before or equal to period end (new Date(allocation.period[1]) > periodStart! && // allocation ends after the period starts new Date(allocation.period[0]) >= periodStart!)) // allocation starts after or equal to period start ); }, [rootAllocations, period]); const currentAllocations: SpendAllocation[] = useMemo(() => { // Return all project allocations that overlap with the selected period const [periodStart, periodEnd] = period; return spendAllocations.filter( allocation => allocation && ((new Date(allocation.period[0]) < periodEnd! && // allocation starts before period ends new Date(allocation.period[1]) <= periodEnd!) || // allocation ends before or equal to period end (new Date(allocation.period[1]) > periodStart! && // allocation ends after the period starts new Date(allocation.period[0]) >= periodStart!)) // allocation starts after or equal to period start ); }, [spendAllocations, period]); const rootAllocationForMetric: SpendAllocation | undefined = useMemo(() => { const root = currentRootAllocations.find( // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message a => a.billingMetric === SINGULAR_DATA_CATEGORY[selectedMetric] ); return root; }, [currentRootAllocations, selectedMetric]); const fetchSpendAllocations = useCallback( // Target timestamp allows us to specify a period // Periods allows us to specify how many periods we want to fetch async (targetTimestamp: number | undefined = undefined, periods = 1) => { try { setIsLoading(true); // NOTE: we cannot just use the subscription period start since newly created allocations could start after the period start // we cannot use the middle of the subscription period since it's possible to have a current allocation that ends before mid period if (!targetTimestamp) { targetTimestamp = Math.max( new Date().getTime() / 1000, period[0]!.getTime() / 1000 ); } const SPEND_ALLOCATIONS_PATH = `/organizations/${organization.slug}/spend-allocations/`; // there should only be one root allocation per billing metric, so we don't need to pass the cursor const rootAllocationsResp = await api.requestPromise(SPEND_ALLOCATIONS_PATH, { method: 'GET', query: { timestamp: targetTimestamp, periods, target_id: organization.id, target_type: 'Organization', }, }); setRootAllocations(rootAllocationsResp); const [projectAllocations, _, resp] = await api.requestPromise( SPEND_ALLOCATIONS_PATH, { method: 'GET', includeAllArgs: true, query: { timestamp: targetTimestamp, periods, target_type: 'Project', cursor: currentCursor, // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message billing_metric: SINGULAR_DATA_CATEGORY[selectedMetric], }, } ); setOrgEnabledFlag(true); setSpendAllocations(projectAllocations); setErrors(null); const links = (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined; setPageLinks(links); } catch (err) { if (err.status === 404) { setErrors('Error fetching spend allocations'); } else if (err.status === 403) { // NOTE: If spend allocations are not enabled, API will return a 403 not found // So capture this case and set enabled to false setOrgEnabledFlag(false); } else { setErrors(err.statusText); } } setIsLoading(false); setShouldRetry(true); }, [api, currentCursor, organization.id, organization.slug, period, selectedMetric] ); const deleteSpendAllocation = (billingMetric: string, targetId: number, targetType: string, timestamp: number) => async (e: React.MouseEvent) => { e.preventDefault(); setErrors(null); try { const PATH = `/organizations/${organization.slug}/spend-allocations/`; await api.requestPromise(PATH, { method: 'DELETE', query: { billing_metric: billingMetric, target_id: targetId, target_type: targetType, timestamp, }, }); await fetchSpendAllocations(); } catch (err) { setErrors(err.statusText); } }; const createRootAllocation = async (e: React.MouseEvent) => { e.preventDefault(); try { const PATH = `/organizations/${organization.slug}/spend-allocations/`; await api.requestPromise(PATH, { method: 'POST', data: { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message billing_metric: SINGULAR_DATA_CATEGORY[selectedMetric], target_id: organization.id, target_type: AllocationTargetTypes.ORGANIZATION, desired_quantity: 1, start_timestamp: period[0]!.getTime() / 1000, end_timestamp: period[1]!.getTime() / 1000, }, }); await fetchSpendAllocations(); } catch (err) { setShouldRetry(false); setErrors(err.responseJSON.detail); } }; const confirmDisableContent = () => { return (
{t( 'This action will delete all the current allocations set. Are you sure you want to disable Spend Allocations?' )}
); }; const disableSpendAllocations = async () => { try { // Clear all allocations await api.requestPromise( `/organizations/${organization.slug}/spend-allocations/index/`, { method: 'DELETE', } ); } catch (err) { if (err.status === 409) { setErrors('Spend Allocations are already disabled'); } } await fetchSpendAllocations(); }; useEffect(() => { fetchSpendAllocations(); }, [fetchSpendAllocations, viewNextPeriod]); const openForm = (formData?: SpendAllocation) => (e: React.MouseEvent) => { e.preventDefault(); trackGetsentryAnalytics('spend_allocations.open_form', { organization, subscription, create_or_edit: formData ? 'edit' : 'create', }); openModal( modalProps => ( ), { closeEvents: 'escape-key', } ); }; if (!organization.features.includes('spend-allocations')) { return ( {({plan}) => ( } title={t( 'Allocate event resources to important projects every billing period.' )} description={tct( '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.', { planRequirement: ( {t( 'requires %s %s Plan', isAmEnterprisePlan(plan?.id) ? 'an' : 'a', displayPlanName(plan) )} ), } )} action={ {t('Documentation')} } /> )} ); } if (isDisabledByPartner(subscription)) { return ; } return ( {subscription.canSelfServe && hasBillingPerms && ( )} ) } />
{tct( `Allocate a portion of your subscription's reserved quota to your projects and guarantee a minimum volume for them. Read the [docsLink: docs]`, { docsLink: ( ), } )}
{!isLoading && !canViewSpendAllocation && ( )} {canViewSpendAllocation && ( {!viewNextPeriod && 'Current Period'} {viewNextPeriod && 'Next Period'}
{new Date(period[0]!).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} {' — '} {new Date(period[1]!).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}
subscription.planDetails.categories.includes( DATA_CATEGORY_INFO[category].plural ) ) .map(category => ({ value: DATA_CATEGORY_INFO[category].plural, label: DATA_CATEGORY_INFO[category].titleName, }))} onChange={opt => { setSelectedMetric(String(opt?.value)); setCurrentCursor(''); }} />
)} {isLoading && } {errors && ( )} {!isLoading && !orgEnabledFlag && canViewSpendAllocation && ( )} {!isLoading && orgEnabledFlag && canViewSpendAllocation && ( )} {!isLoading && orgEnabledFlag && rootAllocationForMetric && canViewSpendAllocation && ( {pageLinks && ( )} )} {!isLoading && orgEnabledFlag && canViewSpendAllocation && ( )}
); } export default withOrganization(withSubscription(SpendAllocationsRoot)); const PageGrid = styled('div')` display: grid; grid-template-columns: 1fr; gap: ${space(2)}; margin: ${space(2)} 0; @media (min-width: 0) { grid-template-columns: repeat(3, 1fr); grid-template-areas: 'bb bb dd'; } @media (min-width: ${p => p.theme.breakpoints.large}) { grid-template-columns: repeat(5, 1fr); grid-template-areas: 'bb bb dd . .'; } `; const DropdownDataCategory = styled(CompactSelect)` grid-column: auto / span 1; grid-area: dd; button[aria-haspopup='listbox'] { width: 100%; height: 100%; } `; const StyledPermissionAlert = styled(OrganizationPermissionAlert)` margin-top: 30px; `; const StyledButtonBar = styled(ButtonBar)` grid-column: auto / span 1; grid-area: bb; `; const Dates = styled('div')` display: flex; flex-direction: column; align-items: center; grid-column: 2 / 5; `; const StyledLearnMoreButton = styled(LearnMoreButton)` margin: ${space(0.75)}; `;