Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { UsageDashboard } from 'components/pages/usage'
import { Res } from 'common/types/responses'

const meta: Meta<typeof UsageDashboard> = {
args: { meterWindow: 'this billing period' },
component: UsageDashboard,
parameters: { layout: 'fullscreen' },
title: 'Pages/Usage Dashboard/Page',
Expand Down Expand Up @@ -85,6 +86,7 @@ export const FreeOnARollingWindow: Story = {
data: free,
hasBillingPeriod: false,
limit: 50000,
meterWindow: 'last 30 days',
total: free.totals.total,
},
}
Expand All @@ -96,6 +98,7 @@ export const EnterpriseWithoutABillingPeriod: Story = {
data: paid,
hasBillingPeriod: false,
limit: 50000000,
meterWindow: 'last 30 days',
total: paid.totals.total,
},
}
Expand All @@ -107,6 +110,7 @@ export const WithoutAPlanLimit: Story = {
data: paid,
hasBillingPeriod: false,
limit: null,
meterWindow: 'last 30 days',
total: paid.totals.total,
},
}
Expand Down
7 changes: 6 additions & 1 deletion frontend/documentation/components/UsageMeter.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from 'storybook'
import UsageMeter from 'components/pages/usage/components/UsageMeter'

const meta: Meta<typeof UsageMeter> = {
args: { windowLabel: 'this billing period' },
component: UsageMeter,
parameters: { layout: 'padded' },
title: 'Pages/Usage Dashboard/Components/UsageMeter',
Expand All @@ -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 = {
Expand Down
24 changes: 22 additions & 2 deletions frontend/web/components/pages/usage/UsageDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -24,6 +28,10 @@ const UsageDashboard: FC<UsageDashboardProps> = ({
isError,
isLoading,
limit,
meterNote,
meterWindow,
onRetry,
showPlanCeiling,
total,
}) => {
let content
Expand All @@ -40,16 +48,28 @@ const UsageDashboard: FC<UsageDashboardProps> = ({
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 && (
<Button onClick={onRetry} theme='secondary'>
Try again
</Button>
)
}
/>
)
} else {
content = (
<>
<UsageMeter total={total} limit={limit} />
<UsageMeter
total={total}
limit={limit}
windowLabel={meterWindow}
note={meterNote}
/>

<UsageOverTime
data={data}
limit={limit}
limit={showPlanCeiling ? limit : undefined}
isBillingPeriod={hasBillingPeriod}
/>

Expand Down
54 changes: 49 additions & 5 deletions frontend/web/components/pages/usage/UsageDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown'
import UsageDashboard from './UsageDashboard'
import {
isBillingPeriodSelected,
contributionNote,
allowanceWindow,
allowanceWindowLabel,
showsPlanCeiling,
periodLabel,
periodsFor,
PeriodSelection,
Expand All @@ -34,6 +38,7 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
data: organisation,
isError: organisationFailed,
isLoading: loadingOrganisation,
refetch: refetchOrganisation,
} = useGetOrganisationQuery(
organisationId ? { id: organisationId } : skipToken,
)
Expand All @@ -49,6 +54,8 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
data,
isError: usageFailed,
isFetching: loadingUsage,
isUninitialized: usageNotStarted,
refetch: refetchUsage,
} = useGetOrganisationUsageQuery(
organisationId && organisation
? {
Expand All @@ -57,11 +64,30 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
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,
)
Comment on lines +84 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- changed hunk ---'
git diff -- frontend/web/components/pages/usage/UsageDashboardPage.tsx

printf '%s\n' '--- page definitions and usage ---'
sed -n '1,180p' frontend/web/components/pages/usage/UsageDashboardPage.tsx
fd -i 'UsageDashboard' frontend
rg -n -C 5 'useGetSubscriptionMetadataQuery|isError=|Try again|loadingLimit|subscriptionMeta' frontend/web/components/pages/usage frontend/common frontend/components frontend/project 2>/dev/null || true

Repository: Flagsmith/flagsmith

Length of output: 17401


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- UsageDashboard contract ---'
sed -n '1,120p' frontend/web/components/pages/usage/UsageDashboard.tsx

printf '%s\n' '--- subscription metadata endpoint ---'
sed -n '1,80p' frontend/common/services/useSubscriptionMetadata.ts

printf '%s\n' '--- shared service base ---'
rg -n -C 6 'const service|createApi|fetchBaseQuery|baseQuery' frontend/common/services frontend/common | head -160

Repository: Flagsmith/flagsmith

Length of output: 17024


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- RTK Query dependency and base service ---'
rg -n '"`@reduxjs/toolkit`"|reduxjs/toolkit' package.json frontend/package.json frontend/*/package.json 2>/dev/null || true
fd -i 'service' frontend/common -t f | head -40
fd -i 'package.json' . -t f -E node_modules -E dist -E build | head -30

Repository: Flagsmith/flagsmith

Length of output: 334


Surface subscription metadata failures.

Destructure isError: limitFailed from useGetSubscriptionMetadataQuery and include it in UsageDashboard's isError prop. Otherwise, a failed request can render the dashboard without a plan limit or the Try again action.

const { data: subscriptionMeta, isLoading: loadingLimit } =
useGetSubscriptionMetadataQuery(
organisationId ? { id: organisationId } : skipToken,
)

const periods = periodsFor(planIsBilled)

Expand All @@ -81,9 +107,20 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
return (
<UsageDashboard
data={data}
total={data?.totals?.total ?? 0}
total={organisationData?.totals?.total ?? 0}
limit={subscriptionMeta?.max_api_calls}
hasBillingPeriod={isBillingPeriodSelected(billingPeriod)}
meterWindow={allowanceWindowLabel(planIsBilled)}
showPlanCeiling={showsPlanCeiling(billingPeriod, selectedProjectId)}
meterNote={
selectedProjectId && projectName
? contributionNote(
projectName,
data?.totals?.total ?? 0,
organisationData?.totals?.total ?? 0,
)
: undefined
}
breakdown={
<UsageBreakdown
{...breakdown}
Expand All @@ -93,6 +130,13 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
}
isError={organisationFailed || usageFailed}
isLoading={loadingOrganisation || loadingUsage || loadingLimit}
onRetry={() => {
refetchOrganisation()
refetchLimit()
if (!usageNotStarted) {
refetchUsage()
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
filters={
<Row className='gap-3 align-items-end'>
<div className='usage-dashboard__filter'>
Expand Down
43 changes: 43 additions & 0 deletions frontend/web/components/pages/usage/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Subscription } from 'common/types/responses'
import {
contributionNote,
isBillingPeriodSelected,
allowanceWindow,
allowanceWindowLabel,
showsPlanCeiling,
planHasBillingPeriod,
periodsFor,
resolvePeriod,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,36 +10,50 @@ const NOTIFICATION_THRESHOLDS = [WARN_AT, 100]
export type UsageMeterProps = {
total: number
limit: PlanLimit
windowLabel: string
note?: ReactNode
}

const UsageMeter: FC<UsageMeterProps> = ({ limit, note, total }) => {
const UsageMeter: FC<UsageMeterProps> = ({
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 (
<div className='p-4 mb-3 border border-default rounded-lg bg-surface-default'>
<div className='d-flex align-items-end justify-content-between gap-3 mb-4'>
<div>
<p className='fs-caption text-secondary mb-1'>Plan usage</p>
<p className='fs-caption text-secondary mb-1'>
Plan usage · {windowLabel}
</p>
<div className='d-flex align-items-end gap-2'>
<span className={`usage-meter__percent fw-bold lh-1 text-${tone}`}>
<span
className={`usage-meter__percent fw-bold lh-1 ${
tone ? `text-${tone}` : ''
}`}
>
{copy.headline}
</span>
<span className='fs-captionSmall text-secondary'>
{copy.headlineCaption}
</span>
</div>
</div>
<div className='usage-meter__fraction text-end'>
<div>
<strong>{Format.shortenNumber(total)}</strong>
{copy.fractionSuffix}
{copy.fraction && (
<div className='usage-meter__fraction text-end'>
<div>
<strong>{copy.fraction.value}</strong>
{copy.fraction.suffix}
</div>
<div className='fs-captionSmall text-secondary'>
{copy.fraction.caption}
</div>
</div>
<div className='fs-captionSmall text-secondary'>
{copy.fractionCaption}
</div>
</div>
)}
</div>

{!!limit && (
Expand All @@ -57,7 +66,9 @@ const UsageMeter: FC<UsageMeterProps> = ({ limit, note, total }) => {
/>
)}

{note}
{note && (
<p className='fs-captionSmall text-secondary mt-3 mb-0'>{note}</p>
)}
</div>
)
}
Expand Down
Loading
Loading