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
2 changes: 1 addition & 1 deletion frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
5. **Linting**: ALWAYS run `npx eslint --fix <file>` on any files you modify
6. **Type Enums**: Extract inline union types to named types (e.g., `type Status = 'A' | 'B'` instead of inline)
7. **NO FETCH**: NEVER use `fetch()` directly - ALWAYS use RTK Query mutations/queries (inject endpoints into services in `common/services/`), see api-integration context
8. **Component structure**: Each new component lives in its own folder with an `index.ts` barrel - `ComponentName/ComponentName.tsx`, co-located `ComponentName.scss`, any sub-components, and an `index.ts` that re-exports the default (and public types). Import via the folder (`components/.../ComponentName`), never the inner file. Keep files focused (~100 lines as a target); split by concern, not to hit a number. Data tables/constant maps are exempt.
8. **Component structure**: A component with nothing to keep beside it is a single `ComponentName.tsx`. It gets a folder once it has a co-located `ComponentName.scss`, sub-components, tests or hooks: `ComponentName/ComponentName.tsx` plus an `index.ts` re-exporting the default (and public types). Import via `components/.../ComponentName` either way, never the inner file, so promoting a file to a folder changes no imports. Keep files focused (~100 lines as a target); split by concern, not to hit a number. Data tables/constant maps are exempt.

## Key Files
- Store: `common/store.ts`
Expand Down
139 changes: 92 additions & 47 deletions frontend/documentation/components/UsageDashboard.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { FC, useState } from 'react'
import type { Meta, StoryObj } from 'storybook'
import { UsageDashboard } from 'components/pages/usage'
import UsagePageLayout from 'components/pages/usage/components/UsagePageLayout'
import OverLimitBanner from 'components/pages/usage/components/OverLimitBanner'
import SectionHeading from 'components/pages/usage/components/SectionHeading'
import UsageBreakdown, {
useUsageBreakdown,
} from 'components/pages/usage/components/UsageBreakdown'
import UsageMeter from 'components/pages/usage/components/UsageMeter'
import UsageOverTime from 'components/pages/usage/components/UsageOverTime'
import { overLimitNote, overLimitOf } from 'components/pages/usage/overLimit'
import {
allowanceWindow,
contributionNote,
Expand Down Expand Up @@ -99,10 +104,20 @@ const UsagePage: FC<HarnessProps> = ({
scenarioFor(billingPeriod, !!empty, isFreePlan),
share * scale,
)
const allowanceTotal = toUsageResponse(
const allowance = toUsageResponse(
scenarioFor(allowanceWindow(basis), !!empty, isFreePlan),
scale,
).totals.total
)
const allowanceTotal = allowance.totals.total
const exceeded = overLimitOf(allowanceTotal, limit, allowance)

const contribution = showsContribution(
basis,
billingPeriod,
filtered ? 1 : undefined,
)
? contributionNote(project, scoped.totals.total, allowanceTotal)
: undefined

// The note needs the organisation over the period on screen, not over the
// allowance window, or a project can read as more than all of it.
Expand All @@ -114,53 +129,68 @@ const UsagePage: FC<HarnessProps> = ({
)}`

return (
<UsageDashboard
breakdown={
<UsageBreakdown
{...breakdown}
onChangeDimension={setDimension}
scope={scope}
/>
}
data={scoped}
filters={
<Row className='gap-2'>
<div style={{ minWidth: 210 }}>
<Select
aria-label='Period'
onChange={(option: PeriodOption) => setChosenPeriod(option.value)}
options={periods}
value={periods.find((option) => option.value === billingPeriod)}
/>
</div>
<div style={{ minWidth: 210 }}>
<Select
aria-label='Project'
onChange={(option: { value: string }) => setProject(option.value)}
options={PROJECTS.map((name) => ({ label: name, value: name }))}
value={{ label: project, value: project }}
/>
</div>
</Row>
}
hasBillingPeriod={isBillingPeriodSelected(billingPeriod)}
<UsagePageLayout
isError={isError}
isLoading={isLoading}
limit={limit}
meterNote={
showsContribution(basis, billingPeriod, filtered ? 1 : undefined)
? contributionNote(project, scoped.totals.total, allowanceTotal)
: undefined
}
// Nothing to refetch here; passed so FailedToLoad renders its button.
onRetry={() => {}}
periodLabel={periodLabel(periods, billingPeriod)}
planCopy={planSectionCopy(basis, limit)}
showPlanCeiling={showsPlanCeiling(
billingPeriod,
filtered ? 1 : undefined,
)}
total={allowanceTotal}
/>
>
{exceeded && <OverLimitBanner over={exceeded} basis={basis} canUpgrade />}

<SectionHeading {...planSectionCopy(basis, limit)} />

<UsageMeter
total={allowanceTotal}
limit={limit}
note={exceeded ? overLimitNote(exceeded) : contribution}
/>

<SectionHeading
title='Explore usage'
hint='Narrow the chart and the breakdown by period or project.'
action={
<Row className='gap-2'>
<div className='usage-filters__field'>
<Select
aria-label='Period'
onChange={(option: PeriodOption) =>
setChosenPeriod(option.value)
}
options={periods}
value={periods.find((option) => option.value === billingPeriod)}
/>
</div>
<div className='usage-filters__field'>
<Select
aria-label='Project'
onChange={(option: { value: string }) =>
setProject(option.value)
}
options={PROJECTS.map((name) => ({ label: name, value: name }))}
value={{ label: project, value: project }}
/>
</div>
</Row>
}
/>

<UsageOverTime
data={scoped}
limit={
showsPlanCeiling(billingPeriod, filtered ? 1 : undefined)
? limit
: undefined
}
isBillingPeriod={isBillingPeriodSelected(billingPeriod)}
periodLabel={periodLabel(periods, billingPeriod)}
/>

<UsageBreakdown
{...breakdown}
onChangeDimension={setDimension}
scope={scope}
/>
</UsagePageLayout>
)
}

Expand All @@ -183,6 +213,9 @@ export const PaidApproachingTheLimit: Story = {
args: { limit: 1400000, subscription: billed },
}

// Over the limit on a paid plan. Flags keep being served, since restriction
// only ever applies to a free plan, so the page reports the overage and says
// the charge may follow.
export const PaidOverTheLimit: Story = {
args: { limit: 900000, subscription: billed },
}
Expand All @@ -203,6 +236,18 @@ export const EnterpriseWithoutABillingPeriod: Story = {
},
}

// Also invoiced outside Chargebee, so an overage can never be billed. The
// banner reports the overage and says nothing about charges.
export const EnterpriseOverTheLimit: Story = {
args: {
limit: 1000000,
subscription: subscriptionOf({
payment_method: 'XERO',
plan: 'enterprise',
}),
},
}

// On Chargebee, but no period has arrived. Reads differently from invoiced,
// because this one may resolve itself.
export const ChargebeeWithoutAPeriodYet: Story = {
Expand Down
15 changes: 12 additions & 3 deletions frontend/web/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ import Announcement from './Announcement'
import { getBuildVersion } from 'common/services/useBuildVersion'
import AccountProvider from 'common/providers/AccountProvider'
import Nav from './navigation/Nav'
import { routes } from 'web/routes'
import 'project/darkMode'

// Blocked keeps the organisations list, to switch away, and the usage page,
// which explains the block. Read inside the function, not at module level:
// web/routes imports this file, so routes is still empty while this one runs.
const isAllowedWhileBlocked = (pathname) =>
[routes.organisations, routes['organisation-usage']].some((path) =>
matchPath(pathname, { exact: true, path, strict: false }),
)

const App = class extends Component {
static propTypes = {
children: propTypes.element.isRequired,
Expand Down Expand Up @@ -271,9 +281,8 @@ const App = class extends Component {
const environmentId = this.getEnvironmentId(this.props)

if (
AccountStore.getOrganisation() &&
AccountStore.getOrganisation().block_access_to_admin &&
pathname !== '/organisations'
AccountStore.getOrganisation()?.block_access_to_admin &&
!isAllowedWhileBlocked(pathname)
) {
return <Blocked />
}
Expand Down
106 changes: 0 additions & 106 deletions frontend/web/components/pages/usage/UsageDashboard.tsx

This file was deleted.

3 changes: 0 additions & 3 deletions frontend/web/components/pages/usage/UsageDashboardPage.scss

This file was deleted.

Loading
Loading