diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index 45139cb3cbaa..c637ee0336f1 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -3,6 +3,7 @@ import { UsageDashboard } from 'components/pages/usage' import { Res } from 'common/types/responses' const meta: Meta = { + args: { meterWindow: 'this billing period' }, component: UsageDashboard, parameters: { layout: 'fullscreen' }, title: 'Pages/Usage Dashboard/Page', @@ -85,6 +86,7 @@ export const FreeOnARollingWindow: Story = { data: free, hasBillingPeriod: false, limit: 50000, + meterWindow: 'last 30 days', total: free.totals.total, }, } @@ -96,6 +98,7 @@ export const EnterpriseWithoutABillingPeriod: Story = { data: paid, hasBillingPeriod: false, limit: 50000000, + meterWindow: 'last 30 days', total: paid.totals.total, }, } @@ -107,6 +110,7 @@ export const WithoutAPlanLimit: Story = { data: paid, hasBillingPeriod: false, limit: null, + meterWindow: 'last 30 days', total: paid.totals.total, }, } diff --git a/frontend/documentation/components/UsageMeter.stories.tsx b/frontend/documentation/components/UsageMeter.stories.tsx index 8eb53252d7b4..2c1ae466c3a7 100644 --- a/frontend/documentation/components/UsageMeter.stories.tsx +++ b/frontend/documentation/components/UsageMeter.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from 'storybook' import UsageMeter from 'components/pages/usage/components/UsageMeter' const meta: Meta = { + args: { windowLabel: 'this billing period' }, component: UsageMeter, parameters: { layout: 'padded' }, title: 'Pages/Usage Dashboard/Components/UsageMeter', @@ -28,7 +29,11 @@ export const OverTheLimit: Story = { // Nothing to be a percentage of, so the total stands on its own. export const WithoutAPlanLimit: Story = { - args: { limit: null, total: 340000 }, + args: { + limit: null, + total: 340000, + windowLabel: 'last 30 days', + }, } export const NoUsageYet: Story = { diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx index f094328aaad7..c059fee626b1 100644 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ b/frontend/web/components/pages/usage/UsageDashboard.tsx @@ -9,9 +9,13 @@ export type UsageDashboardProps = { data: Res['organisationUsage'] | undefined total: number limit: PlanLimit + meterWindow: string + meterNote?: ReactNode + showPlanCeiling?: boolean hasBillingPeriod: boolean isError?: boolean isLoading?: boolean + onRetry?: () => void filters?: ReactNode breakdown?: ReactNode } @@ -24,6 +28,10 @@ const UsageDashboard: FC = ({ isError, isLoading, limit, + meterNote, + meterWindow, + 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..aa22c8dc6862 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -11,6 +11,10 @@ import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown' import UsageDashboard from './UsageDashboard' import { isBillingPeriodSelected, + contributionNote, + allowanceWindow, + allowanceWindowLabel, + showsPlanCeiling, periodLabel, periodsFor, PeriodSelection, @@ -34,6 +38,7 @@ const UsageDashboardPage: FC = ({ data: organisation, isError: organisationFailed, isLoading: loadingOrganisation, + refetch: refetchOrganisation, } = useGetOrganisationQuery( organisationId ? { id: organisationId } : skipToken, ) @@ -49,6 +54,8 @@ const UsageDashboardPage: FC = ({ data, isError: usageFailed, isFetching: loadingUsage, + isUninitialized: usageNotStarted, + refetch: refetchUsage, } = useGetOrganisationUsageQuery( organisationId && organisation ? { @@ -57,11 +64,30 @@ 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 + ? { + // The meter answers for the allowance window, not the one on screen. + billing_period: allowanceWindow(planIsBilled), + 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 +107,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..cd7e5b7c551a 100644 --- a/frontend/web/components/pages/usage/__tests__/utils.test.ts +++ b/frontend/web/components/pages/usage/__tests__/utils.test.ts @@ -1,6 +1,10 @@ import { Subscription } from 'common/types/responses' import { + contributionNote, isBillingPeriodSelected, + allowanceWindow, + allowanceWindowLabel, + showsPlanCeiling, planHasBillingPeriod, periodsFor, resolvePeriod, @@ -81,6 +85,45 @@ 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('allowanceWindow', () => { + it('measures a billed organisation over its billing period', () => { + expect(allowanceWindow(true)).toBe('current_billing_period') + expect(allowanceWindowLabel(true)).toBe('this billing period') + }) + + it('measures everyone else over the trailing 30 days', () => { + expect(allowanceWindow(false)).toBeUndefined() + expect(allowanceWindowLabel(false)).toBe('last 30 days') + }) + }) + + 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..60727cd499f8 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,20 +10,32 @@ const NOTIFICATION_THRESHOLDS = [WARN_AT, 100] export type UsageMeterProps = { total: number limit: PlanLimit + windowLabel: string note?: ReactNode } -const UsageMeter: FC = ({ limit, note, total }) => { +const UsageMeter: FC = ({ + limit, + note, + total, + windowLabel, +}) => { const copy = meterCopy(total, limit) - const tone = toneFor(usagePercent(total, limit), WARN_AT) + const tone = meterTone(total, limit, WARN_AT) return (
-

Plan usage

+

+ Plan usage ยท {windowLabel} +

- + {copy.headline} @@ -36,15 +43,17 @@ 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 && ( @@ -57,7 +66,9 @@ const UsageMeter: FC = ({ 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..bbb01dbbff20 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,8 +26,6 @@ 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: '', headline: '1.5M', headlineCaption: 'API calls', }) @@ -33,4 +37,16 @@ describe('UsageMeter utils', () => { expect(meterCopy(0, 2000000).headline).toBe('0%') }) }) + + describe('meterTone', () => { + it('tracks the thresholds when the comparison holds', () => { + expect(meterTone(500000, 2000000, 75)).toBe('success') + expect(meterTone(1600000, 2000000, 75)).toBe('warning') + expect(meterTone(2000000, 2000000, 75)).toBe('danger') + }) + + it('has no tone to give without a limit', () => { + expect(meterTone(9000000, null, 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..98747fe86445 100644 --- a/frontend/web/components/pages/usage/components/UsageMeter/utils.ts +++ b/frontend/web/components/pages/usage/components/UsageMeter/utils.ts @@ -1,26 +1,40 @@ 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)}`, - headline: `${usagePercent(total, limit)}%`, - headlineCaption: 'of plan consumed', -}) - -const withoutLimit = (total: number): MeterCopy => ({ - fractionCaption: 'API calls used', - fractionSuffix: '', - headline: Format.shortenNumber(total), - headlineCaption: 'API calls', -}) - export const meterCopy = (total: number, limit: PlanLimit): MeterCopy => - limit ? withLimit(total, limit) : withoutLimit(total) + limit + ? { + fraction: { + caption: 'API calls used / plan limit', + suffix: ` / ${Format.shortenNumber(limit)}`, + value: Format.shortenNumber(total), + }, + headline: `${usagePercent(total, limit)}%`, + headlineCaption: 'of plan consumed', + } + : { + headline: Format.shortenNumber(total), + headlineCaption: 'API calls', + } + +export const meterTone = ( + total: number, + limit: PlanLimit, + warnAt: number, +): UsageTone | undefined => + limit ? 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..1eb9e84636aa 100644 --- a/frontend/web/components/pages/usage/utils.ts +++ b/frontend/web/components/pages/usage/utils.ts @@ -26,6 +26,31 @@ export const resolvePeriod = ( export const isBillingPeriodSelected = (period: BillingPeriod): boolean => period === 'current_billing_period' || period === 'previous_billing_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 => period !== '90_day_period' && !projectId + +export const allowanceWindow = (planIsBilled: boolean): BillingPeriod => + planIsBilled ? 'current_billing_period' : undefined + +export const allowanceWindowLabel = (planIsBilled: boolean): string => + planIsBilled ? 'this billing period' : 'last 30 days' + export const periodLabel = ( periods: PeriodOption[], period: BillingPeriod,