Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
870231d
feat(usage): derive a usage breakdown from the response
talissoncosta Aug 21, 2026
0abe57b
feat(usage): add the breakdown section and its rows
talissoncosta Aug 21, 2026
5181301
feat(usage): fetch per-project and per-environment totals
talissoncosta Aug 21, 2026
b4ad092
feat(usage): show the breakdown on the dashboard
talissoncosta Aug 21, 2026
96b2844
docs(usage): cover the breakdown states in Storybook
talissoncosta Aug 21, 2026
63fdbce
fix(usage): correct the scoped breakdown and share the bar
talissoncosta Aug 21, 2026
31795d5
refactor(shared): name the bar primitive ValueBar
talissoncosta Aug 21, 2026
5ae25b1
fix(a11y): report plan usage as a meter, not a progress bar
talissoncosta Aug 21, 2026
85238e6
refactor(usage): drop the per-row colours
talissoncosta Aug 21, 2026
0141cb5
refactor(usage): group the breakdown's sub-components
talissoncosta Aug 22, 2026
a2f4729
fix(usage): label the dimension select, and wait for the scope list
talissoncosta Aug 24, 2026
8f9c72f
chore(usage): trim comments that repeat themselves
talissoncosta Aug 24, 2026
cb27695
refactor(usage): move the breakdown in with the page it belongs to
talissoncosta Aug 24, 2026
d582724
test(usage): use the shared usage fixtures in the breakdown tests
talissoncosta Aug 24, 2026
724ef37
fix(usage): let a failed scope answer, and drop stale ones on a switch
talissoncosta Aug 24, 2026
37fc660
refactor(usage): move the breakdown's data into a hook
talissoncosta Aug 24, 2026
befc923
refactor(usage): drop the View suffix, and let the page own the fetching
talissoncosta Aug 24, 2026
a565403
feat(usage): colour the request-type rows
talissoncosta Aug 24, 2026
e102c70
chore(usage): fix a stale comment and drop three that say nothing
talissoncosta Aug 24, 2026
271dea5
chore(usage): cut the ValueBar docblock to one line
talissoncosta Aug 24, 2026
9b4e605
chore(usage): trim ScopeTotal's comments to the parts that prevent a bug
talissoncosta Aug 24, 2026
8c88cee
chore(usage): take the explanations out of the breakdown too
talissoncosta Aug 24, 2026
b39d74b
chore(usage): drop the last two page comments
talissoncosta Aug 24, 2026
36bc86e
fix(usage): the environment breakdown asked for the wrong id
talissoncosta Aug 24, 2026
85e8174
fix(usage): a scope answers when it stops fetching, not when it has a…
talissoncosta Aug 24, 2026
2eb0b66
refactor(usage): give each breakdown sub-component its own folder
talissoncosta Aug 25, 2026
3a49f8f
feat(usage): hold the project and environment dimensions back
talissoncosta Aug 25, 2026
db92b04
fix(usage): let a breakdown row survive a narrow viewport
talissoncosta Aug 25, 2026
5fd0a84
refactor(usage): stop exporting what nothing imports
talissoncosta Aug 25, 2026
9b514d4
refactor(usage): keep the sub-component barrels to what is public
talissoncosta Aug 25, 2026
3ec67c5
fix(usage): make the breakdown shares add up to 100
talissoncosta Aug 25, 2026
246293a
fix(usage): keep a tiny contributor's bar visible
talissoncosta Aug 25, 2026
c6d602a
feat(usage): say what the breakdown is showing
talissoncosta Aug 25, 2026
3f68637
fix(usage): let the first breakdown row lose its top border
talissoncosta Aug 26, 2026
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
101 changes: 101 additions & 0 deletions frontend/documentation/components/UsageBreakdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from 'storybook'
import {
colorChart1,
colorChart2,
colorChart3,
colorChart4,
} from 'common/theme/tokens'
import UsageBreakdown from 'components/pages/usage/components/UsageBreakdown'

const meta: Meta<typeof UsageBreakdown> = {
args: { onChangeDimension: () => {} },
component: UsageBreakdown,
title: 'Pages/Usage Dashboard/Components/UsageBreakdown',
}
export default meta

type Story = StoryObj<typeof UsageBreakdown>

export const ByRequestType: Story = {
args: {
dimension: 'request-type',
rows: [
{
colour: colorChart4,
key: 'environment_document',
label: 'Environment Document',
value: 5_240_000,
},
{
colour: colorChart3,
key: 'identities',
label: 'Identities',
value: 1_910_000,
},
{ colour: colorChart1, key: 'flags', label: 'Flags', value: 730_000 },
{ colour: colorChart2, key: 'traits', label: 'Traits', value: 120_000 },
],
},
}

/** One row dwarfing the rest is the common shape, so the bars must still read. */
export const OneDominantRow: Story = {
args: {
dimension: 'request-type',
rows: [
{ colour: colorChart1, key: 'flags', label: 'Flags', value: 8_900_000 },
{
colour: colorChart3,
key: 'identities',
label: 'Identities',
value: 41_000,
},
{ colour: colorChart2, key: 'traits', label: 'Traits', value: 9_000 },
],
},
}

export const BySdk: Story = {
args: {
dimension: 'sdk',
rows: [
{
key: 'flagsmith-python-3-9-1',
label: 'flagsmith-python/3.9.1',
value: 3_100_000,
},
{
key: 'flagsmith-java-7-2-0',
label: 'flagsmith-java/7.2.0',
value: 2_450_000,
},
{
key: 'flagsmith-nodejs-5-0-4',
label: 'flagsmith-nodejs/5.0.4',
value: 980_000,
},
{ key: 'unknown', label: 'Unknown', value: 210_000 },
],
},
}

/** The section states what it is showing, so a filter is never silently applied. */
export const ScopedToAProject: Story = {
args: {
dimension: 'request-type',
rows: [
{ colour: colorChart1, key: 'flags', label: 'Flags', value: 620_000 },
{
colour: colorChart3,
key: 'identities',
label: 'Identities',
value: 180_000,
},
],
scope: 'Checkout · Last 30 days',
},
}

export const NoUsageRecorded: Story = {
args: { dimension: 'request-type', rows: [] },
}
4 changes: 4 additions & 0 deletions frontend/web/components/pages/usage/UsageDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export type UsageDashboardProps = {
isError?: boolean
isLoading?: boolean
filters?: ReactNode
breakdown?: ReactNode
}

const UsageDashboard: FC<UsageDashboardProps> = ({
breakdown,
data,
filters,
hasBillingPeriod,
Expand Down Expand Up @@ -50,6 +52,8 @@ const UsageDashboard: FC<UsageDashboardProps> = ({
limit={limit}
isBillingPeriod={hasBillingPeriod}
/>

{breakdown}
</>
)
}
Expand Down
27 changes: 25 additions & 2 deletions frontend/web/components/pages/usage/UsageDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscription
import FieldLabel from 'components/base/forms/FieldLabel'
import ProjectFilter from 'components/ProjectFilter'
import { PeriodOption } from 'common/types/requests'
import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown'
import UsageDashboard from './UsageDashboard'
import {
isBillingPeriodSelected,
periodLabel,
periodsFor,
PeriodSelection,
planHasBillingPeriod,
Expand All @@ -25,6 +27,8 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
organisationId,
}) => {
const [project, setProject] = useState<string | undefined>()
const [projectName, setProjectName] = useState<string | undefined>()
const selectedProjectId = project ? Number(project) : undefined

const {
data: organisation,
Expand All @@ -50,7 +54,7 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
? {
billing_period: billingPeriod,
organisationId,
projectId: project ? Number(project) : undefined,
projectId: selectedProjectId,
}
: skipToken,
)
Expand All @@ -61,6 +65,15 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({

const periods = periodsFor(planIsBilled)

const { setDimension, ...breakdown } = useUsageBreakdown({ data })

const scope = [
selectedProjectId ? projectName : 'All projects',
periodLabel(periods, billingPeriod),
]
.filter(Boolean)
.join(' · ')

if (!organisationId) {
return null
}
Expand All @@ -71,6 +84,13 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
total={data?.totals?.total ?? 0}
limit={subscriptionMeta?.max_api_calls}
hasBillingPeriod={isBillingPeriodSelected(billingPeriod)}
breakdown={
<UsageBreakdown
{...breakdown}
onChangeDimension={setDimension}
scope={scope}
/>
}
isError={organisationFailed || usageFailed}
isLoading={loadingOrganisation || loadingUsage || loadingLimit}
filters={
Expand All @@ -90,7 +110,10 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
inputId='usage-project'
showAll
organisationId={organisationId}
onChange={setProject}
onChange={(id: string, name: string) => {
setProject(id)
setProjectName(name)
}}
value={project}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.usage-breakdown__dimension {
min-width: 210px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { FC } from 'react'
import FieldLabel from 'components/base/forms/FieldLabel'
import List from './components/List'
import { BREAKDOWN_DIMENSIONS, BreakdownDimension, BreakdownRow } from './utils'
import './UsageBreakdown.scss'

export type UsageBreakdownProps = {
dimension: BreakdownDimension
onChangeDimension: (dimension: BreakdownDimension) => void
rows: BreakdownRow[]
scope?: string
}

type DimensionOption = (typeof BREAKDOWN_DIMENSIONS)[number]

const UsageBreakdown: FC<UsageBreakdownProps> = ({
dimension,
onChangeDimension,
rows,
scope,
}) => (
<div className='p-4 mt-3 border border-default rounded-lg bg-surface-default'>
<div className='d-flex align-items-end justify-content-between gap-3 mb-3'>
<div>
<strong>Where the usage came from</strong>
{scope && <div className='fs-captionSmall text-secondary'>{scope}</div>}
</div>
<div className='usage-breakdown__dimension'>
<FieldLabel htmlFor='usage-breakdown-dimension'>
Break down by
</FieldLabel>
<Select
inputId='usage-breakdown-dimension'
onChange={(option: DimensionOption) =>
onChangeDimension(option.value)
}
value={BREAKDOWN_DIMENSIONS.find(
(option) => option.value === dimension,
)}
options={BREAKDOWN_DIMENSIONS}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</div>

<List rows={rows} />
</div>
)

export default UsageBreakdown
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { colorChart1 } from 'common/theme/tokens'
import {
usageEvent,
usageResponse,
} from 'components/pages/usage/__tests__/fixtures'
import {
byRequestType,
barPercent,
bySdk,
sharesOf,
totalOf,
} from 'components/pages/usage/components/UsageBreakdown/utils'

describe('UsageBreakdown utils', () => {
describe('totalOf', () => {
it('counts every billable type', () => {
expect(
totalOf(
usageEvent({
environment_document: 1,
flags: 10,
identities: 5,
traits: 2,
}),
),
).toBe(18)
})
})

describe('byRequestType', () => {
it('carries the colour each type has always had on the usage page', () => {
const result = byRequestType(usageResponse([usageEvent({ flags: 1 })]))

expect(result[0].colour).toBe(colorChart1)
})

it('sums each type across every day, biggest first', () => {
const result = byRequestType(
usageResponse([
usageEvent({ flags: 10, identities: 2 }),
usageEvent({ flags: 5, traits: 20 }),
]),
)

expect(
result.map(({ key, label, value }) => ({ key, label, value })),
).toEqual([
{ key: 'traits', label: 'Traits', value: 20 },
{ key: 'flags', label: 'Flags', value: 15 },
{ key: 'identities', label: 'Identities', value: 2 },
])
})

it('drops types with no usage rather than showing empty rows', () => {
const result = byRequestType(usageResponse([usageEvent({ flags: 3 })]))

expect(result.map(({ label, value }) => ({ label, value }))).toEqual([
{ label: 'Flags', value: 3 },
])
})

it('returns nothing when there is no data', () => {
expect(byRequestType(undefined)).toEqual([])
expect(byRequestType(usageResponse([]))).toEqual([])
})
})

describe('bySdk', () => {
it('groups by user agent, biggest first', () => {
const result = bySdk(
usageResponse([
usageEvent({ flags: 10, labels: { user_agent: 'python/3.1.0' } }),
usageEvent({ flags: 4, labels: { user_agent: 'java/2.0.0' } }),
usageEvent({ identities: 5, labels: { user_agent: 'python/3.1.0' } }),
]),
)

expect(result.map(({ label, value }) => ({ label, value }))).toEqual([
{ label: 'python/3.1.0', value: 15 },
{ label: 'java/2.0.0', value: 4 },
])
})

// Older events predate user-agent capture. Dropping them would make the
// rows disagree with the total on the meter above.
it('keeps unattributed usage rather than dropping it', () => {
const result = bySdk(
usageResponse([
usageEvent({ flags: 10, labels: { user_agent: null } }),
usageEvent({ flags: 4, labels: { user_agent: 'go/1.0.0' } }),
]),
)

expect(result.map(({ label, value }) => ({ label, value }))).toEqual([
{ label: 'Unknown', value: 10 },
{ label: 'go/1.0.0', value: 4 },
])
})

it('returns nothing when there is no data', () => {
expect(bySdk(undefined)).toEqual([])
})
})

describe('sharesOf', () => {
it('adds up to 100 when the split does not divide evenly', () => {
const shares = sharesOf([1, 1, 1])

expect(shares.reduce((sum, share) => sum + share, 0)).toBe(100)
expect(shares).toEqual([34, 33, 33])
})

it('gives the spare points to the largest remainders', () => {
const shares = sharesOf([5, 3, 1])

expect(shares.reduce((sum, share) => sum + share, 0)).toBe(100)
})

it('reports zero rather than NaN when nothing was used', () => {
expect(sharesOf([0, 0])).toEqual([0, 0])
})
})

describe('barPercent', () => {
it('keeps a tiny contributor visible rather than rounding it away', () => {
expect(barPercent(9_000, 8_900_000)).toBe(1)
})

it('scales to the largest row', () => {
expect(barPercent(4_450_000, 8_900_000)).toBe(50)
expect(barPercent(8_900_000, 8_900_000)).toBe(100)
})

it('draws nothing when there is nothing to draw', () => {
expect(barPercent(0, 8_900_000)).toBe(0)
expect(barPercent(10, 0)).toBe(0)
})
})
})
Loading
Loading