diff --git a/api/app_analytics/analytics_db_service.py b/api/app_analytics/analytics_db_service.py index dd5f336daf1a..7a8a4d2a4836 100644 --- a/api/app_analytics/analytics_db_service.py +++ b/api/app_analytics/analytics_db_service.py @@ -342,6 +342,7 @@ def _get_start_date_and_stop_date_for_subscribed_organisation( raise NotFound("No billing periods found for this organisation.") month_delta = relativedelta(now, starts_at).months + month_delta += relativedelta(now, starts_at).years * 12 date_start = relativedelta(months=month_delta) + starts_at return date_start, now diff --git a/api/tests/unit/app_analytics/test_analytics_db_service.py b/api/tests/unit/app_analytics/test_analytics_db_service.py index 50321bdcc47a..5ce4235ddee3 100644 --- a/api/tests/unit/app_analytics/test_analytics_db_service.py +++ b/api/tests/unit/app_analytics/test_analytics_db_service.py @@ -707,6 +707,39 @@ def test_get_usage_data__current_billing_period__passes_correct_date_range( ) +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_get_usage_data__current_billing_period_annual_plan__passes_correct_date_range( + mocker: MockerFixture, + settings: SettingsWrapper, + organisation: Organisation, + cache: OrganisationSubscriptionInformationCache, +) -> None: + # Given + # A billing term that started more than 12 months ago (annual plan): the + # months-only delta used to drop the years component and land a year early. + settings.USE_POSTGRES_FOR_ANALYTICS = True + cache.current_billing_term_starts_at = datetime( + 2021, 12, 30, 9, 9, 47, 325132, tzinfo=UTC + ) + cache.save() + mocked_get_usage_data_from_local_db = mocker.patch( + "app_analytics.analytics_db_service.get_usage_data_from_local_db", autospec=True + ) + + # When + get_usage_data(organisation, period=CURRENT_BILLING_PERIOD) + + # Then the current period start is this month, not a year ago. + mocked_get_usage_data_from_local_db.assert_called_once_with( + organisation=organisation, + environment_id=None, + project_id=None, + date_start=datetime(2022, 12, 30, 9, 9, 47, 325132, tzinfo=UTC), + date_stop=datetime(2023, 1, 19, 9, 9, 47, 325132, tzinfo=UTC), + labels_filter=None, + ) + + @pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") def test_get_usage_data__previous_billing_period__passes_correct_date_range( mocker: MockerFixture, diff --git a/frontend/documentation/components/StatItem.stories.tsx b/frontend/documentation/components/StatItem.stories.tsx index a62e50773027..3dac25f6bfdf 100644 --- a/frontend/documentation/components/StatItem.stories.tsx +++ b/frontend/documentation/components/StatItem.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from 'storybook' import StatItem from 'components/StatItem' +import StatusBadge from 'components/experiments/StatusBadge' const meta: Meta = { component: StatItem, @@ -56,3 +57,31 @@ export const StringValue: Story = { value: 'Scale-Up', }, } + +export const WithSub: Story = { + args: { + icon: 'bar-chart', + label: 'Total API Calls', + sub: 'of 2M plan limit', + value: 1240000, + }, +} + +export const WithBadge: Story = { + args: { + badge: , + icon: 'flask', + label: 'Experiment', + sub: 'started 12 days ago', + value: 'Checkout v2', + }, +} + +// The icon is optional: dense rows of figures often read better without one. +export const WithoutIcon: Story = { + args: { + label: '% of plan consumed', + sub: 'this billing period', + value: '62%', + }, +} diff --git a/frontend/web/components/StatItem.scss b/frontend/web/components/StatItem.scss new file mode 100644 index 000000000000..bfe3eda66ac0 --- /dev/null +++ b/frontend/web/components/StatItem.scss @@ -0,0 +1,56 @@ +.stat-item { + flex: 1; + min-width: 180px; + padding: 16px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + + &__head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + } + + &__icon { + flex-shrink: 0; + } + + &__label { + font-size: 12px; + color: var(--color-text-secondary); + } + + // Pushes the badge to the right of the label, whatever the label's length. + &__badge { + margin-left: auto; + } + + &__value { + font-weight: var(--font-weight-bold); + line-height: 1.1; + // Long text values (emails, ids) wrap instead of pushing the card wide. + overflow-wrap: anywhere; + + &--default { + font-size: 28px; + } + + &--sm { + font-size: 16px; + } + } + + &__limit { + font-size: 12px; + font-weight: var(--font-weight-regular); + color: var(--color-text-secondary); + } + + &__sub { + margin-top: 2px; + font-size: 11px; + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/StatItem.tsx b/frontend/web/components/StatItem.tsx index d46d4837544a..e3763ee07e6a 100644 --- a/frontend/web/components/StatItem.tsx +++ b/frontend/web/components/StatItem.tsx @@ -1,7 +1,8 @@ -import React, { FC, KeyboardEvent } from 'react' +import React, { FC, KeyboardEvent, ReactNode } from 'react' import { colorIconDefault } from 'common/theme/tokens' import Icon, { IconName } from './icons/Icon' import Tooltip from './Tooltip' +import './StatItem.scss' type VisibilityToggleProps = { colour: string @@ -10,9 +11,15 @@ type VisibilityToggleProps = { } export type StatItemProps = { - icon: IconName label: string value: string | number + /** Qualifier under the value, e.g. "of 2M plan limit". */ + sub?: ReactNode + /** State on the right of the label, e.g. a status badge. */ + badge?: ReactNode + icon?: IconName + /** 'sm' for text values like emails, which overflow at the default size. */ + size?: 'default' | 'sm' // Optional: for displaying limits (e.g., "1,000 / 10,000") limit?: number | null // Optional: hover tooltip on the label @@ -22,9 +29,12 @@ export type StatItemProps = { } const StatItem: FC = ({ + badge, icon, label, limit, + size = 'default', + sub, tooltip, value, visibilityToggle, @@ -40,45 +50,49 @@ const StatItem: FC = ({ } return ( -
-
- -
-
-

+

+
+ {icon && ( + + )} + {tooltip ? {tooltip} : label} -

-

- {formattedValue} - {limit !== null && limit !== undefined && ( - - {' '} - / {formatNumber(limit)} - - )} -

- {visibilityToggle && ( +
+ {badge && {badge}} +
+
+ {formattedValue} + {limit !== null && limit !== undefined && ( + / {formatNumber(limit)} + )} +
+ {sub &&
{sub}
} + {visibilityToggle && ( +
-
- {visibilityToggle.isVisible && ( - - )} -
- Visible + {visibilityToggle.isVisible && ( + + )}
- )} -
+ Visible +
+ )}
) } diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/GraceChip.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/GraceChip.tsx new file mode 100644 index 000000000000..ad9dc7a65df6 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/GraceChip.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react' +import Tooltip from 'components/Tooltip' +import UsageBadge, { BadgeTone } from './UsageBadge' +import { GraceState } from './types' + +type GraceChipProps = { + grace: GraceState + daysLeft?: number +} + +const TONE: Record = { + available: 'success', + countdown: 'warning', + covering: 'info', + restricted: 'danger', + used: 'danger', +} + +const LABEL: Record = { + available: 'Grace period: available', + countdown: 'Grace period: ending', + covering: 'Grace period: covering this period', + restricted: 'Restricted', + used: 'Grace period: used', +} + +const EXPLANATION: Record = { + available: + 'Your first month over the limit is covered. We never cut off your API without warning.', + countdown: + 'You are over your limit. Flag serving pauses when the grace window ends, unless usage drops back under.', + covering: + 'You are over your limit, but this month is covered by your grace period, so there is no overage charge.', + restricted: + 'The grace window has passed. Flag serving and admin access are paused, but this page stays readable.', + used: 'Your grace period has already been used, so usage above the limit is charged as overage.', +} + +/** PROTOTYPE (#8184). Grace period status, per the "Grace period states" design. */ +const GraceChip: FC = ({ daysLeft, grace }) => { + const label = + grace === 'countdown' && daysLeft ? `${daysLeft} days left` : LABEL[grace] + + return ( + {label}}> + {EXPLANATION[grace]} + + ) +} + +export default GraceChip diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBadge.scss b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBadge.scss new file mode 100644 index 000000000000..75f32e25cec7 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBadge.scss @@ -0,0 +1,62 @@ +// Mirrors experiments/StatusBadge so the two read as one pattern. +.usage-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: var(--font-weight-medium); + white-space: nowrap; + + &__dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + } + + &--success { + background: var(--color-surface-success); + color: var(--color-text-success); + + .usage-badge__dot { + background: var(--color-text-success); + } + } + + &--warning { + background: var(--color-surface-warning); + color: var(--color-text-warning); + + .usage-badge__dot { + background: var(--color-text-warning); + } + } + + &--danger { + background: var(--color-surface-danger); + color: var(--color-text-danger); + + .usage-badge__dot { + background: var(--color-text-danger); + } + } + + &--info { + background: var(--color-surface-info); + color: var(--color-text-info); + + .usage-badge__dot { + background: var(--color-text-info); + } + } + + &--neutral { + background: var(--color-surface-muted); + color: var(--color-text-secondary); + + .usage-badge__dot { + background: var(--color-text-secondary); + } + } +} diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBadge.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBadge.tsx new file mode 100644 index 000000000000..82345c02e030 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBadge.tsx @@ -0,0 +1,31 @@ +import { FC, ReactNode } from 'react' +import './UsageBadge.scss' + +export type BadgeTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral' + +type UsageBadgeProps = { + tone: BadgeTone + children: ReactNode + /** Off for value-like badges, e.g. "Estimate". */ + withDot?: boolean +} + +/** + * PROTOTYPE (#8184). The dot-and-label badge from `experiments/StatusBadge`, + * with a tone instead of an experiment status. + * + * If this shape is becoming the standard, the production move is to generalise + * that component rather than keep two, which belongs with #8185. + */ +const UsageBadge: FC = ({ + children, + tone, + withDot = true, +}) => ( + + {withDot && } + {children} + +) + +export default UsageBadge diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBanner.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBanner.tsx new file mode 100644 index 000000000000..19ae9091c0a5 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBanner.tsx @@ -0,0 +1,80 @@ +import { FC } from 'react' +import Constants from 'common/constants' +import { Button } from 'components/base/forms/Button' +import { UsageView } from './types' + +type UsageBannerProps = { + view: UsageView +} + +type Banner = { + tone: 'warning' | 'danger' + title: string + body: string + action?: string +} + +const bannerFor = (view: UsageView): Banner | null => { + if (view.restricted) { + return { + action: 'Upgrade plan', + body: 'Flag serving and admin access are paused until usage drops below your limit or you upgrade. This page stays available so you can see what happened.', + title: 'Your organisation is restricted', + tone: 'danger', + } + } + if (view.grace === 'countdown') { + return { + action: 'Upgrade plan', + body: `You are over your plan limit. Flag serving pauses in ${ + view.graceDaysLeft ?? 0 + } days unless usage drops back under the limit.`, + title: 'Your organisation is over its plan limit', + tone: 'warning', + } + } + if (view.grace === 'covering') { + return { + body: 'This month is covered by your grace period, so there is no overage charge. A later month over the limit will be charged.', + title: 'Your organisation is over its plan limit', + tone: 'warning', + } + } + if (view.grace === 'used') { + return { + action: 'Upgrade plan', + body: 'Usage above your plan limit is charged as overage. Upgrading raises the limit and stops the charges.', + title: 'Your organisation has exceeded its plan limit', + tone: 'danger', + } + } + return null +} + +/** PROTOTYPE (#8184). The over-limit and restricted headers from S2 and S4. */ +const UsageBanner: FC = ({ view }) => { + const banner = bannerFor(view) + if (!banner) { + return null + } + + return ( +
+
+
{banner.title}
+
{banner.body}
+
+ {banner.action && ( + + )} +
+ ) +} + +export default UsageBanner diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss new file mode 100644 index 000000000000..d3fc9a494eae --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.scss @@ -0,0 +1,404 @@ +.usage-proto { + $border: var(--color-border-default); + $muted: var(--color-text-secondary); + $surface: var(--color-surface-default); + $track: var(--color-surface-muted); + + color: var(--color-text-default); + max-width: 1280px; + margin: 0 auto; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + &__title { + margin: 0; + font-size: 22px; + font-weight: 700; + } + + &__header-filters { + display: flex; + align-items: center; + gap: 8px; + } + + &__select { + min-width: 210px; + } + + &__strip { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border: 1px solid $border; + border-radius: 8px; + margin-bottom: 16px; + font-size: 13px; + } + + &__stub { + color: var(--color-text-tertiary); + font-style: italic; + font-size: 12px; + } + + &__strip-right { + display: flex; + align-items: center; + gap: 12px; + } + + + &__banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid transparent; + margin-bottom: 16px; + + &--warning { + color: var(--color-text-warning); + background: var(--color-surface-warning); + border-color: var(--color-border-warning); + } + + &--danger { + color: var(--color-text-danger); + background: var(--color-surface-danger); + border-color: var(--color-border-danger); + } + } + + &__banner-title { + font-weight: 600; + margin-bottom: 2px; + } + + &__banner-body { + font-size: 13px; + } + + &__panel { + border: 1px solid $border; + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; + background: $surface; + } + + &__panel-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + &__headline { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 28px; + } + + &__label { + font-size: 13px; + color: $muted; + margin-bottom: 4px; + } + + &__big { + display: flex; + align-items: flex-end; + gap: 8px; + } + + &__pct { + font-size: 40px; + font-weight: 700; + line-height: 1; + } + + &__sub { + font-size: 12px; + color: $muted; + } + + &__frac { + text-align: right; + font-size: 18px; + } + + &__meter { + position: relative; + padding-top: 22px; + } + + &__track { + height: 14px; + border-radius: 100px; + background: $track; + overflow: hidden; + } + + &__fill { + height: 100%; + border-radius: 100px; + + &--success { + background: var(--color-text-success); + } + + &--warning { + background: var(--color-text-warning); + } + + &--danger { + background: var(--color-text-danger); + } + } + + &__pct { + &--success { + color: var(--color-text-success); + } + + &--warning { + color: var(--color-text-warning); + } + + &--danger { + color: var(--color-text-danger); + } + } + + &__marker-label { + &--warning { + color: var(--color-text-warning); + } + + &--danger { + color: var(--color-text-danger); + } + } + + &__bar-fill { + background: var(--color-surface-action); + } + + &__marker { + position: absolute; + top: 4px; + bottom: 0; + width: 2px; + background: var(--color-border-strong); + transform: translateX(-50%); + } + + &__marker-label { + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + font-size: 11px; + font-weight: 600; + } + + // The 100% marker sits at the track's right edge, so right-align its label + // to keep it inside the panel instead of overflowing. + &__marker--end &__marker-label { + left: auto; + right: 0; + transform: none; + } + + + + + + + + &__tiles { + display: flex; + gap: 16px; + margin-bottom: 16px; + } + + + + + + &__note { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 20px; + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + + &--success { + color: var(--color-text-success); + background: var(--color-surface-success); + } + + &--warning { + color: var(--color-text-warning); + background: var(--color-surface-warning); + } + + &--danger { + color: var(--color-text-danger); + background: var(--color-surface-danger); + } + } + + &__dimension { + min-width: 190px; + } + + &__docs { + font-size: 13px; + font-weight: 600; + color: var(--color-text-action); + } + + &__breakdown { + display: flex; + flex-direction: column; + } + + &__row { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 0; + border-top: 1px solid $border; + + &:first-child { + border-top: 0; + } + } + + &__row-label { + width: 280px; + flex: 0 0 auto; + } + + &__bar-track { + flex: 1; + height: 8px; + border-radius: 100px; + background: $track; + overflow: hidden; + } + + &__bar-fill { + height: 100%; + border-radius: 100px; + } + + &__row-value { + width: 70px; + text-align: right; + font-weight: 600; + } + + &__row-pct { + width: 44px; + text-align: right; + } + + &__notify-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-top: 1px solid $border; + + &:first-of-type { + border-top: 0; + } + } + + &__notify-actions { + display: flex; + align-items: center; + gap: 12px; + } + + &__remove { + display: flex; + align-items: center; + opacity: 0.6; + + &:hover { + opacity: 1; + } + } + + &__call-detail { + max-width: 420px; + text-align: right; + } + + &__add { + background: none; + border: 0; + padding: 12px 0 0; + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-action); + } +} + +// Prototype-only chrome: picks the fixture state and the screen. Goes with the +// fixtures when the real endpoints land. +.usage-proto__switch { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + max-width: 1280px; + margin: 0 auto 16px; + padding: 8px 12px; + border: 1px dashed var(--color-border-strong); + border-radius: 8px; +} + +.usage-proto__switch-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.usage-proto__switch-btn { + background: none; + border: 1px solid var(--color-border-default); + border-radius: 100px; + padding: 4px 12px; + font-size: 12px; + color: var(--color-text-secondary); + + &--active { + color: var(--color-text-default); + background: var(--color-surface-muted); + border-color: var(--color-border-strong); + font-weight: 600; + } +} diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx new file mode 100644 index 000000000000..2bdcaff09591 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototype.tsx @@ -0,0 +1,305 @@ +import { FC, useState } from 'react' +import ProjectFilter from 'components/ProjectFilter' +import StatItem from 'components/StatItem' +import { billingPeriods, freePeriods, Req } from 'common/types/requests' +import UsageBanner from './UsageBanner' +import UsageChart from './UsageChart' +import GraceChip from './GraceChip' +import UsageNote from './UsageNote' +import UsageBadge, { BadgeTone } from './UsageBadge' +import { BREAKDOWN_DIMENSIONS, BreakdownDimension, UsageView } from './types' +import { compact, currency } from './format' +import './UsageBillingPrototype.scss' + +type UsageBillingPrototypeProps = { + view: UsageView + organisationId: number + project: string | undefined + setProject: (project: string | undefined) => void + billingPeriod: Req['getOrganisationUsage']['billing_period'] + setBillingPeriod: ( + period: Req['getOrganisationUsage']['billing_period'], + ) => void + isOnFreePlanPeriods: boolean +} + +type Tile = { + label: string + value: string + sub: string + badge?: { text: string; tone: BadgeTone; withDot?: boolean } +} + +const toneForPercent = (percent: number): 'success' | 'warning' | 'danger' => { + if (percent >= 100) return 'danger' + if (percent >= 75) return 'warning' + return 'success' +} + +const buildTiles = (view: UsageView, percent: number): Tile[] => { + const tiles: Tile[] = [ + { + badge: + percent >= 100 + ? { text: 'Over limit', tone: 'danger' } + : { + text: percent >= 75 ? 'Watch' : 'On track', + tone: percent >= 75 ? 'warning' : 'success', + }, + label: 'Total API calls', + sub: view.limit + ? `of ${compact(view.limit)} plan limit` + : 'no plan limit', + value: compact(view.total), + }, + { + label: '% of plan consumed', + sub: view.period.isBillingPeriod ? 'this billing period' : 'this period', + value: view.limit ? `${percent}%` : '—', + }, + { + label: 'Days remaining', + sub: view.period.isBillingPeriod + ? `resets ${view.period.resetsAt || '(needs the billing period)'}` + : 'in this rolling window', + value: view.period.daysRemaining ? `${view.period.daysRemaining}` : '—', + }, + ] + + if (view.restricted) { + tiles.push({ + badge: { text: 'Paused', tone: 'danger' }, + label: 'Flag serving', + sub: 'resumes on upgrade', + value: 'Paused', + }) + } else if (view.overageCost !== null) { + tiles.push({ + badge: { text: 'Estimate', tone: 'neutral', withDot: false }, + label: 'Est. overage cost', + sub: 'charged at the end of the period', + value: currency(view.overageCost), + }) + } else { + tiles.push({ + badge: { text: 'Estimate', tone: 'neutral', withDot: false }, + label: 'Projected end-of-period', + sub: view.projected ? 'at the current run rate' : 'too early to project', + value: view.projected ? compact(view.projected) : '—', + }) + } + + return tiles +} + +/** + * PROTOTYPE (#8184). The billing-aligned usage page, rendered from a view + * model that is either live data or a fixture. See `usePrototypeUsage`. + */ +const UsageBillingPrototype: FC = ({ + billingPeriod, + isOnFreePlanPeriods, + organisationId, + project, + setBillingPeriod, + setProject, + view, +}) => { + const [dimension, setDimension] = useState('request-type') + + const percent = view.limit ? Math.round((view.total / view.limit) * 100) : 0 + const tone = toneForPercent(percent) + const rows = view.breakdowns[dimension] + const maxBreakdown = Math.max(1, ...rows.map((row) => row.value)) + const breakdownTotal = rows.reduce((acc, row) => acc + row.value, 0) + + return ( +
+ + +
+

Usage

+
+
+ setDimension(v.value)} + value={BREAKDOWN_DIMENSIONS.find((d) => d.value === dimension)} + options={BREAKDOWN_DIMENSIONS} + /> +
+
+
+ {!rows.length && ( +
+ The API does not break usage down this way yet. +
+ )} + {[...rows] + .sort((a, b) => b.value - a.value) + .map((row) => ( +
+
+
{row.label}
+ {row.op &&
{row.op}
} +
+
+
+
+
+ {compact(row.value)} +
+
+ {breakdownTotal + ? Math.round((row.value / breakdownTotal) * 100) + : 0} + % +
+
+ ))} +
+
+
+ ) +} + +export default UsageBillingPrototype diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototypePage.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototypePage.tsx new file mode 100644 index 000000000000..290e687943ed --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageBillingPrototypePage.tsx @@ -0,0 +1,146 @@ +import { FC, useMemo, useState } from 'react' +import Utils, { planNames } from 'common/utils/utils' +import AccountStore from 'common/stores/account-store' +import BareButton from 'components/base/forms/BareButton' +import { Req } from 'common/types/requests' +import UsageBillingPrototype from './UsageBillingPrototype' +import UsageNotifications from './UsageNotifications' +import usePrototypeUsage from './usePrototypeUsage' +import { ScenarioId, SCENARIOS } from './fixtures' +import { UsageNotification } from './types' +import './UsageBillingPrototype.scss' + +type UsageBillingPrototypePageProps = { + organisationId: number +} + +type Tab = 'usage' | 'notifications' + +const TABS: { id: Tab; label: string }[] = [ + { id: 'usage', label: 'Usage' }, + { id: 'notifications', label: 'Notifications' }, +] + +/** + * PROTOTYPE (#8184). Wiring for the prototype: which scenario, which tab, and + * where the data comes from. Reached only when the `usage_billing_prototype` + * flag is on. + */ +const UsageBillingPrototypePage: FC = ({ + organisationId, +}) => { + const [scenario, setScenario] = useState('healthy') + const [tab, setTab] = useState('usage') + const [project, setProject] = useState() + + const currentPlan = Utils.getPlanName(AccountStore.getActiveOrgPlan()) + const orgSubscription = AccountStore.getOrganisation()?.subscription + const isOnFreePlanPeriods = + planNames.free === currentPlan || + !orgSubscription?.has_active_billing_periods + + const [billingPeriod, setBillingPeriod] = useState< + Req['getOrganisationUsage']['billing_period'] + >(isOnFreePlanPeriods ? '90_day_period' : 'current_billing_period') + + // Notifications live here rather than in the settings screen, so removing + // one takes its marker off the meter. Null until edited, so switching + // scenario shows that fixture's own notifications again. + const [editedNotifications, setEditedNotifications] = useState< + UsageNotification[] | null + >(null) + + const baseView = usePrototypeUsage({ + billingPeriod, + isOnFreePlanPeriods, + organisationId, + // ProjectFilter hands back the id as a string, the query wants the pk. + projectId: project ? Number(project) : undefined, + scenario, + }) + + const view = useMemo( + () => + editedNotifications + ? { ...baseView, notifications: editedNotifications } + : baseView, + [baseView, editedNotifications], + ) + + return ( +
+
+
+ Prototype state + {SCENARIOS.map((option) => ( + { + setScenario(option.id) + setEditedNotifications(null) + }} + aria-pressed={option.id === scenario} + className={ + option.id === scenario + ? 'usage-proto__switch-btn usage-proto__switch-btn--active' + : 'usage-proto__switch-btn' + } + > + {option.label} + + ))} +
+
+ {TABS.map((option) => ( + setTab(option.id)} + aria-pressed={tab === option.id} + className={ + tab === option.id + ? 'usage-proto__switch-btn usage-proto__switch-btn--active' + : 'usage-proto__switch-btn' + } + > + {option.label} + + ))} +
+
+ + {tab === 'usage' ? ( + + ) : ( + + )} +
+ ) +} + +export default UsageBillingPrototypePage diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageChart.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageChart.tsx new file mode 100644 index 000000000000..6dc10e6e60e2 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageChart.tsx @@ -0,0 +1,202 @@ +import { FC, useMemo } from 'react' +import moment from 'moment' +import { + Area, + CartesianGrid, + ComposedChart, + ReferenceArea, + ReferenceDot, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { + colorSurfaceAction, + colorTextDanger, + colorTextSecondary, +} from 'common/theme/tokens' +import { UsagePoint } from './types' +import { compact } from './format' + +type UsageChartProps = { + series: UsagePoint[] + limit: number | null + /** End-of-period usage at the current run rate, or null when too early. */ + projected: number | null + daysRemaining: number +} + +const ACCENT = colorSurfaceAction +const DANGER = colorTextDanger + +type Row = { + day: string + cumulative: number | null + projection: number | null +} + +/** + * Cumulative usage against the plan limit. The limit is drawn as a ceiling, + * anything above it is shaded as overage, and the run rate continues as a + * dashed line to the end of the period. + */ +const UsageChart: FC = ({ + daysRemaining, + limit, + projected, + series, +}) => { + const rows = useMemo(() => { + const actual: Row[] = series.map((point, index) => ({ + cumulative: point.cumulative, + day: point.day, + // Join the two lines at today so there is no visual gap. + projection: + index === series.length - 1 && projected ? point.cumulative : null, + })) + + if (!projected || daysRemaining <= 0 || !series.length) { + return actual + } + + const last = series[series.length - 1] + const step = (projected - last.cumulative) / daysRemaining + const future: Row[] = Array.from({ length: daysRemaining }).map( + (_, index) => ({ + cumulative: null, + day: moment(last.day) + .add(index + 1, 'days') + .format('YYYY-MM-DD'), + projection: Math.round(last.cumulative + step * (index + 1)), + }), + ) + return actual.concat(future) + }, [series, projected, daysRemaining]) + + const peak = Math.max( + limit ?? 0, + projected ?? 0, + series[series.length - 1]?.cumulative ?? 0, + ) + // Headroom above the highest value, so the topmost label has room to render + // instead of being clipped by the top of the chart. + const ceiling = peak ? Math.round(peak * 1.08) : 0 + const today = series[series.length - 1] + + return ( + + + + + + + + + + moment(day).format('D MMM')} + tick={{ dx: -4, fill: colorTextSecondary, fontSize: 11 }} + tickLine={false} + axisLine={{ stroke: colorTextSecondary }} + /> + compact(value)} + /> + moment(day).format('D MMM')} + formatter={(value: number, name: string) => [ + compact(value), + name === 'projection' ? 'Projected' : 'Cumulative', + ]} + /> + {!!limit && peak > limit && ( + + )} + + + {!!limit && ( + + )} + {!!projected && rows.length > 0 && ( + + )} + {today && ( + + )} + + + ) +} + +export default UsageChart diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNote.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNote.tsx new file mode 100644 index 000000000000..8e670be00030 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNote.tsx @@ -0,0 +1,116 @@ +import { FC } from 'react' +import Icon from 'components/icons/Icon' +import { + colorTextDanger, + colorTextSuccess, + colorTextWarning, +} from 'common/theme/tokens' +import { UsageView } from './types' +import { compact, currency } from './format' + +type UsageNoteProps = { + view: UsageView + percent: number +} + +type Note = { tone: 'success' | 'warning' | 'danger'; text: string } + +const ICON_FILL = { + danger: colorTextDanger, + success: colorTextSuccess, + warning: colorTextWarning, +} + +/** + * The one line on the page that draws a conclusion: how far over, what it + * costs, and what to do about it. Everything else states a number. + */ +const noteFor = (view: UsageView, percent: number): Note | null => { + const limit = view.limit + if (!limit) { + return null + } + const over = view.total - limit + const overPercent = Math.max(percent - 100, 0) + + if (view.restricted) { + return { + text: `Flag serving is paused. Reduce usage below ${compact( + limit, + )} calls or upgrade to restore service.`, + tone: 'danger', + } + } + + if (view.grace === 'countdown') { + return { + text: `Over your limit by ~${compact( + over, + )} calls (${overPercent}%). Flag serving pauses in ${ + view.graceDaysLeft ?? 0 + } days unless usage drops back below ${compact(limit)}.`, + tone: 'warning', + } + } + + if (view.grace === 'covering') { + return { + text: `Overage this period: ~${compact( + over, + )} calls (${overPercent}%). Covered by your grace period this month, so there is no charge.`, + tone: 'warning', + } + } + + if (view.grace === 'used') { + return { + text: `Overage this period: ~${compact(over)} calls (${overPercent}%)${ + view.overageCost ? `, estimated ${currency(view.overageCost)}` : '' + }. Reduce usage or upgrade to avoid further charges.`, + tone: 'danger', + } + } + + if (!view.projected) { + return null + } + + const projectedPercent = Math.round((view.projected / limit) * 100) + const by = view.period.resetsAt + ? ` by ${view.period.resetsAt}` + : ' by the end of this window' + + return projectedPercent >= 100 + ? { + text: `At this rate you will pass your limit${by}, reaching ~${compact( + view.projected, + )} calls (${projectedPercent}% of your limit).`, + tone: 'warning', + } + : { + text: `On track to use ~${compact( + view.projected, + )} calls (${projectedPercent}% of your limit)${by}.`, + tone: 'success', + } +} + +const UsageNote: FC = ({ percent, view }) => { + const note = noteFor(view, percent) + if (!note) { + return null + } + + return ( +
+ + {note.text} +
+ ) +} + +export default UsageNote diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx new file mode 100644 index 000000000000..03b10c2c3721 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/UsageNotifications.tsx @@ -0,0 +1,171 @@ +import { FC, useState } from 'react' +import Switch from 'components/Switch' +import BareButton from 'components/base/forms/BareButton' +import { Button } from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import { colorIconSecondary } from 'common/theme/tokens' +import { UsageNotification } from './types' + +type UsageNotificationsProps = { + notifications: UsageNotification[] + /** Lifted, so edits reach the meter markers on the usage screen. */ + onChange: (notifications: UsageNotification[]) => void + channels: { email: boolean; inApp: boolean } +} + +const describe = (percent: number): string => { + if (percent > 100) return 'You are over your plan limit' + if (percent === 100) return 'You have reached your plan limit' + return 'Early warning, while there is time to act' +} + +const BILLABLE_CALLS = [ + { + detail: 'Flags fetched for an anonymous visitor.', + label: 'Flag evaluations', + op: 'get-flags', + }, + { + detail: 'Flags fetched for a known identity, including any traits sent.', + label: 'Identity flag evaluations', + op: 'get-identity-flags', + }, + { + detail: 'Traits written against an identity.', + label: 'Trait updates', + op: 'set-identity-traits', + }, + { + detail: 'The whole environment pulled by a server-side SDK on start-up.', + label: 'Environment bootstrap', + op: 'get-environment-document', + }, +] + +/** + * PROTOTYPE (#8184). Screen S3. Local state only: the API that stores this + * does not exist yet, so nothing here is saved. + */ +const UsageNotifications: FC = ({ + channels, + notifications: rows, + onChange, +}) => { + const [inApp, setInApp] = useState(channels.inApp) + const [email, setEmail] = useState(channels.email) + + const toggleRow = (percent: number) => + onChange( + rows.map((row) => + row.percent === percent ? { ...row, enabled: !row.enabled } : row, + ), + ) + + const removeRow = (percent: number) => + onChange(rows.filter((row) => row.percent !== percent)) + + const addRow = () => { + const highest = [...rows].map((row) => row.percent).sort((a, b) => b - a)[0] + onChange( + rows.concat({ + enabled: true, + percent: Math.min((highest ?? 50) + 25, 500), + }), + ) + } + + return ( +
+

Usage notifications

+

+ Get an email or an in-app alert when you reach a percentage of your plan + limit. We never cut off your API. +

+ +
+
+ Notify me at +
+ {[...rows] + .sort((a, b) => a.percent - b.percent) + .map((row) => ( +
+
+
{row.percent}% of plan consumed
+
{describe(row.percent)}
+
+
+ toggleRow(row.percent)} + /> + removeRow(row.percent)} + > + + +
+
+ ))} + + Add notification + +
+ +
+
+ Notify me via +
+
+
+
In-app
+
Shown across the dashboard
+
+ setInApp(!inApp)} /> +
+
+
+
Email
+
+ Sent to your organisation admins +
+
+ setEmail(!email)} /> +
+
+ +
+
+ What counts as an API call? + + See docs + +
+ {BILLABLE_CALLS.map((call) => ( +
+
+
{call.label}
+
{call.op}
+
+
+ {call.detail} +
+
+ ))} +
+ +
+ +
+
+ ) +} + +export default UsageNotifications diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/fixtures.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/fixtures.ts new file mode 100644 index 000000000000..fdad051a65ab --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/fixtures.ts @@ -0,0 +1,224 @@ +import moment from 'moment' +import { + BreakdownDimension, + BreakdownRow, + UsagePoint, + UsageView, +} from './types' + +/** + * PROTOTYPE (#8184). Fake data so every designed state can be demonstrated. + * + * Delete this file when the real endpoints land: nothing outside the + * prototype folder imports it. + */ + +export type ScenarioId = + | 'live' + | 'healthy' + | 'approaching' + | 'over-covered' + | 'over-charged' + | 'free-countdown' + | 'free-restricted' + +export const SCENARIOS: { id: ScenarioId; label: string }[] = [ + { id: 'live', label: 'Live data' }, + { id: 'healthy', label: 'Healthy' }, + { id: 'approaching', label: 'Approaching limit' }, + { id: 'over-covered', label: 'Over limit, grace covering' }, + { id: 'over-charged', label: 'Over limit, charged' }, + { id: 'free-countdown', label: 'Free, grace countdown' }, + { id: 'free-restricted', label: 'Free, restricted' }, +] + +// A day's share of the period, shaped so the cumulative line has a believable +// wobble rather than a straight ramp. Indexed by day % 7, weekends lighter. +const DAY_WEIGHTS = [1.08, 1.12, 1.05, 1.1, 0.98, 0.62, 0.58] + +const buildSeries = ( + total: number, + daysElapsed: number, + periodStart: moment.Moment, +): UsagePoint[] => { + const weightTotal = Array.from({ length: daysElapsed }).reduce( + (acc: number, _, index) => acc + DAY_WEIGHTS[index % DAY_WEIGHTS.length], + 0, + ) + let running = 0 + return Array.from({ length: daysElapsed }).map((_, index) => { + running += total * (DAY_WEIGHTS[index % DAY_WEIGHTS.length] / weightTotal) + return { + cumulative: Math.round(running), + day: periodStart.clone().add(index, 'days').format('YYYY-MM-DD'), + } + }) +} + +const split = ( + total: number, + parts: { label: string; op?: string; share: number }[], +): BreakdownRow[] => + parts.map(({ label, op, share }) => ({ + label, + op, + value: Math.round(total * share), + })) + +// Obviously invented names, so nobody mistakes the demo for their own data. +const buildBreakdowns = ( + total: number, +): Record => ({ + environment: split(total, [ + { label: 'Production', share: 0.81 }, + { label: 'Staging', share: 0.13 }, + { label: 'Development', share: 0.06 }, + ]), + project: split(total, [ + { label: 'Web app', share: 0.54 }, + { label: 'Mobile', share: 0.31 }, + { label: 'Internal tools', share: 0.15 }, + ]), + 'request-type': split(total, [ + { label: 'Flag evaluations', op: 'get-flags', share: 0.63 }, + { + label: 'Identity flag evaluations', + op: 'get-identity-flags', + share: 0.24, + }, + { label: 'Trait updates', op: 'set-identity-traits', share: 0.09 }, + { + label: 'Environment bootstrap', + op: 'get-environment-document', + share: 0.04, + }, + ]), + sdk: split(total, [ + { label: 'JavaScript', op: 'flagsmith-js', share: 0.42 }, + { label: 'Python', op: 'flagsmith-python', share: 0.27 }, + { label: 'Java', op: 'flagsmith-java', share: 0.19 }, + { label: 'Go', op: 'flagsmith-go', share: 0.12 }, + ]), +}) + +type ScenarioInput = { + plan: UsageView['plan'] + limit: number + percent: number + periodDays: number + daysElapsed: number + grace: UsageView['grace'] + graceDaysLeft?: number + restricted?: boolean + overageCost?: number | null + periodLabel?: string + isBillingPeriod?: boolean +} + +const buildView = ({ + daysElapsed, + grace, + graceDaysLeft, + isBillingPeriod = true, + limit, + overageCost = null, + percent, + periodDays, + periodLabel, + plan, + restricted = false, +}: ScenarioInput): UsageView => { + const total = Math.round((limit * percent) / 100) + const periodStart = moment().subtract(daysElapsed - 1, 'days') + const resetsAt = periodStart.clone().add(periodDays, 'days') + + return { + breakdowns: buildBreakdowns(total), + channels: { email: true, inApp: true }, + grace, + graceDaysLeft, + limit, + notifications: [ + { enabled: true, percent: 75 }, + { enabled: true, percent: 100 }, + ], + overageCost, + period: { + daysRemaining: periodDays - daysElapsed, + isBillingPeriod, + label: + periodLabel ?? + `${periodStart.format('D MMM')} to ${resetsAt.format('D MMM YYYY')}`, + // Rolling windows never reset, so they get no reset date. + resetsAt: isBillingPeriod ? resetsAt.format('D MMM YYYY') : '', + + selectValue: isBillingPeriod ? 'current_billing_period' : undefined, + }, + plan, + // Run rate to the end of the period. Deliberately null early on, which is + // the rule #8188 has to settle. + projected: + daysElapsed >= 5 ? Math.round((total / daysElapsed) * periodDays) : null, + restricted, + series: buildSeries(total, daysElapsed, periodStart), + total, + } +} + +export const FIXTURES: Record, UsageView> = { + approaching: buildView({ + daysElapsed: 24, + grace: 'available', + limit: 2_000_000, + percent: 88, + periodDays: 30, + plan: 'paid', + }), + 'free-countdown': buildView({ + daysElapsed: 22, + grace: 'countdown', + graceDaysLeft: 4, + isBillingPeriod: false, + limit: 50_000, + percent: 112, + periodDays: 30, + periodLabel: 'Last 30 days', + plan: 'free', + }), + 'free-restricted': buildView({ + daysElapsed: 27, + grace: 'restricted', + isBillingPeriod: false, + limit: 50_000, + percent: 137, + periodDays: 30, + periodLabel: 'Last 30 days', + plan: 'free', + restricted: true, + }), + healthy: buildView({ + daysElapsed: 17, + grace: 'available', + limit: 2_000_000, + percent: 62, + periodDays: 30, + plan: 'paid', + }), + 'over-charged': buildView({ + daysElapsed: 21, + grace: 'used', + limit: 2_000_000, + overageCost: 1340, + percent: 128, + periodDays: 30, + plan: 'paid', + }), + 'over-covered': buildView({ + daysElapsed: 19, + grace: 'covering', + limit: 2_000_000, + percent: 118, + periodDays: 30, + plan: 'paid', + }), +} diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/format.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/format.ts new file mode 100644 index 000000000000..602b653d6471 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/format.ts @@ -0,0 +1,11 @@ +import Format from 'common/utils/format' + +/** + * PROTOTYPE (#8184). `Format.shortenNumber` does the formatting, this only + * guards zero: it takes log10 of the value, so 0 comes back as NaN. + */ +export const compact = (n: number): string => + n ? Format.shortenNumber(n) : '0' + +export const currency = (amount: number): string => + `$${amount.toLocaleString()}` diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/index.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/index.ts new file mode 100644 index 000000000000..fd1daaafd44a --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/index.ts @@ -0,0 +1 @@ +export { default } from './UsageBillingPrototypePage' diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/types.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/types.ts new file mode 100644 index 000000000000..21e61b225853 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/types.ts @@ -0,0 +1,86 @@ +import { Req } from 'common/types/requests' + +/** + * PROTOTYPE (#8184). The view model the usage page renders. + * + * It is deliberately shaped like the response we want the API to return, so + * that swapping fixtures for real endpoints is a change of source rather than + * a rewrite of the page. See `usePrototypeUsage`. + */ + +export type PlanKind = 'free' | 'paid' + +/** + * Grace period, as designed in "Grace period states". Paid orgs get one + * billing month up to 200%; free orgs get 7 days after crossing 100%. + */ +export type GraceState = + | 'available' // under the limit, grace intact + | 'covering' // paid, first month at 100-200%, not charged + | 'used' // paid, later month over 100% or any month at 200%+, charged + | 'countdown' // free, over 100%, inside the 7-day window + | 'restricted' // free, window elapsed, access stopped + +export type UsagePeriod = { + label: string + /** Human date, e.g. "9 Aug 2026". Empty on rolling windows, which never reset. */ + resetsAt: string + daysRemaining: number + /** False for the rolling windows (last 30 / 90 days). */ + isBillingPeriod: boolean + /** Keeps the period selector honest about what is on screen. */ + selectValue: Req['getOrganisationUsage']['billing_period'] +} + +export type UsagePoint = { + day: string + cumulative: number +} + +export type BreakdownDimension = + | 'request-type' + | 'project' + | 'environment' + | 'sdk' + +export const BREAKDOWN_DIMENSIONS: { + value: BreakdownDimension + label: string +}[] = [ + { label: 'By request type', value: 'request-type' }, + { label: 'By project', value: 'project' }, + { label: 'By environment', value: 'environment' }, + { label: 'By SDK', value: 'sdk' }, +] + +export type BreakdownRow = { + label: string + /** Canonical operation name, shared with the "what counts" docs. */ + op?: string + value: number +} + +export type UsageNotification = { + percent: number + enabled: boolean +} + +export type UsageView = { + plan: PlanKind + period: UsagePeriod + total: number + limit: number | null + series: UsagePoint[] + breakdowns: Record + grace: GraceState + /** Only set while `grace` is 'countdown'. */ + graceDaysLeft?: number + /** Free orgs past the grace window: flag serving and admin access stopped. */ + restricted: boolean + /** End-of-period usage at the current run rate. Null when it is too early to say. */ + projected: number | null + /** Overage in currency. Null until per-org pricing is queryable. */ + overageCost: number | null + notifications: UsageNotification[] + channels: { email: boolean; inApp: boolean } +} diff --git a/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/usePrototypeUsage.ts b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/usePrototypeUsage.ts new file mode 100644 index 000000000000..ed5edc0dd4a7 --- /dev/null +++ b/frontend/web/components/organisation-settings/usage/UsageBillingPrototype/usePrototypeUsage.ts @@ -0,0 +1,151 @@ +import { useMemo } from 'react' +import moment from 'moment' +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { useGetOrganisationUsageQuery } from 'common/services/useOrganisationUsage' +import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' +import { FIXTURES, ScenarioId } from './fixtures' +import { UsageView } from './types' + +/** + * PROTOTYPE (#8184). The one place the page gets its data. + * + * Either a fixture or the live endpoints, mapped to the same view model. When + * the real endpoints land, the fixture branch goes and the live branch keeps + * its shape, so the page itself does not change. + */ + +type Params = { + scenario: ScenarioId + organisationId: number + billingPeriod: Req['getOrganisationUsage']['billing_period'] + projectId?: number + isOnFreePlanPeriods: boolean +} + +const buildLiveView = ( + usage: Res['organisationUsage'] | undefined, + breakdownUsage: Res['organisationUsage'] | undefined, + limit: number | null, + isOnFreePlanPeriods: boolean, +): UsageView => { + const events = [...(usage?.events_list ?? [])].sort((a, b) => + a.day < b.day ? -1 : 1, + ) + let running = 0 + const series = events.map((event) => { + running += + (event.flags ?? 0) + + (event.identities ?? 0) + + (event.traits ?? 0) + + (event.environment_document ?? 0) + return { cumulative: running, day: event.day } + }) + + const totals = (breakdownUsage ?? usage)?.totals + + return { + breakdowns: { + // The API only breaks usage down by request type today. The other + // dimensions need work that is not raised yet, so they stay empty. + environment: [], + project: [], + 'request-type': [ + { + label: 'Flag evaluations', + op: 'get-flags', + value: totals?.flags ?? 0, + }, + { + label: 'Identity flag evaluations', + op: 'get-identity-flags', + value: totals?.identities ?? 0, + }, + { + label: 'Trait updates', + op: 'set-identity-traits', + value: totals?.traits ?? 0, + }, + { + label: 'Environment bootstrap', + op: 'get-environment-document', + value: totals?.environmentDocument ?? 0, + }, + ], + sdk: [], + }, + channels: { email: true, inApp: true }, + // Grace state is not serialised by the API yet (see the epic), so live + // data can only ever show the neutral case. + grace: 'available', + limit, + notifications: [ + { enabled: true, percent: 75 }, + { enabled: true, percent: 100 }, + ], + // Needs per-org pricing, which is not queryable yet. + overageCost: null, + period: { + daysRemaining: 0, + isBillingPeriod: !isOnFreePlanPeriods, + // The reset date needs the billing term boundaries, which the API does + // not return with usage data yet. + label: events.length + ? `${moment(events[0].day).format('D MMM')} to ${moment( + events[events.length - 1].day, + ).format('D MMM YYYY')}` + : 'No usage recorded', + resetsAt: '', + selectValue: isOnFreePlanPeriods ? undefined : 'current_billing_period', + }, + plan: isOnFreePlanPeriods ? 'free' : 'paid', + projected: null, + restricted: false, + series, + total: usage?.totals?.total ?? 0, + } +} + +const usePrototypeUsage = ({ + billingPeriod, + isOnFreePlanPeriods, + organisationId, + projectId, + scenario, +}: Params): UsageView => { + const isLive = scenario === 'live' + + const { data: orgUsage } = useGetOrganisationUsageQuery( + { billing_period: billingPeriod, organisationId }, + { skip: !organisationId || !isLive }, + ) + const { data: filteredUsage } = useGetOrganisationUsageQuery( + { billing_period: billingPeriod, organisationId, projectId }, + { skip: !organisationId || !isLive }, + ) + const { data: subscriptionMeta } = useGetSubscriptionMetadataQuery( + { id: organisationId }, + { skip: !organisationId || !isLive }, + ) + + return useMemo(() => { + if (!isLive) { + return FIXTURES[scenario] + } + return buildLiveView( + orgUsage, + filteredUsage, + subscriptionMeta?.max_api_calls ?? null, + isOnFreePlanPeriods, + ) + }, [ + isLive, + scenario, + orgUsage, + filteredUsage, + subscriptionMeta, + isOnFreePlanPeriods, + ]) +} + +export default usePrototypeUsage diff --git a/frontend/web/components/pages/OrganisationUsagePage.tsx b/frontend/web/components/pages/OrganisationUsagePage.tsx index 9e3df27809f4..1f035dc117db 100644 --- a/frontend/web/components/pages/OrganisationUsagePage.tsx +++ b/frontend/web/components/pages/OrganisationUsagePage.tsx @@ -1,188 +1,19 @@ -import { FC, useCallback, useEffect, useMemo, useState } from 'react' -import cn from 'classnames' -import OrganisationUsage from 'components/organisation-settings/usage/OrganisationUsage.container' +import { FC } from 'react' import ConfigProvider from 'common/providers/ConfigProvider' -import { useLocation } from 'react-router-dom' -import OrganisationUsageMetrics from 'components/organisation-settings/usage/OrganisationUsageMetrics.container' -import OrganisationUsageSideBar from 'components/organisation-settings/usage/components/OrganisationUsageSideBar' import { useRouteContext } from 'components/providers/RouteContext' -import { AggregateUsageDataItem } from 'common/types/responses' -import Utils from 'common/utils/utils' -import AccountStore from 'common/stores/account-store' -import { planNames } from 'common/utils/utils' -import { Req } from 'common/types/requests' -import { useGetOrganisationUsageQuery } from 'common/services/useOrganisationUsage' -import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' -import UsageChartFilters from 'components/organisation-settings/usage/components/UsageChartFilters' -import UsageChartTotals from 'components/organisation-settings/usage/components/UsageChartTotals' - +import UsageBillingPrototypePage from 'components/organisation-settings/usage/UsageBillingPrototype' + +/** + * PROTOTYPE BRANCH ONLY (#8184). Not for merge. + * + * The usage page is replaced by the billing-aligned prototype, so it can be + * seen by checking this branch out with nothing to set up. The real page is + * untouched on main. + */ const OrganisationUsagePage: FC = () => { - const isSdkViewEnabled = Utils.getFlagsmithHasFeature('sdk_usage_charts') - const { organisationId } = useRouteContext() - const location = useLocation() - - const getInitialView = useCallback((): 'global' | 'user-agents' => { - if (!isSdkViewEnabled) { - return 'global' - } - const params = new URLSearchParams(location.search) - return params.get('p') === 'user-agents' ? 'user-agents' : 'global' - }, [isSdkViewEnabled, location.search]) - - const [chartsView, setChartsView] = useState<'global' | 'user-agents'>( - getInitialView(), - ) - const [project, setProject] = useState() - const [environment, setEnvironment] = useState() - const [selection, setSelection] = useState([ - 'Flags', - 'Identities', - 'Environment Document', - 'Traits', - ]) - - const colours = ['#0AADDF', '#27AB95', '#FF9F43', '#EF4D56'] - const currentPlan = Utils.getPlanName(AccountStore.getActiveOrgPlan()) - const orgSubscription = AccountStore.getOrganisation()?.subscription - const isOnFreePlanPeriods = - planNames.free === currentPlan || - !orgSubscription?.has_active_billing_periods - - const [billingPeriod, setBillingPeriod] = useState< - Req['getOrganisationUsage']['billing_period'] - >(isOnFreePlanPeriods ? '90_day_period' : 'current_billing_period') - - const { data, isError } = useGetOrganisationUsageQuery( - { - billing_period: billingPeriod, - environmentId: environment, - organisationId: organisationId || 0, - projectId: project, - }, - { skip: !organisationId }, - ) - - const { data: subscriptionMeta } = useGetSubscriptionMetadataQuery( - { id: organisationId || 0 }, - { skip: !organisationId }, - ) - - // Aggregate usage events by date, summing metrics across all client types - const chartData = useMemo(() => { - const consolidated = Object.values( - data?.events_list?.reduce((acc, event) => { - const date = event.day - if (!acc[date]) { - acc[date] = { - day: date, - environment_document: 0, - flags: 0, - identities: 0, - traits: 0, - } - } - - acc[date].flags = (acc[date].flags ?? 0) + (event.flags ?? 0) - acc[date].identities = - (acc[date].identities ?? 0) + (event.identities ?? 0) - acc[date].traits = (acc[date].traits ?? 0) + (event.traits ?? 0) - acc[date].environment_document = - (acc[date].environment_document ?? 0) + - (event.environment_document ?? 0) - - return acc - }, {} as Record) || {}, - ) - - return consolidated.map((v) => ({ - ...v, - environment_document: selection.includes('Environment Document') - ? v.environment_document - : null, - flags: selection.includes('Flags') ? v.flags : null, - identities: selection.includes('Identities') ? v.identities : null, - traits: selection.includes('Traits') ? v.traits : null, - })) - }, [data?.events_list, selection]) - - useEffect(() => { - if (!isSdkViewEnabled) { - return setChartsView('global') - } - - const currentView = getInitialView() - if (currentView !== chartsView) { - setChartsView(currentView) - } - }, [location.search, chartsView, getInitialView, isSdkViewEnabled]) - - const updateSelection = (key: string) => { - if (selection.includes(key)) { - setSelection(selection.filter((v) => v !== key)) - } else { - setSelection(selection.concat([key])) - } - } - return ( -
- - {isSdkViewEnabled && ( -
- {organisationId && ( - - )} -
- )} -
- - - {chartsView === 'user-agents' ? ( - - ) : ( - - )} -
-
-
- ) + return } export default ConfigProvider(OrganisationUsagePage) diff --git a/frontend/web/components/pages/admin-dashboard/components/InstanceMetricsCards.tsx b/frontend/web/components/pages/admin-dashboard/components/InstanceMetricsCards.tsx index f360fc204374..da84bc60e7b1 100644 --- a/frontend/web/components/pages/admin-dashboard/components/InstanceMetricsCards.tsx +++ b/frontend/web/components/pages/admin-dashboard/components/InstanceMetricsCards.tsx @@ -25,56 +25,36 @@ const InstanceMetricsCards: FC = ({ className='d-flex flex-row justify-content-between mb-4' style={{ gap: '24px' }} > -
- - - {summary.active_organisations} active in last {days} days - -
- -
- - - Across {summary.total_projects} projects - -
- -
- - - {summary.active_users} active of {summary.total_users} - -
- -
- - - Across {summary.total_environments} environments - -
- -
- - - Across {summary.total_organisations} organisations - -
+ + + + +
) } diff --git a/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx index fa37ea94612b..4cd756e34f2f 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx @@ -1,6 +1,5 @@ import React from 'react' import { Organisation } from 'common/types/responses' -import Icon from 'components/icons/Icon' import Utils from 'common/utils/utils' import Payment from 'components/modals/payment' import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' @@ -43,74 +42,46 @@ export const BillingTab = ({ organisation }: BillingTabProps) => { Utils.getFlagsmithHasFeature('feature_versioning') && feature_history_visibility_days !== 0 - const limitItems: LimitItem[] = [ - { - icon: 'bar-chart', - label: 'API Calls', - value: formatLimit(max_api_calls), - }, - { icon: 'people', label: 'Team Seats', value: formatLimit(max_seats) }, - { icon: 'layers', label: 'Projects', value: formatLimit(max_projects) }, - showAuditLog - ? { - icon: 'list', - label: 'Audit Log', - value: formatDays(audit_log_visibility_days), - } - : undefined, - showFeatureHistory - ? { - icon: 'clock', - label: 'Feature History', - value: formatDays(feature_history_visibility_days), - } - : undefined, - ].filter((item): item is LimitItem => item !== undefined) + const limitItems: LimitItem[] = ( + [ + { + icon: 'bar-chart', + label: 'API Calls', + value: formatLimit(max_api_calls), + }, + { icon: 'people', label: 'Team Seats', value: formatLimit(max_seats) }, + { icon: 'layers', label: 'Projects', value: formatLimit(max_projects) }, + showAuditLog + ? { + icon: 'list', + label: 'Audit Log', + value: formatDays(audit_log_visibility_days), + } + : undefined, + showFeatureHistory + ? { + icon: 'clock', + label: 'Feature History', + value: formatDays(feature_history_visibility_days), + } + : undefined, + ] as (LimitItem | undefined)[] + ).filter((item): item is LimitItem => item !== undefined) return (
- -
- -
- -
- -
-
-

Your plan

-

{planName}

-
-
-
-
- -
-

- ID -

-
-
-

Organisation ID

-

{organisation.id}

-
-
-
- {!!chargebee_email && ( -
- -
- -
-
-

Management Email

-
{chargebee_email}
-
-
-
- )} -
-
+ + + + + {!!chargebee_email && ( + + )} +
{organisation.subscription?.subscription_id && (