diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx index f094328aaad7..e94ad587998b 100644 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ b/frontend/web/components/pages/usage/UsageDashboard.tsx @@ -9,21 +9,29 @@ export type UsageDashboardProps = { data: Res['organisationUsage'] | undefined total: number limit: PlanLimit + comparable?: boolean + meterNote?: ReactNode + showPlanCeiling?: boolean hasBillingPeriod: boolean isError?: boolean isLoading?: boolean + onRetry?: () => void filters?: ReactNode breakdown?: ReactNode } const UsageDashboard: FC = ({ breakdown, + comparable, data, filters, hasBillingPeriod, isError, isLoading, limit, + meterNote, + onRetry, + showPlanCeiling, total, }) => { let content @@ -40,16 +48,28 @@ const UsageDashboard: FC = ({ title='Usage could not be loaded' description='Something went wrong fetching usage for this period. Try again in a moment.' icon='bar-chart' + action={ + onRetry && ( + + ) + } /> ) } else { content = ( <> - + diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index c00d3e5e653a..51ac97e7f4f0 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -11,6 +11,9 @@ import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown' import UsageDashboard from './UsageDashboard' import { isBillingPeriodSelected, + contributionNote, + isComparableToAllowance, + showsPlanCeiling, periodLabel, periodsFor, PeriodSelection, @@ -34,6 +37,7 @@ const UsageDashboardPage: FC = ({ data: organisation, isError: organisationFailed, isLoading: loadingOrganisation, + refetch: refetchOrganisation, } = useGetOrganisationQuery( organisationId ? { id: organisationId } : skipToken, ) @@ -49,6 +53,8 @@ const UsageDashboardPage: FC = ({ data, isError: usageFailed, isFetching: loadingUsage, + isUninitialized: usageNotStarted, + refetch: refetchUsage, } = useGetOrganisationUsageQuery( organisationId && organisation ? { @@ -57,11 +63,26 @@ const UsageDashboardPage: FC = ({ projectId: selectedProjectId, } : skipToken, + // usage-data is throttled at five requests a minute per user, so + // refetching every time the tab regains focus spends that budget. + { refetchOnFocus: false }, + ) + const { data: organisationData } = useGetOrganisationUsageQuery( + organisationId && organisation + ? { billing_period: billingPeriod, organisationId } + : skipToken, + // usage-data is throttled at five requests a minute per user, so + // refetching every time the tab regains focus spends that budget. + { refetchOnFocus: false }, + ) + + const { + data: subscriptionMeta, + isLoading: loadingLimit, + refetch: refetchLimit, + } = useGetSubscriptionMetadataQuery( + organisationId ? { id: organisationId } : skipToken, ) - const { data: subscriptionMeta, isLoading: loadingLimit } = - useGetSubscriptionMetadataQuery( - organisationId ? { id: organisationId } : skipToken, - ) const periods = periodsFor(planIsBilled) @@ -81,9 +102,20 @@ const UsageDashboardPage: FC = ({ return ( = ({ } isError={organisationFailed || usageFailed} isLoading={loadingOrganisation || loadingUsage || loadingLimit} + onRetry={() => { + refetchOrganisation() + refetchLimit() + if (!usageNotStarted) { + refetchUsage() + } + }} filters={
diff --git a/frontend/web/components/pages/usage/__tests__/utils.test.ts b/frontend/web/components/pages/usage/__tests__/utils.test.ts index f8b795a205ca..81fe89fb57c0 100644 --- a/frontend/web/components/pages/usage/__tests__/utils.test.ts +++ b/frontend/web/components/pages/usage/__tests__/utils.test.ts @@ -1,6 +1,9 @@ import { Subscription } from 'common/types/responses' import { + contributionNote, isBillingPeriodSelected, + isComparableToAllowance, + showsPlanCeiling, planHasBillingPeriod, periodsFor, resolvePeriod, @@ -81,6 +84,44 @@ describe('UsageDashboard utils', () => { }) }) + describe('contributionNote', () => { + it('says what share of the organisation a project accounts for', () => { + expect(contributionNote('Checkout', 400000, 1000000)).toBe( + 'Checkout accounts for 40% of that usage.', + ) + }) + + it('has nothing to say when the organisation used nothing', () => { + expect(contributionNote('Checkout', 0, 0)).toBeUndefined() + }) + }) + + describe('isComparableToAllowance', () => { + it('compares the 30 day window and the billing periods', () => { + expect(isComparableToAllowance(undefined)).toBe(true) + expect(isComparableToAllowance('current_billing_period')).toBe(true) + }) + + it('refuses the 90 day window against a 30 day allowance', () => { + expect(isComparableToAllowance('90_day_period')).toBe(false) + }) + }) + + describe('showsPlanCeiling', () => { + it('draws the ceiling for the organisation over a comparable period', () => { + expect(showsPlanCeiling(undefined, undefined)).toBe(true) + expect(showsPlanCeiling('current_billing_period', undefined)).toBe(true) + }) + + it('drops it for one project, which will never reach the ceiling', () => { + expect(showsPlanCeiling(undefined, 12)).toBe(false) + }) + + it('drops it for the 90 day window', () => { + expect(showsPlanCeiling('90_day_period', undefined)).toBe(false) + }) + }) + describe('periodsFor', () => { it('offers the billing periods only when there is a term', () => { expect(periodsFor(true).map((period) => period.value)).toContain( diff --git a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx index 1643de2d4fef..c9af7c6c3059 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx +++ b/frontend/web/components/pages/usage/components/UsageMeter/UsageMeter.tsx @@ -1,12 +1,7 @@ import { FC, ReactNode } from 'react' -import Format from 'common/utils/format' import UsageBar from 'components/shared/UsageBar' -import { - PlanLimit, - toneFor, - usagePercent, -} from 'components/shared/UsageBar/utils' -import { meterCopy } from './utils' +import { PlanLimit } from 'components/shared/UsageBar/utils' +import { meterCopy, meterTone } from './utils' import './UsageMeter.scss' const WARN_AT = 75 @@ -15,12 +10,18 @@ const NOTIFICATION_THRESHOLDS = [WARN_AT, 100] export type UsageMeterProps = { total: number limit: PlanLimit + comparable?: boolean note?: ReactNode } -const UsageMeter: FC = ({ limit, note, total }) => { - const copy = meterCopy(total, limit) - const tone = toneFor(usagePercent(total, limit), WARN_AT) +const UsageMeter: FC = ({ + comparable = true, + limit, + note, + total, +}) => { + const copy = meterCopy(total, limit, comparable) + const tone = meterTone(total, limit, comparable, WARN_AT) return (
@@ -28,7 +29,11 @@ const UsageMeter: FC = ({ limit, note, total }) => {

Plan usage

- + {copy.headline} @@ -36,18 +41,20 @@ const UsageMeter: FC = ({ limit, note, total }) => {
-
-
- {Format.shortenNumber(total)} - {copy.fractionSuffix} + {copy.fraction && ( +
+
+ {copy.fraction.value} + {copy.fraction.suffix} +
+
+ {copy.fraction.caption} +
-
- {copy.fractionCaption} -
-
+ )}
- {!!limit && ( + {!!limit && comparable && ( = ({ limit, note, total }) => { /> )} - {note} + {note && ( +

{note}

+ )}
) } diff --git a/frontend/web/components/pages/usage/components/UsageMeter/__tests__/utils.test.ts b/frontend/web/components/pages/usage/components/UsageMeter/__tests__/utils.test.ts index d6d472f0467b..3dee98c3c41b 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/__tests__/utils.test.ts +++ b/frontend/web/components/pages/usage/components/UsageMeter/__tests__/utils.test.ts @@ -1,11 +1,17 @@ -import { meterCopy } from 'components/pages/usage/components/UsageMeter/utils' +import { + meterCopy, + meterTone, +} from 'components/pages/usage/components/UsageMeter/utils' describe('UsageMeter utils', () => { describe('meterCopy', () => { it('reads as a percentage of the limit when there is one', () => { expect(meterCopy(1500000, 2000000)).toEqual({ - fractionCaption: 'API calls used / plan limit', - fractionSuffix: ' / 2M', + fraction: { + caption: 'API calls used / plan limit', + suffix: ' / 2M', + value: '1.5M', + }, headline: '75%', headlineCaption: 'of plan consumed', }) @@ -20,17 +26,40 @@ describe('UsageMeter utils', () => { 'falls back to the raw count when the limit is %p', (limit) => { expect(meterCopy(1500000, limit)).toEqual({ - fractionCaption: 'API calls used', - fractionSuffix: '', + fraction: undefined, headline: '1.5M', headlineCaption: 'API calls', }) }, ) + it('names the allowance window when the period rules a percentage out', () => { + expect(meterCopy(1500000, 2000000, false)).toEqual({ + fraction: { caption: 'allowance per 30 days', value: '2M' }, + headline: '1.5M', + headlineCaption: 'API calls', + }) + }) + it('shows zero rather than NaN for an organisation with no calls', () => { expect(meterCopy(0, null).headline).toBe('0') expect(meterCopy(0, 2000000).headline).toBe('0%') }) }) + + describe('meterTone', () => { + it('tracks the thresholds when the comparison holds', () => { + expect(meterTone(500000, 2000000, true, 75)).toBe('success') + expect(meterTone(1600000, 2000000, true, 75)).toBe('warning') + expect(meterTone(2000000, 2000000, true, 75)).toBe('danger') + }) + + it('has no tone to give when the comparison does not hold', () => { + expect(meterTone(9000000, 2000000, false, 75)).toBeUndefined() + }) + + it('has no tone to give without a limit', () => { + expect(meterTone(9000000, null, true, 75)).toBeUndefined() + }) + }) }) diff --git a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts index 80ddc2a5e9a2..dee226aa4de5 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts +++ b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts @@ -1,26 +1,53 @@ import Format from 'common/utils/format' -import { PlanLimit, usagePercent } from 'components/shared/UsageBar/utils' +import { + PlanLimit, + toneFor, + usagePercent, + UsageTone, +} from 'components/shared/UsageBar/utils' -export type MeterCopy = { +type MeterCopy = { headline: string headlineCaption: string - fractionSuffix: string - fractionCaption: string + fraction?: { + value: string + suffix?: string + caption: string + } } const withLimit = (total: number, limit: number): MeterCopy => ({ - fractionCaption: 'API calls used / plan limit', - fractionSuffix: ` / ${Format.shortenNumber(limit)}`, + fraction: { + caption: 'API calls used / plan limit', + suffix: ` / ${Format.shortenNumber(limit)}`, + value: Format.shortenNumber(total), + }, headline: `${usagePercent(total, limit)}%`, headlineCaption: 'of plan consumed', }) -const withoutLimit = (total: number): MeterCopy => ({ - fractionCaption: 'API calls used', - fractionSuffix: '', +const withoutLimit = (total: number, limit: PlanLimit): MeterCopy => ({ + fraction: limit + ? { + caption: 'allowance per 30 days', + value: Format.shortenNumber(limit), + } + : undefined, headline: Format.shortenNumber(total), headlineCaption: 'API calls', }) -export const meterCopy = (total: number, limit: PlanLimit): MeterCopy => - limit ? withLimit(total, limit) : withoutLimit(total) +export const meterCopy = ( + total: number, + limit: PlanLimit, + comparable = true, +): MeterCopy => + limit && comparable ? withLimit(total, limit) : withoutLimit(total, limit) + +export const meterTone = ( + total: number, + limit: PlanLimit, + comparable: boolean, + warnAt: number, +): UsageTone | undefined => + limit && comparable ? toneFor(usagePercent(total, limit), warnAt) : undefined diff --git a/frontend/web/components/pages/usage/utils.ts b/frontend/web/components/pages/usage/utils.ts index 823b24d3b504..eedd5c20dc7e 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -26,6 +26,28 @@ export const resolvePeriod = ( export const isBillingPeriodSelected = (period: BillingPeriod): boolean => period === 'current_billing_period' || period === 'previous_billing_period' +export const isComparableToAllowance = (period: BillingPeriod): boolean => + period !== '90_day_period' + +export const contributionNote = ( + projectName: string, + scopedTotal: number, + organisationTotal: number, +): string | undefined => { + if (organisationTotal <= 0) { + return undefined + } + + const percent = Math.round((scopedTotal / organisationTotal) * 100) + + return `${projectName} accounts for ${percent}% of that usage.` +} + +export const showsPlanCeiling = ( + period: BillingPeriod, + projectId: number | undefined, +): boolean => isComparableToAllowance(period) && !projectId + export const periodLabel = ( periods: PeriodOption[], period: BillingPeriod,