123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- import styled from '@emotion/styled';
- import moment from 'moment-timezone';
- import Panel from 'sentry/components/panels/panel';
- import {IconFire, IconStats, IconWarning} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {DataCategory} from 'sentry/types/core';
- import type {Organization} from 'sentry/types/organization';
- import oxfordizeArray from 'sentry/utils/oxfordizeArray';
- import withOrganization from 'sentry/utils/withOrganization';
- import TextBlock from 'sentry/views/settings/components/text/textBlock';
- import AddEventsCTA from 'getsentry/components/addEventsCTA';
- import {GIGABYTE, RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
- import OrgStatsBanner from 'getsentry/hooks/orgStatsBanner';
- import type {CustomerUsage, Subscription} from 'getsentry/types';
- import {
- formatReservedWithUnits,
- formatUsageWithUnits,
- getBestActionToIncreaseEventLimits,
- hasPerformance,
- isBizPlanFamily,
- isUnlimitedReserved,
- UsageAction,
- } from 'getsentry/utils/billing';
- import {getPlanCategoryName, sortCategoriesWithKeys} from 'getsentry/utils/dataCategory';
- import {ButtonWrapper, SubscriptionBody} from './styles';
- type ProjectedOverages = string[];
- type Props = {
- organization: Organization;
- subscription: Subscription;
- usage: CustomerUsage;
- };
- function UsageAlert({organization, subscription, usage}: Props) {
- function getActionSentence() {
- switch (getBestActionToIncreaseEventLimits(organization, subscription)) {
- case UsageAction.START_TRIAL:
- return t('Start a free trial to avoid data loss.');
- case UsageAction.ADD_EVENTS:
- return t('Increase your event limits to avoid data loss.');
- case UsageAction.REQUEST_ADD_EVENTS:
- return t(
- 'Bug your organization owner to increase your event limits to avoid data loss.'
- );
- case UsageAction.REQUEST_UPGRADE:
- return t('Bug your organization owner to upgrade your plan to avoid data loss.');
- case UsageAction.SEND_TO_CHECKOUT:
- default:
- return t('Upgrade your plan to avoid data loss.');
- }
- }
- function formatProjected(projected: number, category: string): string {
- const displayName = getPlanCategoryName({
- plan: subscription.planDetails,
- category,
- capitalize: false,
- hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
- });
- return category === DataCategory.ATTACHMENTS
- ? `${formatUsageWithUnits(projected, category)} of attachments`
- : `${formatReservedWithUnits(projected, category, {
- isAbbreviated: true,
- })} ${displayName}`;
- }
- function projectedCategoryOverages() {
- // hide projected overages for plans with on-demand for now since
- // shared on-demand budget can be applied to any data category
- if (subscription.onDemandMaxSpend) {
- return [];
- }
- return Object.entries(subscription.categories).reduce<ProjectedOverages>(
- (acc, [category, currentHistory]) => {
- if (
- currentHistory.reserved === RESERVED_BUDGET_QUOTA ||
- isUnlimitedReserved(currentHistory.reserved)
- ) {
- return acc;
- }
- const projected = usage.totals[category]?.projected || 0;
- const projectedWithReservedUnit =
- category === DataCategory.ATTACHMENTS ? projected / GIGABYTE : projected;
- const hasOverage =
- !!currentHistory.reserved &&
- projectedWithReservedUnit > (currentHistory.prepaid ?? 0);
- if (hasOverage) {
- acc.push(formatProjected(projected, category));
- }
- return acc;
- },
- []
- );
- }
- function getProjectedOverages(): ProjectedOverages {
- if (subscription.isEnterpriseTrial || subscription.hasOverageNotificationsDisabled) {
- return [];
- }
- return projectedCategoryOverages();
- }
- function renderProjectedInfo(projectedOverages: ProjectedOverages) {
- if (!projectedOverages) {
- return null;
- }
- return (
- <Panel data-test-id="projected-overage-alert">
- <SubscriptionBody withPadding>
- <UsageInfo>
- <IconStats size="md" color="blue300" />
- <div>
- <h3>{t('Projected Overage')}</h3>
- <Description>
- {tct(
- `Based on your previous usage, we predict your organization will need at least [totals].`,
- {totals: oxfordizeArray(projectedOverages)}
- )}{' '}
- {getActionSentence()}
- </Description>
- </div>
- </UsageInfo>
- {renderPrimaryCTA('projected-overage')}
- </SubscriptionBody>
- </Panel>
- );
- }
- function renderGracePeriodInfo() {
- return (
- <Panel data-test-id="grace-period-alert">
- <SubscriptionBody withPadding>
- <UsageInfo>
- <IconWarning size="md" color="yellow300" />
- <div>
- <h3>{t('Grace Period')}</h3>
- <Description>
- {tct(
- `Your organization has depleted its error capacity for the current usage period.
- We've put your account into a one time grace period, which will continue to accept errors at a limited rate.
- This grace period ends on [gracePeriodEnd].`,
- {gracePeriodEnd: moment(subscription.gracePeriodEnd).format('ll')}
- )}{' '}
- {getActionSentence()}
- </Description>
- </div>
- </UsageInfo>
- {renderPrimaryCTA('grace-period')}
- </SubscriptionBody>
- </Panel>
- );
- }
- function renderExceededInfo() {
- const exceededList = sortCategoriesWithKeys(subscription.categories)
- .filter(
- ([category]) =>
- category !== DataCategory.SPANS_INDEXED || subscription.hadCustomDynamicSampling
- )
- .reduce((acc, [category, currentHistory]) => {
- if (currentHistory.usageExceeded) {
- acc.push(
- getPlanCategoryName({
- plan: subscription.planDetails,
- category,
- capitalize: false,
- hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
- })
- );
- }
- return acc;
- }, [] as string[]);
- const quotasExceeded =
- exceededList.length > 0
- ? oxfordizeArray(exceededList)
- : getPlanCategoryName({
- plan: subscription.planDetails,
- category: DataCategory.ERRORS,
- capitalize: false,
- hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
- });
- return (
- <Panel data-test-id="usage-exceeded-alert">
- <SubscriptionBody withPadding>
- <UsageInfo>
- <IconFire size="md" color="red300" />
- <div>
- <h3>{t('Usage Exceeded')}</h3>
- <Description>
- {tct(
- `Your organization has depleted its [quotasExceeded] capacity for the current usage period.`,
- {quotasExceeded}
- )}{' '}
- {getActionSentence()}
- </Description>
- </div>
- </UsageInfo>
- {renderPrimaryCTA('exceded-quota')}
- </SubscriptionBody>
- </Panel>
- );
- }
- function renderDefaultEventCTA() {
- // allow business plan members to request events even if no overages
- // every other user will have another type of CTA
- if (
- getBestActionToIncreaseEventLimits(organization, subscription) ===
- 'request_add_events' &&
- isBizPlanFamily(subscription.planDetails) &&
- hasPerformance(subscription.planDetails)
- ) {
- return (
- <OrgStatsBanner
- organization={organization}
- referrer="subscription-default-event-cta"
- />
- );
- }
- return null;
- }
- function renderPrimaryCTA(alertType: string) {
- if (!subscription.canSelfServe) {
- return null;
- }
- return (
- <ButtonWrapper>
- <AddEventsCTA
- {...{
- organization,
- subscription,
- source: `subscription-usage-alert-${alertType}`,
- referrer: `subscription-usage-alert-${alertType}`,
- buttonProps: {
- size: 'sm',
- },
- }}
- />
- </ButtonWrapper>
- );
- }
- if (!subscription || !usage) {
- return null;
- }
- const hasExceeded =
- Object.values(subscription.categories).some(({usageExceeded}) => usageExceeded) ||
- // TODO: Remove when mmx plans have error BillingMetricHistory
- subscription.usageExceeded;
- const projectedOverages = getProjectedOverages();
- const hasOverage =
- subscription.isGracePeriod || hasExceeded || !!projectedOverages.length;
- // if no overage, we can still have a CTA
- if (!hasOverage) {
- return renderDefaultEventCTA();
- }
- const showProjected = !hasExceeded && !subscription.isGracePeriod;
- return (
- <div data-test-id="usage-alert">
- {hasExceeded && renderExceededInfo()}
- {subscription.isGracePeriod && renderGracePeriodInfo()}
- {showProjected && renderProjectedInfo(projectedOverages)}
- </div>
- );
- }
- export default withOrganization(UsageAlert);
- const UsageInfo = styled('div')`
- display: grid;
- grid-template-columns: max-content auto;
- gap: ${space(1)};
- `;
- const Description = styled(TextBlock)`
- font-size: ${p => p.theme.fontSizeMedium};
- color: ${p => p.theme.subText};
- margin-bottom: 0;
- `;
|