diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index 04287987748ed4..caa88f97f88318 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -4,7 +4,7 @@ import {t} from 'sentry/locale'; import type {DataCategoryInfo, Scope} from 'sentry/types/core'; import {DataCategory, DataCategoryExact} from 'sentry/types/core'; import type {PermissionResource} from 'sentry/types/integrations'; -import type {OrgRole} from 'sentry/types/organization'; +import type {Organization, OrgRole} from 'sentry/types/organization'; /** * Common constants here @@ -611,8 +611,8 @@ export const DATA_CATEGORY_INFO = { name: DataCategoryExact.SEER_USER, plural: DataCategory.SEER_USER, singular: 'seerUser', - displayName: 'seer user', - titleName: t('Seer'), + displayName: 'active contributor', + titleName: t('Active Contributors'), productName: t('Seer'), uid: 34, isBilledCategory: true, @@ -620,6 +620,8 @@ export const DATA_CATEGORY_INFO = { ...DEFAULT_STATS_INFO, showExternalStats: false, // TODO(seer): add external stats when ready }, + getProductLink: (organization: Organization) => + `/settings/${organization.slug}/seer/`, }, } as const satisfies Record; diff --git a/static/app/types/core.tsx b/static/app/types/core.tsx index 999c4ebc56fd68..d13264e84534b7 100644 --- a/static/app/types/core.tsx +++ b/static/app/types/core.tsx @@ -7,6 +7,7 @@ import type {getInterval} from 'sentry/components/charts/utils'; import type {MenuListItemProps} from 'sentry/components/core/menuListItem'; import type {ALLOWED_SCOPES} from 'sentry/constants'; +import type {Organization} from 'sentry/types/organization'; /** * Visual representation of a project/team/organization/user @@ -149,6 +150,7 @@ export interface DataCategoryInfo { titleName: string; uid: number; docsUrl?: string; + getProductLink?: (organization: Organization) => string; } export enum Outcome { diff --git a/static/gsAdmin/components/changePlanAction.spec.tsx b/static/gsAdmin/components/changePlanAction.spec.tsx index 4a09faea09deb7..2783b106c06466 100644 --- a/static/gsAdmin/components/changePlanAction.spec.tsx +++ b/static/gsAdmin/components/changePlanAction.spec.tsx @@ -229,7 +229,7 @@ describe('ChangePlanAction', () => { ); await selectEvent.select(screen.getByRole('textbox', {name: 'Logs (GB)'}), '5'); - expect(screen.queryByText('Available Products')).not.toBeInTheDocument(); + expect(screen.getByText('Available Products')).toBeInTheDocument(); // will always show if any product is launched and available for an org expect(screen.getByRole('button', {name: 'Change Plan'})).toBeEnabled(); await userEvent.click(screen.getByRole('button', {name: 'Change Plan'})); @@ -241,7 +241,6 @@ describe('ChangePlanAction', () => { }); it('completes form with addOns', async () => { - mockOrg.features = ['seer-billing', 'seer-user-billing']; // this won't happen IRL, but doing this for testing multiple addons const putMock = MockApiClient.addMockResponse({ url: `/customers/${mockOrg.slug}/subscription/`, method: 'PUT', @@ -267,11 +266,14 @@ describe('ChangePlanAction', () => { ); await selectEvent.select(screen.getByRole('textbox', {name: 'Logs (GB)'}), '5'); + // XXX: irl we would not have both versions of Seer available, but doing this for testing multiple addons expect(screen.getByText('Available Products')).toBeInTheDocument(); - const seerSelections = screen.getAllByText('Seer'); - expect(seerSelections).toHaveLength(2); - await userEvent.click(seerSelections[0]!); - await userEvent.click(seerSelections[1]!); + const seerSelection = screen.getByText('Seer'); + const legacySeerSelection = screen.getByText('Seer (Legacy)'); + expect(seerSelection).toBeInTheDocument(); + expect(legacySeerSelection).toBeInTheDocument(); + await userEvent.click(seerSelection); + await userEvent.click(legacySeerSelection); expect(screen.getByRole('button', {name: 'Change Plan'})).toBeEnabled(); await userEvent.click(screen.getByRole('button', {name: 'Change Plan'})); @@ -473,7 +475,7 @@ describe('ChangePlanAction', () => { // Verify Seer budget checkbox is checked when subscription has Seer budget const seerCheckbox = screen.getByRole('checkbox', { - name: 'Seer', + name: 'Seer (Legacy)', }); expect(seerCheckbox).toBeChecked(); }); @@ -516,7 +518,7 @@ describe('ChangePlanAction', () => { // Check the Seer budget checkbox const seerCheckbox = screen.getByRole('checkbox', { - name: 'Seer', + name: 'Seer (Legacy)', }); await userEvent.click(seerCheckbox); @@ -568,7 +570,7 @@ describe('ChangePlanAction', () => { // Verify Seer budget checkbox is unchecked (default state) const seerCheckbox = screen.getByRole('checkbox', { - name: 'Seer', + name: 'Seer (Legacy)', }); expect(seerCheckbox).not.toBeChecked(); diff --git a/static/gsAdmin/components/changePlanAction.tsx b/static/gsAdmin/components/changePlanAction.tsx index fe19630c459eb0..a7185cafe1ed1a 100644 --- a/static/gsAdmin/components/changePlanAction.tsx +++ b/static/gsAdmin/components/changePlanAction.tsx @@ -325,7 +325,6 @@ function ChangePlanAction({ formModel={formModel} activePlan={activePlan} subscription={subscription} - organization={organization} onSubmit={handleSubmit} onCancel={closeModal} onSubmitSuccess={(data: Data) => { diff --git a/static/gsAdmin/components/planList.tsx b/static/gsAdmin/components/planList.tsx index 3bdf4a79a59b3a..4010a657833916 100644 --- a/static/gsAdmin/components/planList.tsx +++ b/static/gsAdmin/components/planList.tsx @@ -11,11 +11,15 @@ import type FormModel from 'sentry/components/forms/model'; import type {Data, OnSubmitCallback} from 'sentry/components/forms/types'; import {space} from 'sentry/styles/space'; import type {DataCategory} from 'sentry/types/core'; -import type {Organization} from 'sentry/types/organization'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import {ANNUAL} from 'getsentry/constants'; -import type {BillingConfig, Plan, Subscription} from 'getsentry/types'; +import { + AddOnCategory, + type BillingConfig, + type Plan, + type Subscription, +} from 'getsentry/types'; import {getPlanCategoryName, isByteCategory} from 'getsentry/utils/dataCategory'; import formatCurrency from 'getsentry/utils/formatCurrency'; @@ -27,7 +31,6 @@ type Props = { onSubmit: OnSubmitCallback; onSubmitError: (error: any) => void; onSubmitSuccess: (data: Data) => void; - organization: Organization; subscription: Subscription; tierPlans: BillingConfig['planList']; }; @@ -35,7 +38,6 @@ type Props = { function PlanList({ activePlan, subscription, - organization, onSubmit, onCancel, onSubmitSuccess, @@ -79,27 +81,25 @@ function PlanList({ 100000: '100K', }; - const availableProducts = useMemo( + const availableAddOns = useMemo( () => Object.values(activePlan?.addOnCategories || {}) .filter( - productInfo => - productInfo.billingFlag && - organization.features.includes(productInfo.billingFlag) + productInfo => subscription.addOns?.[productInfo.apiName]?.isAvailable ?? false ) .map(productInfo => { return productInfo; }), - [activePlan?.addOnCategories, organization.features] + [activePlan?.addOnCategories, subscription.addOns] ); useEffect(() => { - availableProducts.forEach(productInfo => { + availableAddOns.forEach(productInfo => { const addOnKey = `addOn${toTitleCase(productInfo.apiName, {allowInnerUpperCase: true})}`; const enabled = subscription.addOns?.[productInfo.apiName]?.enabled; formModel.setValue(addOnKey, enabled); }); - }, [availableProducts, subscription.addOns, formModel]); + }, [availableAddOns, subscription.addOns, formModel]); return (
)} - {availableProducts.length > 0 && ( + {availableAddOns.length > 0 && (

Available Products

- {availableProducts.map(productInfo => { + {availableAddOns.map(productInfo => { const addOnKey = `addOn${toTitleCase(productInfo.apiName, {allowInnerUpperCase: true})}`; + const titleCaseName = toTitleCase(productInfo.productName, { + allowInnerUpperCase: true, + }); + const label = + productInfo.apiName === AddOnCategory.LEGACY_SEER + ? `${titleCaseName} (Legacy)` + : titleCaseName; return ( { formModel.setValue(addOnKey, value.target.checked); diff --git a/static/gsApp/constants.tsx b/static/gsApp/constants.tsx index 8cef22dc316588..c6e837b0a7e2a5 100644 --- a/static/gsApp/constants.tsx +++ b/static/gsApp/constants.tsx @@ -213,5 +213,6 @@ export const BILLED_DATA_CATEGORY_INFO = { canProductTrial: true, maxAdminGift: 10_000, // TODO(seer): Update this to the actual max admin gift tallyType: 'seat', + shortenedUnitName: t('contributor'), }, } as const satisfies Record; diff --git a/static/gsApp/hooks/useProductBillingMetadata.tsx b/static/gsApp/hooks/useProductBillingMetadata.tsx index a7ebf64b465913..934a1f36a828cf 100644 --- a/static/gsApp/hooks/useProductBillingMetadata.tsx +++ b/static/gsApp/hooks/useProductBillingMetadata.tsx @@ -1,5 +1,6 @@ import type {DataCategory} from 'sentry/types/core'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; +import useOrganization from 'sentry/utils/useOrganization'; import type {AddOn, AddOnCategory, ProductTrial, Subscription} from 'getsentry/types'; import { @@ -9,7 +10,10 @@ import { getPotentialProductTrial, productIsEnabled, } from 'getsentry/utils/billing'; -import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; +import { + getCategoryInfoFromPlural, + getPlanCategoryName, +} from 'getsentry/utils/dataCategory'; interface ProductBillingMetadata { /** @@ -50,6 +54,10 @@ interface ProductBillingMetadata { * if any. */ addOnInfo?: AddOn; + /** + * Link to the in-app page for the given product, if any. + */ + productLink?: string; } const EMPTY_PRODUCT_BILLING_METADATA: ProductBillingMetadata = { @@ -68,6 +76,7 @@ export function useProductBillingMetadata( parentProduct?: DataCategory | AddOnCategory, excludeProductTrials?: boolean ): ProductBillingMetadata { + const organization = useOrganization(); const isAddOn = checkIsAddOn(parentProduct ?? product); const billedCategory = getBilledCategory(subscription, product); @@ -75,6 +84,8 @@ export function useProductBillingMetadata( return EMPTY_PRODUCT_BILLING_METADATA; } + const billedCategoryInfo = getCategoryInfoFromPlural(billedCategory); + let displayName = ''; let addOnInfo = undefined; @@ -112,5 +123,9 @@ export function useProductBillingMetadata( potentialProductTrial: excludeProductTrials ? null : getPotentialProductTrial(subscription.productTrials ?? null, billedCategory), + productLink: + organization && billedCategoryInfo + ? billedCategoryInfo.getProductLink?.(organization) + : undefined, }; } diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index e0bc55450e727f..0fc6e28cd4f1c8 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -1202,3 +1202,22 @@ export interface BilledDataCategoryInfo extends DataCategoryInfo { */ shortenedUnitName?: string; } + +type SeatStatus = + | 'UNKNOWN' + | 'ASSIGNED' + | 'OVER_QUOTA' + | 'DISABLED_FOR_BILLING' + | 'REMOVED' + | 'REALLOCATED'; + +export type BillingSeatAssignment = { + billingMetric: DataCategory; + created: string; + displayName: string; + id: number; + isTrialSeat: boolean; + projectId: number; + seatIdentifier: string; + status: SeatStatus; +}; diff --git a/static/gsApp/utils/dataCategory.spec.tsx b/static/gsApp/utils/dataCategory.spec.tsx index 41018348d68301..ee543a19405145 100644 --- a/static/gsApp/utils/dataCategory.spec.tsx +++ b/static/gsApp/utils/dataCategory.spec.tsx @@ -13,7 +13,11 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {DataCategory} from 'sentry/types/core'; +import {UNLIMITED_RESERVED} from 'getsentry/constants'; +import {MILLISECONDS_IN_HOUR} from 'getsentry/utils/billing'; import { + calculateSeerUserSpend, + formatCategoryQuantityWithDisplayName, getPlanCategoryName, getReservedBudgetDisplayName, getSingularCategoryName, @@ -457,3 +461,193 @@ describe('isByteCategory', () => { expect(isByteCategory(DataCategory.TRANSACTIONS)).toBe(false); }); }); + +describe('formatCategoryQuantityWithDisplayName', () => { + const organization = OrganizationFixture(); + const subscription = SubscriptionFixture({organization, plan: 'am3_team'}); + + it('formats profiling categories with hours', () => { + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.PROFILE_DURATION, + quantity: MILLISECONDS_IN_HOUR, + formattedQuantity: '1', + subscription, + options: { + capitalize: false, + }, + }) + ).toBe('1 hour'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.PROFILE_DURATION, + quantity: MILLISECONDS_IN_HOUR * 2, + formattedQuantity: '2', + subscription, + options: { + capitalize: false, + }, + }) + ).toBe('2 hours'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.PROFILE_DURATION, + quantity: MILLISECONDS_IN_HOUR * 1.5, + formattedQuantity: '1.5', + subscription, + options: { + capitalize: false, + }, + }) + ).toBe('1.5 hours'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.PROFILE_DURATION, + quantity: MILLISECONDS_IN_HOUR * 2, + formattedQuantity: '2', + subscription, + options: { + title: true, + }, + }) + ).toBe('2 Hours'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.PROFILE_DURATION, + quantity: UNLIMITED_RESERVED, + formattedQuantity: 'Unlimited', + subscription, + options: { + capitalize: false, + }, + }) + ).toBe('Unlimited hours'); + }); + + it('formats other categories with display names', () => { + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.SEER_USER, + quantity: 1, + formattedQuantity: '1', + subscription, + options: { + capitalize: false, + }, + }) + ).toBe('1 active contributor'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.SEER_USER, + quantity: 2, + formattedQuantity: '2', + subscription, + options: { + capitalize: false, + }, + }) + ).toBe('2 active contributors'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.SEER_USER, + quantity: 2, + formattedQuantity: '2', + subscription, + options: { + capitalize: true, + }, + }) + ).toBe('2 Active contributors'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.SEER_USER, + quantity: 2, + formattedQuantity: '2', + subscription, + options: { + title: true, + }, + }) + ).toBe('2 Active Contributors'); + + expect( + formatCategoryQuantityWithDisplayName({ + dataCategory: DataCategory.SEER_USER, + quantity: UNLIMITED_RESERVED, + formattedQuantity: 'Unlimited', + subscription, + options: { + capitalize: false, + }, + }) + ).toBe('Unlimited active contributors'); + }); +}); + +describe('calculateSeerUserSpend', () => { + it('returns 0 if the category is not SEER_USER', () => { + expect( + calculateSeerUserSpend( + MetricHistoryFixture({ + category: DataCategory.ERRORS, + reserved: 0, + usage: 100, + prepaid: 0, + }) + ) + ).toBe(0); + }); + + it('returns 0 if the reserved is not 0', () => { + expect( + calculateSeerUserSpend( + MetricHistoryFixture({ + category: DataCategory.SEER_USER, + reserved: 100, + usage: 100, + prepaid: 100, + }) + ) + ).toBe(0); + expect( + calculateSeerUserSpend( + MetricHistoryFixture({ + category: DataCategory.SEER_USER, + reserved: UNLIMITED_RESERVED, + usage: 100, + prepaid: UNLIMITED_RESERVED, + }) + ) + ).toBe(0); + }); + + it('returns the spend if the reserved is 0', () => { + expect( + calculateSeerUserSpend( + MetricHistoryFixture({ + category: DataCategory.SEER_USER, + reserved: 0, + usage: 100, + prepaid: 0, + }) + ) + ).toBe(4000_00); + expect( + calculateSeerUserSpend( + MetricHistoryFixture({ + category: DataCategory.SEER_USER, + reserved: 0, + usage: 100, + prepaid: 50, + }) + ) + ).toBe(2000_00); + }); +}); diff --git a/static/gsApp/utils/dataCategory.tsx b/static/gsApp/utils/dataCategory.tsx index acff61129390cc..1be006885e518c 100644 --- a/static/gsApp/utils/dataCategory.tsx +++ b/static/gsApp/utils/dataCategory.tsx @@ -7,7 +7,7 @@ import type {Organization} from 'sentry/types/organization'; import oxfordizeArray from 'sentry/utils/oxfordizeArray'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -import {BILLED_DATA_CATEGORY_INFO} from 'getsentry/constants'; +import {BILLED_DATA_CATEGORY_INFO, UNLIMITED_RESERVED} from 'getsentry/constants'; import type { BilledDataCategoryInfo, BillingMetricHistory, @@ -18,6 +18,7 @@ import type { ReservedBudgetCategory, Subscription, } from 'getsentry/types'; +import {MILLISECONDS_IN_HOUR} from 'getsentry/utils/billing'; /** * Returns the data category info defined in DATA_CATEGORY_INFO for the given category, @@ -290,3 +291,77 @@ export function getChunkCategoryFromDuration(category: DataCategory) { } return ''; } + +function formatWithHours( + quantityInMilliseconds: number, + formattedHours: string, + options: Pick +) { + const quantityInHours = + quantityInMilliseconds === UNLIMITED_RESERVED + ? quantityInMilliseconds + : quantityInMilliseconds / MILLISECONDS_IN_HOUR; + if (quantityInHours === 1) { + return `${formattedHours} ${options.title ? t('Hour') : t('hour')}`; + } + return `${formattedHours} ${options.title ? t('Hours') : t('hours')}`; +} + +/** + * Format category usage or reserved quantity with the appropriate display name. + */ +export function formatCategoryQuantityWithDisplayName({ + dataCategory, + quantity, + formattedQuantity, + subscription, + planOverride, + options = {}, +}: { + dataCategory: DataCategory; + formattedQuantity: string; + options: Omit; + quantity: number; + subscription: Subscription; + planOverride?: Plan; +}) { + if (isContinuousProfiling(dataCategory)) { + return formatWithHours(quantity, formattedQuantity, options); + } + const plan = planOverride ?? subscription.planDetails; + if (quantity === 1) { + const displayName = getSingularCategoryName({ + plan, + category: dataCategory, + capitalize: options.capitalize, + title: options.title, + hadCustomDynamicSampling: options.hadCustomDynamicSampling, + }); + return `${formattedQuantity} ${displayName}`; + } + + const displayName = getPlanCategoryName({ + plan, + category: dataCategory, + capitalize: options.capitalize, + title: options.title, + hadCustomDynamicSampling: options.hadCustomDynamicSampling, + }); + return `${formattedQuantity} ${displayName}`; +} + +/** + * Calculate the accumulated variable spend for active contributors, in cents. + */ +export function calculateSeerUserSpend(metricHistory: BillingMetricHistory) { + const {category, usage, reserved, prepaid} = metricHistory; + if (category !== DataCategory.SEER_USER) { + return 0; + } + if (reserved !== 0) { + // if they have reserved or unlimited seats, we assume there is no variable spend + return 0; + } + // TODO(seer): serialize pricing info + return Math.max(0, usage - prepaid) * 40_00; +} diff --git a/static/gsApp/views/amCheckout/components/cart.tsx b/static/gsApp/views/amCheckout/components/cart.tsx index de9d176a9c3e74..2203a75f6f0cbb 100644 --- a/static/gsApp/views/amCheckout/components/cart.tsx +++ b/static/gsApp/views/amCheckout/components/cart.tsx @@ -478,6 +478,7 @@ function SubtotalSummary({ size="xs" position="bottom" title={t( + // TODO(seer): serialize pricing info '$40 per active contributor. Total varies month to month based on your active contributor count.' )} /> diff --git a/static/gsApp/views/amCheckout/steps/productSelect.tsx b/static/gsApp/views/amCheckout/steps/productSelect.tsx index f0f92c55b5cee9..659036c4428acf 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.tsx @@ -278,6 +278,7 @@ function ProductSelect({ })} + {/* TODO(seer): serialize pricing info */} +$40 diff --git a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx index 12845e29eb1699..fbd40ce3c4cf85 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.spec.tsx @@ -38,6 +38,7 @@ describe('NextBillCard', () => { expect(screen.queryByText(/Pay-as-you-go/)).not.toBeInTheDocument(); expect(screen.queryByText(/Tax/)).not.toBeInTheDocument(); expect(screen.queryByText(/Credits/)).not.toBeInTheDocument(); + expect(screen.queryByText(/active contributors/)).not.toBeInTheDocument(); }); it('renders for bill', async () => { @@ -96,6 +97,15 @@ describe('NextBillCard', () => { period_end: '', description: 'GST/HST', }, + + { + amount: 40_00, + type: 'activated_seer_users', + data: {}, + period_start: '', + period_end: '', + description: '1 active contributor', + }, ], }); MockApiClient.addMockResponse({ @@ -119,6 +129,8 @@ describe('NextBillCard', () => { expect(screen.getByText('$20.00')).toBeInTheDocument(); expect(screen.getByText('Credits')).toBeInTheDocument(); expect(screen.getByText('-$10.00')).toBeInTheDocument(); + expect(screen.getByText('1 active contributor')).toBeInTheDocument(); + expect(screen.getByText('$40.00')).toBeInTheDocument(); }); it('renders alert for error', async () => { diff --git a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx index 36f5cf6a4c113b..6bc24ad50dc146 100644 --- a/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx +++ b/static/gsApp/views/subscriptionPage/headerCards/nextBillCard.tsx @@ -53,6 +53,7 @@ function NextBillCard({ const paygTotal = invoiceItems .filter(item => item.type.startsWith('ondemand_')) .reduce((acc, item) => acc + item.amount, 0); + const seerItem = invoiceItems.find(item => item.type === 'activated_seer_users'); const fees = getFees({invoiceItems}); const credits = getCredits({invoiceItems}); // these should all be negative already @@ -131,6 +132,16 @@ function NextBillCard({ )} + {seerItem && ( + + + {seerItem.description} + + + {displayPriceWithCents({cents: seerItem.amount})} + + + )} {fees.map(item => ( diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/billedSeats.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/billedSeats.spec.tsx new file mode 100644 index 00000000000000..6bfa8091888fa9 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/billedSeats.spec.tsx @@ -0,0 +1,122 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {DataCategory} from 'sentry/types/core'; + +import SubscriptionStore from 'getsentry/stores/subscriptionStore'; +import {AddOnCategory, type Subscription} from 'getsentry/types'; +import BilledSeats from 'getsentry/views/subscriptionPage/usageOverview/components/billedSeats'; + +describe('BilledSeats', () => { + const organization = OrganizationFixture(); + let subscription: Subscription; + + beforeEach(() => { + subscription = SubscriptionFixture({organization, plan: 'am3_business'}); + SubscriptionStore.set(organization.slug, subscription); + + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-seats/current/?billingMetric=${DataCategory.SEER_USER}`, + method: 'GET', + body: [], + }); + }); + + it('should not render for non-Seer product', () => { + render( + + ); + expect(screen.queryByText(/Active Contributors/)).not.toBeInTheDocument(); + }); + + it('should not render when Seer is disabled', () => { + subscription.addOns = { + ...subscription.addOns, + seer: { + ...subscription.addOns?.seer!, + enabled: false, + }, + }; + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + expect(screen.queryByText(/Active Contributors/)).not.toBeInTheDocument(); + }); + + it('should render when Seer is enabled', () => { + subscription.addOns = { + ...subscription.addOns, + seer: { + ...subscription.addOns?.seer!, + enabled: true, + }, + }; + SubscriptionStore.set(organization.slug, subscription); + render( + + ); + expect(screen.getByText('Active Contributors (0)')).toBeInTheDocument(); + }); + + it('should render the list of active contributors', async () => { + subscription.addOns = { + ...subscription.addOns, + seer: { + ...subscription.addOns?.seer!, + enabled: true, + }, + }; + SubscriptionStore.set(organization.slug, subscription); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-seats/current/?billingMetric=${DataCategory.SEER_USER}`, + method: 'GET', + body: [ + { + billingMetric: DataCategory.SEER_USER, + created: '2021-01-01', + displayName: 'johndoe', + id: 1, + isTrialSeat: false, + projectId: 1, + seatIdentifier: '1234567890', + status: 'ASSIGNED', + }, + { + billingMetric: DataCategory.SEER_USER, + created: '2021-01-01', + displayName: 'janedoe', + id: 2, + isTrialSeat: false, + projectId: 1, + seatIdentifier: '1234567890', + status: 'ASSIGNED', + }, + ], + }); + render( + + ); + await screen.findByText('Active Contributors (2)'); + expect(screen.getByText('@johndoe')).toBeInTheDocument(); + expect(screen.getByText('@janedoe')).toBeInTheDocument(); + }); +}); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/billedSeats.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/billedSeats.tsx new file mode 100644 index 00000000000000..b9fd83636ec3bf --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/billedSeats.tsx @@ -0,0 +1,108 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import {Container} from '@sentry/scraps/layout'; + +import LoadingError from 'sentry/components/loadingError'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import Pagination from 'sentry/components/pagination'; +import {SimpleTable} from 'sentry/components/tables/simpleTable'; +import TimeSince from 'sentry/components/timeSince'; +import {t, tct} from 'sentry/locale'; +import {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import {defined} from 'sentry/utils'; +import {useApiQuery} from 'sentry/utils/queryClient'; + +import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; +import { + AddOnCategory, + type BillingSeatAssignment, + type Subscription, +} from 'getsentry/types'; + +function BilledSeats({ + selectedProduct, + subscription, + organization, +}: { + organization: Organization; + selectedProduct: DataCategory | AddOnCategory; + subscription: Subscription; +}) { + const {billedCategory, isEnabled} = useProductBillingMetadata( + subscription, + selectedProduct + ); + const shouldShowBilledSeats = + selectedProduct === AddOnCategory.SEER && defined(billedCategory) && isEnabled; + const billedSeatsQueryKey = [ + `/customers/${organization.slug}/billing-seats/current/?billingMetric=${billedCategory}`, + ] as const; + const { + data: billedSeats, + isPending: seatsLoading, + error: seatsError, + refetch, + getResponseHeader, + } = useApiQuery(billedSeatsQueryKey, { + staleTime: 0, + enabled: shouldShowBilledSeats, + }); + + if (!shouldShowBilledSeats) { + // eventually we should expand this to support other seat-based products + return null; + } + + return ( + + 0}> + + + {tct('Active Contributors ([count])', {count: billedSeats?.length ?? 0})} + + + {t('Date Added')} + + + {seatsError && ( + + + + )} + {seatsLoading && ( + + + + )} + {billedSeats?.map(seat => ( + + @{seat.displayName} + + + + + ))} +
+ {billedSeats && billedSeats.length > 0 && ( + + + + )} +
+ ); +} + +export default BilledSeats; + +const Table = styled(SimpleTable)<{hasBorderTop: boolean}>` + grid-template-columns: 1fr 1fr; + border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}; + border: none; + border-top: ${p => (p.hasBorderTop ? `1px solid ${p.theme.border}` : 'none')}; +`; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx index a248f48d1451a5..22d88ce22a3d94 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/breakdownInfo.tsx @@ -5,7 +5,7 @@ import {Text} from '@sentry/scraps/text'; import QuestionTooltip from 'sentry/components/questionTooltip'; import {t, tct} from 'sentry/locale'; -import type {DataCategory} from 'sentry/types/core'; +import {DataCategory} from 'sentry/types/core'; import {defined} from 'sentry/utils'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; @@ -26,6 +26,7 @@ import { isTrialPlan, supportsPayg, } from 'getsentry/utils/billing'; +import {calculateSeerUserSpend} from 'getsentry/utils/dataCategory'; import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils'; interface BaseProps { @@ -44,6 +45,7 @@ interface UsageBreakdownInfoProps extends BaseProps { platformReservedField: React.ReactNode; productCanUsePayg: boolean; recurringReservedSpend: number | null; + formattedOtherSpend?: React.ReactNode; } interface DataCategoryUsageBreakdownInfoProps extends BaseProps { @@ -90,6 +92,7 @@ function UsageBreakdownInfo({ productCanUsePayg, activeProductTrial, formattedSoftCapType, + formattedOtherSpend, }: UsageBreakdownInfoProps) { const canUsePayg = productCanUsePayg && supportsPayg(subscription); const shouldShowIncludedVolume = @@ -102,7 +105,10 @@ function UsageBreakdownInfo({ recurringReservedSpend > 0 && subscription.canSelfServe; const shouldShowAdditionalSpend = - shouldShowReservedSpend || canUsePayg || defined(formattedSoftCapType); + shouldShowReservedSpend || + canUsePayg || + defined(formattedSoftCapType) || + formattedOtherSpend; if (!shouldShowIncludedVolume && !shouldShowAdditionalSpend) { return null; @@ -177,6 +183,7 @@ function UsageBreakdownInfo({ )} /> )} + {formattedOtherSpend}
)} @@ -220,8 +227,7 @@ function DataCategoryUsageBreakdownInfo({ ); const gifted = metricHistory.free ?? 0; - const formattedGifted = - isAddOnChildCategory || !gifted ? null : formatReservedWithUnits(gifted, category); + const formattedGifted = gifted ? formatReservedWithUnits(gifted, category) : null; const paygSpend = metricHistory.onDemandSpendUsed ?? 0; const paygCategoryBudget = metricHistory.onDemandBudget ?? 0; @@ -231,6 +237,20 @@ function DataCategoryUsageBreakdownInfo({ : (plan.planCategories[category]?.find(bucket => bucket.events === reserved)?.price ?? 0); + const otherSpend = calculateSeerUserSpend(metricHistory); + const formattedOtherSpend = + otherSpend > 0 ? ( + + ) : undefined; + return ( ); } diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx index 3478c4c002f50b..53adcf331abf28 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.spec.tsx @@ -2,6 +2,7 @@ import moment from 'moment-timezone'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; +import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory'; import { SubscriptionFixture, SubscriptionWithLegacySeerFixture, @@ -227,8 +228,9 @@ describe('ProductBreakdownPanel', () => { /> ); - await screen.findByRole('heading', {name: 'Continuous Profile Hours'}); - expect(screen.getByRole('button', {name: 'Activate free trial'})).toBeInTheDocument(); + expect( + await screen.findByRole('button', {name: 'Activate free trial'}) + ).toBeInTheDocument(); }); it('renders upgrade CTA', async () => { @@ -241,8 +243,7 @@ describe('ProductBreakdownPanel', () => { /> ); - await screen.findByRole('heading', {name: 'Continuous Profile Hours'}); - expect(screen.getByRole('button', {name: 'Upgrade now'})).toBeInTheDocument(); + expect(await screen.findByRole('button', {name: 'Upgrade now'})).toBeInTheDocument(); }); it('renders active product trial status', async () => { @@ -332,4 +333,79 @@ describe('ProductBreakdownPanel', () => { expect(screen.queryByText('Pay-as-you-go')).not.toBeInTheDocument(); expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument(); }); + + it('renders for Seer add-on', async () => { + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-seats/current/?billingMetric=${DataCategory.SEER_USER}`, + method: 'GET', + body: [ + { + billingMetric: DataCategory.SEER_USER, + created: '2021-01-01', + displayName: 'johndoe', + id: 1, + isTrialSeat: false, + projectId: 1, + seatIdentifier: '1234567890', + status: 'ASSIGNED', + }, + + { + billingMetric: DataCategory.SEER_USER, + created: '2021-01-01', + displayName: 'janedoe', + id: 2, + isTrialSeat: false, + projectId: 1, + seatIdentifier: '1234567890', + status: 'ASSIGNED', + }, + + { + billingMetric: DataCategory.SEER_USER, + created: '2021-01-01', + displayName: 'alicebob', + id: 3, + isTrialSeat: false, + projectId: 1, + seatIdentifier: '1234567890', + status: 'ASSIGNED', + }, + ], + }); + subscription.categories.seerUsers = MetricHistoryFixture({ + category: DataCategory.SEER_USER, + usage: 3, + free: 1, + prepaid: 1, + reserved: 0, + }); + subscription.addOns!.seer = { + ...subscription.addOns!.seer!, + enabled: true, + }; + render( + + ); + await screen.findByRole('heading', {name: 'Seer'}); + expect(screen.getByText('Included volume')).toBeInTheDocument(); + expect(screen.queryByText('Business plan')).not.toBeInTheDocument(); + expect(screen.queryByText('Additional reserved')).not.toBeInTheDocument(); + expect(screen.getByText('Gifted')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('Additional spend')).toBeInTheDocument(); + expect(screen.queryByText('Pay-as-you-go')).not.toBeInTheDocument(); + expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument(); + expect(screen.getByText('Active contributors spend')).toBeInTheDocument(); + expect(screen.getByText('$80.00')).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Active Contributors (3)'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Configure Seer'})).toBeInTheDocument(); + }); }); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx index 9c8594947aef07..02c63b29b0ccff 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/panel.tsx @@ -1,21 +1,23 @@ import {Fragment} from 'react'; import {Tag} from '@sentry/scraps/badge'; +import {LinkButton} from '@sentry/scraps/button/linkButton'; import {Container, Flex} from '@sentry/scraps/layout'; import {Heading} from '@sentry/scraps/text'; -import {IconClock, IconWarning} from 'sentry/icons'; +import {IconClock, IconSettings, IconWarning} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import {DataCategory} from 'sentry/types/core'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; -import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types'; +import {OnDemandBudgetMode} from 'getsentry/types'; import { displayBudgetName, getReservedBudgetCategoryForAddOn, supportsPayg, } from 'getsentry/utils/billing'; +import BilledSeats from 'getsentry/views/subscriptionPage/usageOverview/components/billedSeats'; import { DataCategoryUsageBreakdownInfo, ReservedBudgetUsageBreakdownInfo, @@ -29,10 +31,13 @@ import {USAGE_OVERVIEW_PANEL_HEADER_HEIGHT} from 'getsentry/views/subscriptionPa import type {BreakdownPanelProps} from 'getsentry/views/subscriptionPage/usageOverview/types'; function PanelHeader({ + panelIsOnlyCta, selectedProduct, subscription, isInline, -}: Pick) { +}: Pick & { + panelIsOnlyCta: boolean; +}) { const {onDemandBudgets: paygBudgets} = subscription; const { @@ -42,14 +47,10 @@ function PanelHeader({ addOnInfo, usageExceeded, activeProductTrial, + productLink, } = useProductBillingMetadata(subscription, selectedProduct); - if ( - // special case for seer add-on - selectedProduct === AddOnCategory.SEER || - !billedCategory || - (isAddOn && !addOnInfo) - ) { + if (!billedCategory || (isAddOn && !addOnInfo) || panelIsOnlyCta) { return null; } @@ -84,13 +85,24 @@ function PanelHeader({ return ( - {!isInline && {displayName}} - {status} + + {!isInline && {displayName}} + {status} + + {productLink && ( + } + aria-label={t('Configure %s', displayName)} + title={tct('Configure [productName]', {productName: displayName})} + /> + )} ); } @@ -135,6 +147,18 @@ function ProductBreakdownPanel({ activeProductTrial={activeProductTrial} /> ); + } else { + const metricHistory = subscription.categories[billedCategory]; + if (metricHistory) { + breakdownInfo = ( + + ); + } } } else { const category = selectedProduct as DataCategory; @@ -157,9 +181,8 @@ function ProductBreakdownPanel({ return ( {potentialProductTrial && ( )} {shouldShowUpgradeCta && ( - + )} + ); } diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx index 5412869f4e6981..d3f93236a11dfa 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/tableRow.tsx @@ -24,7 +24,12 @@ import { MILLISECONDS_IN_HOUR, supportsPayg, } from 'getsentry/utils/billing'; -import {isByteCategory, isContinuousProfiling} from 'getsentry/utils/dataCategory'; +import { + calculateSeerUserSpend, + formatCategoryQuantityWithDisplayName, + isByteCategory, + isContinuousProfiling, +} from 'getsentry/utils/dataCategory'; import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; import ProductBreakdownPanel from 'getsentry/views/subscriptionPage/usageOverview/components/panel'; import ProductTrialRibbon from 'getsentry/views/subscriptionPage/usageOverview/components/productTrialRibbon'; @@ -91,6 +96,7 @@ function UsageOverviewTableRow({ let formattedFree = null; let paygSpend = 0; let isUnlimited = false; + let otherSpend = 0; if (isAddOn) { if (!addOnInfo) { @@ -162,13 +168,14 @@ function UsageOverviewTableRow({ paygSpend = subscription.categories[billedCategory]?.onDemandSpendUsed ?? 0; } - const {reserved} = metricHistory; + const {reserved, prepaid, usage} = metricHistory; const bucket = getBucket({ events: reserved ?? 0, // buckets use the converted unit reserved amount (ie. in GB for byte categories) buckets: subscription.planDetails.planCategories[billedCategory], }); + otherSpend = calculateSeerUserSpend(metricHistory); const recurringReservedSpend = isChildProduct ? 0 : (bucket.price ?? 0); - const additionalSpend = recurringReservedSpend + paygSpend; + const additionalSpend = recurringReservedSpend + paygSpend + otherSpend; const formattedSoftCapType = isChildProduct || !isAddOn ? getSoftCapType(metricHistory) : null; @@ -196,9 +203,12 @@ function UsageOverviewTableRow({ !isUnlimited && (!isAddOn || formattedPrepaid); + const shouldFormatWithDisplayName = + isContinuousProfiling(billedCategory) || billedCategory === DataCategory.SEER_USER; + return ( - setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} isSelected={isSelected} @@ -264,9 +274,19 @@ function UsageOverviewTableRow({ {isUnlimited ? ( {t('Unlimited')} ) : isPaygOnly || isChildProduct || !formattedPrepaid ? ( - formattedUsage + shouldFormatWithDisplayName ? ( + formatCategoryQuantityWithDisplayName({ + dataCategory: billedCategory, + quantity: usage, + formattedQuantity: formattedUsage, + subscription, + options: {capitalize: false}, + }) + ) : ( + formattedUsage + ) ) : ( - `${formattedUsage} / ${formattedPrepaid}` + `${formattedUsage} / ${shouldFormatWithDisplayName ? formatCategoryQuantityWithDisplayName({dataCategory: billedCategory, quantity: prepaid, formattedQuantity: formattedPrepaid, subscription, options: {capitalize: false}}) : formattedPrepaid}` )} {formattedFree && ` (${formattedFree} gifted)`} @@ -282,9 +302,9 @@ function UsageOverviewTableRow({ )} {(isSelected || isHovered) && } - + {showPanelInline && isSelected && ( - + - + )} ); @@ -302,12 +322,7 @@ function UsageOverviewTableRow({ export default UsageOverviewTableRow; -const Row = styled('tr')<{isSelected: boolean}>` - position: relative; - background: ${p => (p.isSelected ? p.theme.backgroundSecondary : p.theme.background)}; - padding: ${p => p.theme.space.xl}; - cursor: pointer; - +const Row = styled('tr')` &:not(:last-child) { border-bottom: 1px solid ${p => p.theme.border}; } @@ -315,6 +330,13 @@ const Row = styled('tr')<{isSelected: boolean}>` &:last-child { border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}; } +`; + +const ProductRow = styled(Row)<{isSelected: boolean}>` + position: relative; + background: ${p => (p.isSelected ? p.theme.backgroundSecondary : p.theme.background)}; + padding: ${p => p.theme.space.xl}; + cursor: pointer; &:hover { background: ${p => p.theme.backgroundSecondary}; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx b/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx index 14e8409b387d14..35863d74576826 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/components/upgradeOrTrialCta.tsx @@ -4,7 +4,7 @@ import {LinkButton} from '@sentry/scraps/button/linkButton'; import {Container, Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; -import {IconLightning, IconLock, IconOpen} from 'sentry/icons'; +import {IconLightning, IconLock, IconOpen, IconUpload} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; @@ -42,6 +42,8 @@ function Cta({ borderBottom={hasContentBelow ? 'primary' : undefined} radius={hasContentBelow ? undefined : '0 0 md md'} align="center" + justify="center" + height={hasContentBelow ? undefined : '100%'} > {icon && ( @@ -86,6 +88,44 @@ function FindOutMoreButton({ ); } +/** + * Full panel CTA for Seer + */ +function SeerCta({action, footerText}: {action: React.ReactNode; footerText?: string}) { + // TODO(isabella): If we ever extend the full panel CTA to other products, we should + // add copy to BILLED_DATA_CATEGORY_INFO or serialize them in some endpoint + return ( + + + + + {t('Find and fix issues anywhere with Seer AI debugger')} + + + {/* TODO(seer): serialize pricing info */} + $40 + {t('per active contributor / month')} + + + {action} + {footerText && ( + + {footerText} + + )} + + + ); +} + function ProductTrialCta({ organization, subscription, @@ -121,6 +161,38 @@ function ProductTrialCta({ title: true, }); + if (selectedProduct === AddOnCategory.SEER) { + return ( + } + organization={organization} + source="usage-overview" + requestData={{ + productTrial: { + category: potentialProductTrial.category, + reasonCode: potentialProductTrial.reasonCode, + }, + }} + priority="primary" + handleClick={() => setTrialButtonBusy(true)} + onTrialStarted={() => setTrialButtonBusy(true)} + onTrialFailed={() => setTrialButtonBusy(false)} + busy={trialButtonBusy} + disabled={trialButtonBusy} + > + {t('Start 14 day free trial')} + + } + footerText={t( + "Trial begins immediately. You won't be billed unless you upgrade after the trial ends." + )} + /> + ); + } + return ( 0) are managed by sales (subscription.customPrice !== null && subscription.customPrice > 0); + if (selectedProduct === AddOnCategory.SEER) { + return ( + } + priority="primary" + href={`/checkout/${organization.slug}/?referrer=product-breakdown-panel`} + > + {t('Add to plan')} + + ) : isSalesAccount ? ( + + {tct('Contact us at [mailto:sales@sentry.io] to upgrade.', { + mailto: , + })} + + ) : ( + + {tct('Contact us at [mailto:support@sentry.io] to upgrade.', { + mailto: , + })} + + ) + } + /> + ); + } + return ( } diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx index 49f810c6b51ff5..9b03064153ca19 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -106,7 +106,7 @@ function UsageOverview({subscription, organization, usageData}: UsageOverviewPro