Merge pull request #14118 from supabase/feat/usage-v2

feat/usage v2
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-05-05 15:00:18 +08:00
commit d1b62a2a8a
23 files changed
+2096 -56

No files matched your search

@@ -0,0 +1 @@
export const USAGE_APPROACHING_THRESHOLD = 0.8
@@ -0,0 +1,275 @@
import clsx from 'clsx'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import SparkBar from 'components/ui/SparkBar'
import { useDailyStatsQuery } from 'data/analytics/daily-stats-query'
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
import {
ProjectUsageResponse,
UsageMetric,
useProjectUsageQuery,
} from 'data/usage/project-usage-query'
import dayjs from 'dayjs'
import Link from 'next/link'
import { Button, IconAlertTriangle } from 'ui'
import { USAGE_APPROACHING_THRESHOLD } from '../Billing.constants'
import UsageBarChart from './UsageBarChart'
import SectionContent from './SectionContent'
import SectionHeader from './SectionHeader'
import { USAGE_CATEGORIES } from './Usage.constants'
import { getUpgradeUrl } from './Usage.utils'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { DataPoint } from 'data/analytics/constants'
export interface ActivityProps {
projectRef: string
}
const Activity = ({ projectRef }: ActivityProps) => {
const { data: usage } = useProjectUsageQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionQuery({ projectRef })
const { current_period_start, current_period_end } = subscription?.billing ?? {}
const startDate = new Date((current_period_start ?? 0) * 1000).toISOString()
const endDate = new Date((current_period_end ?? 0) * 1000).toISOString()
const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === 'activity')
const upgradeUrl = getUpgradeUrl(projectRef, subscription)
const isFreeTier = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.FREE
const isProTier = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.PRO
const exceededLimitStyle = isFreeTier || isProTier ? 'text-red-900' : 'text-amber-900'
const { data: mauData, isLoading: isLoadingMauData } = useDailyStatsQuery({
projectRef,
attribute: 'total_auth_billing_period_mau',
interval: '1d',
startDate,
endDate,
})
const { data: mauSSOData, isLoading: isLoadingMauSSOData } = useDailyStatsQuery({
projectRef,
attribute: 'total_auth_billing_period_sso_mau',
interval: '1d',
startDate,
endDate,
})
const { data: assetTransformationsData, isLoading: isLoadingAssetTransformationsData } =
useDailyStatsQuery({
projectRef,
attribute: 'total_storage_image_render_count',
interval: '1d',
startDate,
endDate,
})
const { data: funcInvocationsData, isLoading: isLoadingFuncInvocationsData } = useDailyStatsQuery(
{
projectRef,
attribute: 'total_func_invocations',
interval: '1d',
startDate,
endDate,
}
)
const { data: realtimeMessagesData, isLoading: isLoadingRealtimeMessagesData } =
useDailyStatsQuery({
projectRef,
attribute: 'total_realtime_message_count',
interval: '1d',
startDate,
endDate,
})
const { data: realtimeConnectionsData, isLoading: isLoadingRealtimeConnectionsData } =
useDailyStatsQuery({
projectRef,
attribute: 'total_realtime_peak_connection',
interval: '1d',
startDate,
endDate,
})
const chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
} = {
monthly_active_users: {
data: mauData?.data ?? [],
margin: 18,
isLoading: isLoadingMauData,
hasNoData: mauData?.hasNoData ?? false,
},
monthly_active_sso_users: {
data: mauSSOData?.data ?? [],
margin: 20,
isLoading: isLoadingMauSSOData,
hasNoData: mauSSOData?.hasNoData ?? false,
},
storage_image_render_count: {
data: assetTransformationsData?.data ?? [],
margin: 0,
isLoading: isLoadingMauSSOData,
hasNoData: assetTransformationsData?.hasNoData ?? false,
},
func_invocations: {
data: funcInvocationsData?.data ?? [],
margin: 26,
isLoading: isLoadingFuncInvocationsData,
hasNoData: funcInvocationsData?.hasNoData ?? false,
},
realtime_message_count: {
data: realtimeMessagesData?.data ?? [],
margin: 38,
isLoading: isLoadingRealtimeMessagesData,
hasNoData: realtimeMessagesData?.hasNoData ?? false,
},
realtime_peak_connection: {
data: realtimeConnectionsData?.data ?? [],
margin: 0,
isLoading: isLoadingRealtimeConnectionsData,
hasNoData: realtimeConnectionsData?.hasNoData ?? false,
},
}
if (categoryMeta === undefined) return null
return (
<>
<SectionHeader title={categoryMeta.name} description={categoryMeta.description} />
{categoryMeta.attributes.map((attribute) => {
const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] as UsageMetric
const usageRatio =
typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0
const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.limit ?? 0)
const chartData = chartMeta[attribute.key]?.data ?? []
// [Joshen] Ideally this should come from the API imo, foresee some discrepancies
const lastZeroValue = chartData.find(
(x: any) => x.loopId > 0 && x[attribute.attribute] === 0
)
const lastKnownValue =
lastZeroValue !== undefined && !chartMeta[attribute.key]?.hasNoData
? dayjs(lastZeroValue.period_start)
.subtract(1, 'day')
.format('DD MMM YYYY, HH:mma (ZZ)')
: undefined
return (
<SectionContent
key={attribute.key}
section={attribute}
includedInPlan={usageMeta?.available_in_plan}
>
{usageMeta?.available_in_plan && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<p className="text-sm">{attribute.name} quota usage</p>
{usageRatio >= 1 ? (
<div className="flex items-center space-x-2 min-w-[115px]">
<IconAlertTriangle
size={14}
strokeWidth={2}
className={exceededLimitStyle}
/>
<p className={`text-sm ${exceededLimitStyle}`}>Exceeded limit</p>
</div>
) : usageRatio >= USAGE_APPROACHING_THRESHOLD ? (
<div className="flex items-center space-x-2 min-w-[115px]">
<IconAlertTriangle size={14} strokeWidth={2} className="text-amber-900" />
<p className="text-sm text-amber-900">Approaching limit</p>
</div>
) : null}
</div>
{isFreeTier && (
<Link href={upgradeUrl}>
<a>
<Button type="default" size="tiny">
Upgrade project
</Button>
</a>
</Link>
)}
</div>
{usageMeta.limit > 0 && (
<SparkBar
type="horizontal"
barClass={clsx(
usageRatio >= 1
? 'bg-red-900'
: usageRatio >= USAGE_APPROACHING_THRESHOLD
? 'bg-amber-900'
: 'bg-scale-1100'
)}
value={usageMeta?.usage ?? 0}
max={usageMeta?.limit ?? 0}
/>
)}
<div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-scale-1000">
Included in {subscription?.tier.name.toLowerCase()}
</p>
{usageMeta?.limit === -1 ? (
<p className="text-xs">None</p>
) : (
<p className="text-xs">{(usageMeta?.limit ?? 0).toLocaleString()}</p>
)}
</div>
<div className="flex items-center justify-between py-1">
<p className="text-xs text-scale-1000">Used</p>
<p className="text-xs">{(usageMeta?.usage ?? 0).toLocaleString()}</p>
</div>
{usageMeta.limit > 0 && (
<div className="flex items-center justify-between border-t py-1">
<p className="text-xs text-scale-1000">Extra volume used this month</p>
<p className="text-xs">
{((usageMeta?.limit ?? 0) === -1 || usageExcess < 0
? 0
: usageExcess
).toLocaleString()}
</p>
</div>
)}
</div>
</div>
<div className="space-y-1">
<p>{attribute.name} over time</p>
{attribute.chartDescription.split('\n').map((paragraph, idx) => (
<p key={`para-${idx}`} className="text-sm text-scale-1000">
{paragraph}
</p>
))}
</div>
{chartMeta[attribute.key].isLoading ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<UsageBarChart
hasQuota
name={attribute.name}
unit={attribute.unit}
attribute={attribute.attribute}
data={chartData}
yLimit={usageMeta?.limit ?? 0}
yLeftMargin={chartMeta[attribute.key].margin}
yFormatter={(value) => value.toLocaleString()}
quotaWarningType={isFreeTier || isProTier ? 'danger' : 'warning'}
/>
)}
</>
)}
</SectionContent>
)
})}
</>
)
}
export default Activity
@@ -0,0 +1,197 @@
import clsx from 'clsx'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import SparkBar from 'components/ui/SparkBar'
import { DataPoint } from 'data/analytics/constants'
import { useDailyStatsQuery } from 'data/analytics/daily-stats-query'
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
import {
ProjectUsageResponse,
UsageMetric,
useProjectUsageQuery,
} from 'data/usage/project-usage-query'
import dayjs from 'dayjs'
import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import Link from 'next/link'
import { Button, IconAlertTriangle } from 'ui'
import SectionContent from './SectionContent'
import SectionHeader from './SectionHeader'
import { USAGE_CATEGORIES } from './Usage.constants'
import { getUpgradeUrl } from './Usage.utils'
import UsageBarChart from './UsageBarChart'
export interface BandwidthProps {
projectRef: string
}
const Bandwidth = ({ projectRef }: BandwidthProps) => {
const { data: usage } = useProjectUsageQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionQuery({ projectRef })
const { current_period_start, current_period_end } = subscription?.billing ?? {}
const startDate = new Date((current_period_start ?? 0) * 1000).toISOString()
const endDate = new Date((current_period_end ?? 0) * 1000).toISOString()
const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === 'bandwidth')
const upgradeUrl = getUpgradeUrl(projectRef, subscription)
const isFreeTier = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.FREE
const isProTier = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.PRO
const exceededLimitStyle = isFreeTier || isProTier ? 'text-red-900' : 'text-amber-900'
const { data: dbEgressData, isLoading: isLoadingDbEgressData } = useDailyStatsQuery({
projectRef,
attribute: 'total_egress_modified',
interval: '1d',
startDate,
endDate,
})
const { data: storageEgressData, isLoading: isLoadingStorageEgressData } = useDailyStatsQuery({
projectRef,
attribute: 'total_storage_egress',
interval: '1d',
startDate,
endDate,
})
const chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
} = {
db_egress: {
data: dbEgressData?.data ?? [],
margin: 16,
isLoading: isLoadingDbEgressData,
hasNoData: dbEgressData?.hasNoData ?? false,
},
storage_egress: {
data: storageEgressData?.data ?? [],
margin: 14,
isLoading: isLoadingStorageEgressData,
hasNoData: storageEgressData?.hasNoData ?? false,
},
}
if (categoryMeta === undefined) return null
return (
<>
<SectionHeader title={categoryMeta.name} description={categoryMeta.description} />
{categoryMeta.attributes.map((attribute) => {
const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] as UsageMetric
const usageRatio =
typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0
const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.limit ?? 0)
const chartData = chartMeta[attribute.key]?.data ?? []
// [Joshen] Ideally this should come from the API imo, foresee some discrepancies
const lastZeroValue = chartData.find(
(x: any) => x.loopId > 0 && x[attribute.attribute] === 0
)
const lastKnownValue =
lastZeroValue !== undefined && !chartMeta[attribute.key]?.hasNoData
? dayjs(lastZeroValue.period_start)
.subtract(1, 'day')
.format('DD MMM YYYY, HH:mma (ZZ)')
: undefined
return (
<SectionContent key={attribute.key} section={attribute} lastKnownValue={lastKnownValue}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<p className="text-sm">{attribute.name} quota usage</p>
{usageRatio >= 1 ? (
<div className="flex items-center space-x-2 min-w-[115px]">
<IconAlertTriangle size={14} strokeWidth={2} className={exceededLimitStyle} />
<p className={`text-sm ${exceededLimitStyle}`}>Exceeded limit</p>
</div>
) : usageRatio >= USAGE_APPROACHING_THRESHOLD ? (
<div className="flex items-center space-x-2 min-w-[115px]">
<IconAlertTriangle size={14} strokeWidth={2} className="text-amber-900" />
<p className="text-sm text-amber-900">Approaching limit</p>
</div>
) : null}
</div>
{isFreeTier && (
<Link href={upgradeUrl}>
<a>
<Button type="default" size="tiny">
Upgrade project
</Button>
</a>
</Link>
)}
</div>
{usageMeta?.limit > 0 && (
<SparkBar
type="horizontal"
barClass={clsx(
usageRatio >= 1
? 'bg-red-900'
: usageRatio >= USAGE_APPROACHING_THRESHOLD
? 'bg-amber-900'
: 'bg-scale-1100'
)}
value={usageMeta?.usage ?? 0}
max={usageMeta?.limit ?? 0}
/>
)}
<div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-scale-1000">
Included in {subscription?.tier.name.toLowerCase()}
</p>
<p className="text-xs">
{usageMeta?.limit === 0 ? 'Unlimited' : formatBytes(usageMeta?.limit ?? 0)}
</p>
</div>
<div className="flex items-center justify-between py-1">
<p className="text-xs text-scale-1000">Used</p>
<p className="text-xs">{formatBytes(usageMeta?.usage ?? 0)}</p>
</div>
{usageMeta?.limit > 0 && (
<div className="flex items-center justify-between border-t py-1">
<p className="text-xs text-scale-1000">Extra volume used this month</p>
<p className="text-xs">
{usageExcess < 0 ? formatBytes(0) : formatBytes(usageExcess)}
</p>
</div>
)}
</div>
</div>
<div className="space-y-1">
<p>{attribute.name} over time</p>
{attribute.chartDescription.split('\n').map((paragraph, idx) => (
<p key={`para-${idx}`} className="text-sm text-scale-1000">
{paragraph}
</p>
))}
</div>
{chartMeta[attribute.key]?.isLoading ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<UsageBarChart
hasQuota={usageMeta?.limit > 0}
name={attribute.name}
unit={attribute.unit}
attribute={attribute.attribute}
data={chartData}
yLimit={usageMeta?.limit ?? 0}
yLeftMargin={chartMeta[attribute.key].margin}
yFormatter={(value) => formatBytes(value, 1, 'GB').replace(/\s/g, '')}
quotaWarningType={isFreeTier || isProTier ? 'danger' : 'warning'}
/>
)}
</SectionContent>
)
})}
</>
)
}
export default Bandwidth
@@ -0,0 +1,210 @@
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { DataPoint } from 'data/analytics/constants'
import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query'
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
import dayjs from 'dayjs'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import Link from 'next/link'
import { Alert, Button } from 'ui'
import SectionContent from './SectionContent'
import SectionHeader from './SectionHeader'
import { COMPUTE_INSTANCE_SPECS, USAGE_CATEGORIES } from './Usage.constants'
import { getUpgradeUrl } from './Usage.utils'
import UsageBarChart from './UsageBarChart'
export interface InfrastructureProps {
projectRef: string
}
// [Joshen] Need to update the IO budget chart to show burst mbps and duration next time
const Infrastructure = ({ projectRef }: InfrastructureProps) => {
const { data: subscription } = useProjectSubscriptionQuery({ projectRef })
const { current_period_start, current_period_end } = subscription?.billing ?? {}
const startDate = new Date((current_period_start ?? 0) * 1000).toISOString()
const endDate = new Date((current_period_end ?? 0) * 1000).toISOString()
const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === 'infra')
const upgradeUrl = getUpgradeUrl(projectRef, subscription)
const isFreeTier = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.FREE
const currentComputeInstance = subscription?.addons.find((addon) =>
addon.supabase_prod_id.includes('_instance_')
)
const currentComputeInstanceSpecs =
COMPUTE_INSTANCE_SPECS[currentComputeInstance?.supabase_prod_id ?? 'addon_instance_micro']
const { data: cpuUsageData, isLoading: isLoadingCpuUsageData } = useInfraMonitoringQuery({
projectRef,
attribute: 'cpu_usage',
interval: '1d',
startDate,
endDate,
})
const { data: memoryUsageData, isLoading: isLoadingMemoryUsageData } = useInfraMonitoringQuery({
projectRef,
attribute: 'ram_usage',
interval: '1d',
startDate,
endDate,
})
const { data: ioBudgetData, isLoading: isLoadingIoBudgetData } = useInfraMonitoringQuery({
projectRef,
attribute: 'disk_io_budget',
interval: '1d',
startDate,
endDate,
})
const currentDayIoBudget = Number(
ioBudgetData?.data.find((x) => x.periodStartFormatted === dayjs().format('DD MMM'))?.[
'disk_io_budget'
] ?? 100
)
const chartMeta: { [key: string]: { data: DataPoint[]; isLoading: boolean } } = {
cpu_usage: {
isLoading: isLoadingCpuUsageData,
data: cpuUsageData?.data ?? [],
},
ram_usage: {
isLoading: isLoadingMemoryUsageData,
data: memoryUsageData?.data ?? [],
},
disk_io_budget: {
isLoading: isLoadingIoBudgetData,
data: ioBudgetData?.data ?? [],
},
}
if (categoryMeta === undefined) return null
return (
<>
<SectionHeader title={categoryMeta.name} description={categoryMeta.description} />
{categoryMeta.attributes.map((attribute) => {
const chartData = chartMeta[attribute.key]?.data ?? []
// [Joshen] Ideally this should come from the API imo, foresee some discrepancies
const lastZeroValue = chartData.find(
(x: any) => x.loopId > 0 && x[attribute.attribute] === 0
)
const lastKnownValue =
lastZeroValue !== undefined
? dayjs(lastZeroValue.period_start)
.subtract(1, 'day')
.format('DD MMM YYYY, HH:mma (ZZ)')
: undefined
return (
<SectionContent key={attribute.key} section={attribute} lastKnownValue={lastKnownValue}>
{attribute.key === 'disk_io_budget' && (
<>
{currentDayIoBudget <= 0 ? (
<Alert withIcon variant="danger" title="IO Budget for today has been used up">
<p className="mb-4">
Your workload has used up all the burst IO throughput minutes during the day
and is running at the baseline performance. If you need consistent disk
performance, consider upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreeTier ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : currentDayIoBudget <= 20 ? (
<Alert withIcon variant="warning" title="IO Budget for today is running out">
<p className="mb-4">
Your workload is about to use up all the burst IO throughput minutes during
the day. Once this is completely used up, your workload will run at the
baseline performance. If you need consistent disk performance, consider
upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreeTier ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : null}
<div className="space-y-1">
<p>What is Disk IO Bandwidth?</p>
<p className="text-sm text-scale-1000">
Smaller compute instances can burst up to the maximum disk IO bandwidth for 30
minutes in a day. Beyond that, the performance reverts to the baseline disk IO
bandwidth.
</p>
</div>
<div>
<p className="text-sm mb-2">Overview</p>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-scale-1000">Current compute instance</p>
<p className="text-xs">{currentComputeInstance?.name ?? 'Micro'}</p>
</div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-scale-1000">Maximum IO Bandwidth (burst limit)</p>
<p className="text-xs">
{currentComputeInstanceSpecs.maxBandwidth.toLocaleString()} Mbps
</p>
</div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-scale-1000">Baseline IO Bandwidth</p>
<p className="text-xs">
{currentComputeInstanceSpecs.baseBandwidth.toLocaleString()} Mbps
</p>
</div>
<div className="flex items-center justify-between py-1">
<p className="text-xs text-scale-1000">Daily burst time limit</p>
<p className="text-xs">30 mins</p>
</div>
</div>
</>
)}
<div className="space-y-1">
{attribute.key === 'disk_io_budget' ? (
<p>IO Budget remaining each day</p>
) : (
<p>
Max{' '}
<span className={attribute.key === 'ram_usage' ? 'lowercase' : ''}>
{attribute.name}
</span>{' '}
usage each day
</p>
)}
{attribute.chartDescription.split('\n').map((paragraph, idx) => (
<p key={`para-${idx}`} className="text-sm text-scale-1000">
{paragraph}
</p>
))}
</div>
{chartMeta[attribute.key].isLoading ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<UsageBarChart
name={attribute.name}
unit={attribute.unit}
attribute={attribute.attribute}
data={chartData}
yFormatter={(value) => `${value}%`}
yLimit={100}
/>
)}
</SectionContent>
)
})}
</>
)
}
export default Infrastructure
@@ -0,0 +1,62 @@
import { PropsWithChildren } from 'react'
import { Badge, Button, IconExternalLink } from 'ui'
import { CategoryAttribute } from './Usage.constants'
import Link from 'next/link'
export interface SectionContent {
section: CategoryAttribute
includedInPlan?: boolean
lastKnownValue?: string
}
const SectionContent = ({
section,
includedInPlan,
lastKnownValue,
children,
}: PropsWithChildren<SectionContent>) => {
const { name, description, docsUrl } = section
return (
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-16">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 md:col-span-5">
<div className="sticky top-16 space-y-6">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<p className="text-base capitalize">{name}</p>
{includedInPlan === false && <Badge color="gray">Not included</Badge>}
</div>
{description.split('\n').map((value, idx) => (
<p key={`desc-${idx}`} className="text-sm text-scale-1000">
{value}
</p>
))}
</div>
{docsUrl !== undefined && (
<div>
<p className="text-sm text-scale-1100 mb-2">More information</p>
<Link href={docsUrl}>
<a target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
<p className="text-sm">Documentation</p>
<IconExternalLink size={16} strokeWidth={2} />
</div>
</a>
</Link>
</div>
)}
{lastKnownValue !== undefined && (
<p className="text-xs text-scale-1000">Last updated at: {lastKnownValue}</p>
)}
</div>
</div>
<div className="col-span-12 md:col-span-7 space-y-6">{children}</div>
</div>
</div>
</div>
)
}
export default SectionContent
@@ -0,0 +1,19 @@
export interface SectionHeaderProps {
title: string
description: string
}
const SectionHeader = ({ title, description }: SectionHeaderProps) => {
return (
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col gap-10 px-5 lg:px-16 2xl:px-32 py-16">
<div className="sticky top-16">
<p className="text-base">{title}</p>
<p className="text-sm text-scale-1000">{description}</p>
</div>
</div>
</div>
)
}
export default SectionHeader
@@ -0,0 +1,249 @@
import clsx from 'clsx'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import SparkBar from 'components/ui/SparkBar'
import { useDailyStatsQuery } from 'data/analytics/daily-stats-query'
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
import {
ProjectUsageResponse,
UsageMetric,
useProjectUsageQuery,
} from 'data/usage/project-usage-query'
import dayjs from 'dayjs'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import Link from 'next/link'
import { Badge, Button, IconAlertTriangle, IconExternalLink } from 'ui'
import { USAGE_APPROACHING_THRESHOLD } from '../Billing.constants'
import UsageBarChart from './UsageBarChart'
import SectionContent from './SectionContent'
import SectionHeader from './SectionHeader'
import { USAGE_CATEGORIES } from './Usage.constants'
import { generateUsageData, getUpgradeUrl } from './Usage.utils'
import { DataPoint } from 'data/analytics/constants'
export interface SizeAndCountsProps {
projectRef: string
}
const SizeAndCounts = ({ projectRef }: SizeAndCountsProps) => {
const { data: usage } = useProjectUsageQuery({ projectRef })
const { data: subscription } = useProjectSubscriptionQuery({ projectRef })
const { current_period_start, current_period_end } = subscription?.billing ?? {}
const startDate = new Date((current_period_start ?? 0) * 1000).toISOString()
const endDate = new Date((current_period_end ?? 0) * 1000).toISOString()
const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === 'sizeCount')
const upgradeUrl = getUpgradeUrl(projectRef, subscription)
const isFreeTier = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.FREE
const isProTier = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.PRO
const exceededLimitStyle = isFreeTier || isProTier ? 'text-red-900' : 'text-amber-900'
const { data: dbSizeData, isLoading: isLoadingDbSizeData } = useDailyStatsQuery({
projectRef,
attribute: 'total_db_size_bytes',
interval: '1d',
startDate,
endDate,
})
const { data: storageSizeData, isLoading: isLoadingStorageSizeData } = useDailyStatsQuery({
projectRef,
attribute: 'total_storage_size_bytes',
interval: '1d',
startDate,
endDate,
})
const { data: functionCountData, isLoading: isLoadingFunctionCountData } = useDailyStatsQuery({
projectRef,
attribute: 'total_func_count',
interval: '1d',
startDate,
endDate,
})
const chartMeta: {
[key: string]: { data: DataPoint[]; margin: number; isLoading: boolean; hasNoData: boolean }
} = {
db_size: {
isLoading: isLoadingDbSizeData,
margin: 14,
data: dbSizeData?.data ?? [],
hasNoData: dbSizeData?.hasNoData ?? false,
},
storage_size: {
isLoading: isLoadingStorageSizeData,
margin: 14,
data: storageSizeData?.data ?? [],
hasNoData: storageSizeData?.hasNoData ?? false,
},
func_count: {
isLoading: isLoadingFunctionCountData,
margin: 0,
data: functionCountData?.data ?? [],
hasNoData: functionCountData?.hasNoData ?? false,
},
}
if (categoryMeta === undefined) return null
return (
<>
<SectionHeader title={categoryMeta.name} description={categoryMeta.description} />
{categoryMeta.attributes.map((attribute) => {
const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse] as UsageMetric
const usageRatio =
typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0
const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.limit ?? 0)
const chartData = chartMeta[attribute.key]?.data ?? []
// [Joshen] Ideally this should come from the API imo, foresee some discrepancies
const lastZeroValue = chartData.find(
(x: any) => x.loopId > 0 && x[attribute.attribute] === 0
)
const lastKnownValue =
lastZeroValue !== undefined && !chartMeta[attribute.key]?.hasNoData
? dayjs(lastZeroValue.period_start)
.subtract(1, 'day')
.format('DD MMM YYYY, HH:mma (ZZ)')
: undefined
return (
<SectionContent key={attribute.key} section={attribute} lastKnownValue={lastKnownValue}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<p className="text-sm">{attribute.name} quota usage</p>
{usageRatio >= 1 ? (
<div className="flex items-center space-x-2 min-w-[115px]">
<IconAlertTriangle size={14} strokeWidth={2} className={exceededLimitStyle} />
<p className={`text-sm ${exceededLimitStyle}`}>Exceeded limit</p>
</div>
) : usageRatio >= USAGE_APPROACHING_THRESHOLD ? (
<div className="flex items-center space-x-2 min-w-[115px]">
<IconAlertTriangle size={14} strokeWidth={2} className="text-amber-900" />
<p className="text-sm text-amber-900">Approaching limit</p>
</div>
) : null}
</div>
{isFreeTier && (
<Link href={upgradeUrl}>
<a>
<Button type="default" size="tiny">
Upgrade project
</Button>
</a>
</Link>
)}
</div>
{usageMeta.limit > 0 && (
<SparkBar
type="horizontal"
barClass={clsx(
usageRatio >= 1
? 'bg-red-900'
: usageRatio >= USAGE_APPROACHING_THRESHOLD
? 'bg-amber-900'
: 'bg-scale-1100'
)}
value={usageMeta?.usage ?? 0}
max={usageMeta?.limit ?? 0}
/>
)}
<div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-scale-1000">
Included in {subscription?.tier.name.toLowerCase()}
</p>
<p className="text-xs">
{attribute.unit === 'bytes'
? formatBytes(usageMeta?.limit ?? 0)
: (usageMeta?.limit ?? 0).toLocaleString()}
</p>
</div>
<div className="flex items-center justify-between py-1">
<p className="text-xs text-scale-1000">Used</p>
<p className="text-xs">
{attribute.unit === 'bytes'
? formatBytes(usageMeta?.usage ?? 0)
: (usageMeta?.usage ?? 0).toLocaleString()}
</p>
</div>
{usageMeta.limit > 0 && (
<div className="flex items-center justify-between border-t py-1">
<p className="text-xs text-scale-1000">Extra volume used this month</p>
<p className="text-xs">
{usageExcess < 0
? attribute.unit === 'bytes'
? formatBytes(0)
: 0
: attribute.unit === 'bytes'
? formatBytes(usageExcess)
: usageExcess.toLocaleString()}
</p>
</div>
)}
</div>
</div>
{attribute.key === 'db_size' &&
subscription?.tier.supabase_prod_id !== PRICING_TIER_PRODUCT_IDS.FREE && (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<p className="text-sm">Disk size:</p>
<p className="text-sm">{usage?.disk_volume_size_gb} GB</p>
<Badge color="green" size="small">
Auto-scaling
</Badge>
</div>
<Link href="https://supabase.com/docs/guides/platform/database-usage#disk-management">
<a>
<Button size="tiny" type="default" icon={<IconExternalLink size={14} />}>
What is disk size?
</Button>
</a>
</Link>
</div>
)}
<div className="space-y-1">
<p>{attribute.name} over time</p>
{attribute.chartDescription.split('\n').map((paragraph, idx) => (
<p key={`para-${idx}`} className="text-sm text-scale-1000">
{paragraph}
</p>
))}
</div>
{chartMeta[attribute.key].isLoading ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<UsageBarChart
hasQuota
name={attribute.name}
unit={attribute.unit}
attribute={attribute.attribute}
data={chartData}
yLimit={usageMeta?.limit ?? 0}
yLeftMargin={chartMeta[attribute.key].margin}
yFormatter={(value) =>
attribute.unit === 'bytes'
? formatBytes(value, 1, 'GB').replace(/\s/g, '')
: value.toLocaleString()
}
quotaWarningType={isFreeTier || isProTier ? 'danger' : 'warning'}
/>
)}
</SectionContent>
)
})}
</>
)
}
export default SizeAndCounts
@@ -0,0 +1,230 @@
export const Y_DOMAIN_CEILING_MULTIPLIER = 4 / 3
export const USAGE_STATUS = {
NORMAL: 'NORMAL',
APPROACHING: 'APPROACHING',
EXCEEDED: 'EXCEEDED',
}
export interface CategoryAttribute {
key: string // Property from project usage
attribute: string // For querying against stats-daily / infra-monitoring
name: string
unit: 'bytes' | 'absolute' | 'percentage'
docsUrl?: string
description: string
chartDescription: string
}
export const USAGE_CATEGORIES: {
key: 'infra' | 'bandwidth' | 'sizeCount' | 'activity'
name: string
description: string
attributes: CategoryAttribute[]
}[] = [
{
key: 'infra',
name: 'Infrastructure',
description: 'Usage statistics related to your Postgres server',
attributes: [
{
key: 'cpu_usage',
attribute: 'cpu_usage',
name: 'CPU',
unit: 'percentage',
description: 'CPU usage of your server',
chartDescription: 'The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'ram_usage',
attribute: 'ram_usage',
name: 'Memory',
unit: 'percentage',
description: 'Memory usage of your server',
chartDescription: 'The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'disk_io_budget',
attribute: 'disk_io_budget',
name: 'Disk IO bandwidth',
unit: 'percentage',
docsUrl: 'https://supabase.com/docs/guides/platform/compute-add-ons#disk-io-bandwidth',
description:
'SSD Disks are attached to your servers and the disk performance of your workload is determined by the Disk IO bandwidth of this connection.',
chartDescription:
'The amount of remaining bandwidth resets at the beginning of each day, and the data shown here is refreshed over a period of 24 hours.',
},
],
},
{
key: 'bandwidth',
name: 'Bandwidth',
description: 'Amount of data transmitted over network connections',
attributes: [
{
key: 'db_egress',
attribute: 'total_egress_modified',
name: 'Database egress',
unit: 'bytes',
description: 'Contains any outgoing traffic (egress) from your database',
chartDescription:
'Billing is based on the total sum of egress in GB throughout your billing period. The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'storage_egress',
attribute: 'total_storage_egress',
name: 'Storage egress',
unit: 'bytes',
description:
'Contains any outgoing traffic (egress) from your storage buckets (only download operations are counted). We currently do not differentiate between no-cache and cache hits.',
chartDescription:
'Billing is based on the total amount of egress in GB throughout your billing period. The data shown here is refreshed over a period of 24 hours.',
},
],
},
{
key: 'sizeCount',
name: 'Size & Counts',
description: 'Amount of resources your project is consuming',
attributes: [
{
key: 'db_size',
attribute: 'total_db_size_bytes',
name: 'Database size',
unit: 'bytes',
description: "Size of your project's database",
docsUrl: 'https://supabase.com/docs/guides/platform/database-usage',
chartDescription:
'Billing is based on the average daily database size in GB throughout the billing period. The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'storage_size',
attribute: 'total_storage_size_bytes',
name: 'Storage size',
unit: 'bytes',
description: 'Sum of all objects in your storage buckets',
chartDescription:
'Billing is based on the average size in GB throughout your billing period. The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'func_count',
attribute: 'total_func_count',
name: 'Edge function count',
unit: 'absolute',
description: 'Number of serverless functions in your project',
chartDescription:
'Billing is based on the maximum amount of functions at any point in time throughout your billing period. The data shown here is refreshed over a period of 24 hours.',
},
],
},
{
key: 'activity',
name: 'Activity',
description: 'Usage statistics that reflect the activity of your project',
attributes: [
{
key: 'monthly_active_users',
attribute: 'total_auth_billing_period_mau',
name: 'Monthly active users',
unit: 'absolute',
description:
'The amount of distinct users requesting your API throughout the billing period.',
chartDescription:
'The data shown here is refreshed over a period of 24 hours and resets at the beginning of every billing period.',
},
{
key: 'monthly_active_sso_users',
attribute: 'total_auth_billing_period_sso_mau',
name: 'Monthly active single sign-on users',
unit: 'absolute',
description:
'The amount of distinct Single Sign-On users requesting your API throughout the billing period.',
chartDescription:
'The data shown here is refreshed over a period of 24 hours and resets at the beginning of every billing period.',
},
{
key: 'storage_image_render_count',
attribute: 'total_storage_image_render_count',
name: 'Storage image transformations',
unit: 'absolute',
description:
'We distinctly count all images that were transformed in the billing period, ignoring any transformations.\nIf you transform one image with different transformations, it only counts as one. We only count the unique (origin) images being transformed.',
chartDescription: 'The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'func_invocations',
attribute: 'total_func_invocations',
name: 'Edge function invocations',
unit: 'absolute',
description:
'Every single serverless function invocation independent of response status is counted.',
chartDescription:
'Billing is based on the sum of all invocations throughout your billing period. The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'realtime_message_count',
attribute: 'total_realtime_message_count',
name: 'Realtime message count',
unit: 'absolute',
description: 'Total number of realtime messages sent',
chartDescription:
'Billing is based on the total amount of messages throughout your billing period. The data shown here is refreshed over a period of 24 hours.',
},
{
key: 'realtime_peak_connection',
attribute: 'total_realtime_peak_connection',
name: 'Realtime peak connections',
unit: 'absolute',
description: 'Total number of successful connections (not connection attempts)',
chartDescription:
'Billing is based on the maximum amount of concurrent peak connections throughout your billing period.',
},
],
},
]
// [Joshen] Ideally live in common package for www as well
export const COMPUTE_INSTANCE_SPECS: {
[key: string]: { maxBandwidth: number; baseBandwidth: number }
} = {
addon_instance_micro: {
maxBandwidth: 2606,
baseBandwidth: 87,
},
addon_instance_small: {
maxBandwidth: 2606,
baseBandwidth: 174,
},
addon_instance_medium: {
maxBandwidth: 2606,
baseBandwidth: 347,
},
addon_instance_large: {
maxBandwidth: 4750,
baseBandwidth: 630,
},
addon_instance_xlarge: {
maxBandwidth: 4750,
baseBandwidth: 1188,
},
addon_instance_xxlarge: {
maxBandwidth: 4750,
baseBandwidth: 2375,
},
addon_instance_4xlarge: {
maxBandwidth: 4750,
baseBandwidth: 4750,
},
addon_instance_8xlarge: {
maxBandwidth: 9500,
baseBandwidth: 9500,
},
addon_instance_12xlarge: {
maxBandwidth: 14250,
baseBandwidth: 14250,
},
addon_instance_16xlarge: {
maxBandwidth: 19000,
baseBandwidth: 19000,
},
}
@@ -0,0 +1,176 @@
import clsx from 'clsx'
import { useParams } from 'common'
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
import { useProjectUsageQuery } from 'data/usage/project-usage-query'
import dayjs from 'dayjs'
import Link from 'next/link'
import { useRef, useState } from 'react'
import { Button, IconAlertCircle, IconCheckCircle, IconLoader, Listbox } from 'ui'
import Activity from './Activity'
import Bandwidth from './Bandwidth'
import Infrastructure from './Infrastructure'
import SizeAndCounts from './SizeAndCounts'
import { USAGE_CATEGORIES, USAGE_STATUS } from './Usage.constants'
import { getUsageStatus } from './Usage.utils'
import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query'
const Usage = () => {
const { ref } = useParams()
// [Joshen] Using a state here for now as in the future, we'd have this usage page support showing
// stats for different projects. So we'll pass the selected ref as a prop into the individual components
const [selectedProjectRef, setSelectedProjectRef] = useState<string>(ref as string)
const infrastructureRef = useRef<HTMLDivElement>(null)
const bandwidthRef = useRef<HTMLDivElement>(null)
const sizeAndCountsRef = useRef<HTMLDivElement>(null)
const activityRef = useRef<HTMLDivElement>(null)
const { data: usage } = useProjectUsageQuery({ projectRef: ref })
const { data: subscription, isLoading: isLoadingSubscription } = useProjectSubscriptionQuery({
projectRef: selectedProjectRef,
})
const { current_period_start, current_period_end } = subscription?.billing ?? {}
const startDate = new Date((current_period_start ?? 0) * 1000).toISOString()
const endDate = new Date((current_period_end ?? 0) * 1000).toISOString()
const { data: ioBudgetData } = useInfraMonitoringQuery({
projectRef: selectedProjectRef,
attribute: 'disk_io_budget',
interval: '1d',
startDate,
endDate,
})
const currentDayIoBudget = Number(
ioBudgetData?.data.find((x) => x.periodStartFormatted === dayjs().format('DD MMM'))?.[
'disk_io_budget'
] ?? 100
)
const billingCycleStart = dayjs.unix(subscription?.billing?.current_period_start ?? 0).utc()
const billingCycleEnd = dayjs.unix(subscription?.billing?.current_period_end ?? 0).utc()
const scrollTo = (id: 'infra' | 'bandwidth' | 'sizeCount' | 'activity') => {
switch (id) {
case 'infra':
if (infrastructureRef.current)
infrastructureRef.current.scrollIntoView({ behavior: 'smooth' })
break
case 'bandwidth':
if (bandwidthRef.current) bandwidthRef.current.scrollIntoView({ behavior: 'smooth' })
break
case 'sizeCount':
if (sizeAndCountsRef.current)
sizeAndCountsRef.current.scrollIntoView({ behavior: 'smooth' })
break
case 'activity':
if (activityRef.current) activityRef.current.scrollIntoView({ behavior: 'smooth' })
break
}
}
return (
<>
<div className="border-b">
<div className="1xl:px-28 mx-auto flex flex-col px-5 lg:px-16 2xl:px-32 pt-6 space-y-4">
<h3 className="text-scale-1200 text-xl">Usage</h3>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Listbox
disabled
size="small"
id="billingCycle"
name="billingCycle"
value={'current'}
className="!w-[200px]"
onChange={() => {}}
>
<Listbox.Option label="Current billing cycle" value="current">
Current billing cycle
</Listbox.Option>
<Listbox.Option label="Previous billing cycle" value="previous">
Previous billing cycle
</Listbox.Option>
</Listbox>
{isLoadingSubscription ? (
<IconLoader className="animate-spin" size={14} />
) : subscription !== undefined ? (
<div>
<p className={clsx('text-sm transition', isLoadingSubscription && 'opacity-50')}>
Project is on {subscription.tier.name}
</p>
<p className="text-sm text-scale-1000">
{billingCycleStart.format('DD MMM YY')} - {billingCycleEnd.format('DD MMM YY')}
</p>
</div>
) : null}
</div>
<div className="flex items-center space-x-2">
<Link href={`/project/${selectedProjectRef}/settings/billing/invoices`}>
<a>
<Button type="default">View invoices</Button>
</a>
</Link>
<Link href={`/project/${selectedProjectRef}/settings/billing/subscription`}>
<a>
<Button type="default">View billing</Button>
</a>
</Link>
</div>
</div>
<div className="flex items-center space-x-6 !mt-2">
{USAGE_CATEGORIES.map((category) => {
const infraStatus =
currentDayIoBudget <= 0
? USAGE_STATUS.EXCEEDED
: currentDayIoBudget <= 20
? USAGE_STATUS.APPROACHING
: USAGE_STATUS.NORMAL
const status =
category.key === 'infra' ? infraStatus : getUsageStatus(category.attributes, usage)
return (
<div
key={category.key}
onClick={() => scrollTo(category.key)}
className="flex items-center opacity-50 space-x-2 py-3 hover:opacity-100 transition cursor-pointer"
>
{status === USAGE_STATUS.NORMAL ? (
<IconCheckCircle size={15} strokeWidth={2} className="text-scale-1100" />
) : status === USAGE_STATUS.APPROACHING ? (
<IconAlertCircle size={15} strokeWidth={2} className="text-amber-900" />
) : status === USAGE_STATUS.EXCEEDED ? (
<IconAlertCircle size={15} strokeWidth={2} className="text-red-900" />
) : null}
<p className="text-sm">{category.name}</p>
</div>
)
})}
</div>
</div>
</div>
{/*
[Joshen] Could potentially run a map here based on USAGE_CATEGORIES, rather than defining each section
but thinking it's gonna "cover up" too much details and make it harder to add attribute specific components
e.g for database size, we also need to show disk volume size. Not to mention that are little nuances across
each attribute RE formatting (bytes vs locale string)
*/}
<div id="infrastructure" ref={infrastructureRef}>
<Infrastructure projectRef={selectedProjectRef} />
</div>
<div id="bandwidth" ref={bandwidthRef}>
<Bandwidth projectRef={selectedProjectRef} />
</div>
<div id="size_and_counts" ref={sizeAndCountsRef}>
<SizeAndCounts projectRef={selectedProjectRef} />
</div>
<div id="activity" ref={activityRef}>
<Activity projectRef={selectedProjectRef} />
</div>
</>
)
}
export default Usage
@@ -0,0 +1,48 @@
import { DataPoint } from 'data/analytics/constants'
import { ProjectUsageResponse } from 'data/usage/project-usage-query'
import { USAGE_APPROACHING_THRESHOLD } from '../Billing.constants'
import { CategoryAttribute, USAGE_STATUS } from './Usage.constants'
import { StripeSubscription } from 'components/interfaces/Billing'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
// [Joshen] This is just for development to generate some test data for chart rendering
export const generateUsageData = (attribute: string, days: number): DataPoint[] => {
const tempArray = new Array(days).fill(0)
return tempArray.map((x, idx) => {
return {
loopId: (idx + 1).toString(),
period_start: `${idx + 1}`,
[attribute]: Math.floor(Math.random() * 100).toString(),
}
})
}
export const getUsageStatus = (attributes: CategoryAttribute[], usage?: ProjectUsageResponse) => {
if (!usage) return USAGE_STATUS.NORMAL
const attributeStatuses = attributes.map((attribute) => {
const usageMeta = usage?.[attribute.key as keyof ProjectUsageResponse]
const usageRatio =
typeof usageMeta !== 'number' ? (usageMeta?.usage ?? 0) / (usageMeta?.limit ?? 0) : 0
if (usageRatio >= 1) return USAGE_STATUS.EXCEEDED
else if (usageRatio >= USAGE_APPROACHING_THRESHOLD) return USAGE_STATUS.APPROACHING
else return USAGE_STATUS.NORMAL
})
if (attributeStatuses.find((x) => x === USAGE_STATUS.EXCEEDED)) return USAGE_STATUS.EXCEEDED
else if (attributeStatuses.find((x) => x === USAGE_STATUS.APPROACHING))
return USAGE_STATUS.APPROACHING
else return USAGE_STATUS.NORMAL
}
export const getUpgradeUrl = (projectRef: string, subscription?: StripeSubscription) => {
if (!subscription) return `/project/${projectRef}/settings/billing/update`
return subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.ENTERPRISE
? `/project/${projectRef}/settings/billing/update/enterprise`
: subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM
? `/project/${projectRef}/settings/billing/update/team`
: subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.FREE
? `/project/${projectRef}/settings/billing/update`
: `/project/${projectRef}/settings/billing/update/pro`
}
@@ -0,0 +1,135 @@
import { DataPoint } from 'data/analytics/constants'
import {
Bar,
CartesianGrid,
Cell,
ComposedChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Y_DOMAIN_CEILING_MULTIPLIER } from './Usage.constants'
import dayjs from 'dayjs'
// [Joshen] This BarChart is specifically for usage, hence not a reusable component, and not
// replacing the existing BarChart in ui/Charts
export interface UsageBarChartProps {
data: DataPoint[]
name: string // Used within the tooltip
attribute: string
unit: 'bytes' | 'absolute' | 'percentage'
hasQuota?: boolean
yLimit?: number
yLeftMargin?: number
yFormatter?: (value: number | string) => string
quotaWarningType?: 'warning' | 'danger'
}
const UsageBarChart = ({
data,
name,
attribute,
unit,
hasQuota = false,
yLimit,
yLeftMargin = 10,
yFormatter,
quotaWarningType = 'warning',
}: UsageBarChartProps) => {
const yMin = 0 // We can consider passing this as a prop if there's a use case in the future
const yMax = (yLimit ?? 0) * Y_DOMAIN_CEILING_MULTIPLIER
const quotaWarningClass = quotaWarningType === 'warning' ? 'fill-amber-900' : 'fill-red-900'
const yDomain = [yMin, yLimit ?? 0]
const ticks =
yLimit !== undefined && hasQuota
? [yMin, (yMax - yMin) * 0.25, (yMax - yMin) * 0.5, (yMax - yMin) * 0.75, yMax].map((x) =>
Math.ceil(x)
)
: undefined
return (
<div className="w-full h-[200px]">
<ResponsiveContainer width="100%" height={200}>
<ComposedChart data={data} margin={{ top: 0, right: 0, left: yLeftMargin, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-scale-800" />
<XAxis dataKey="periodStartFormatted" />
<YAxis
width={28}
axisLine={false}
tickLine={{ stroke: 'none' }}
domain={yDomain}
ticks={ticks}
tickFormatter={yFormatter}
/>
<Tooltip
content={(props) => {
const { active, payload } = props
if (active && payload && payload.length) {
const dataPeriod = dayjs(payload[0].payload.period_start)
const value =
unit === 'percentage'
? Number(payload[0].value).toFixed(2)
: Number(payload[0].value)
return (
<div className="w-[170px] border bg-scale-300 rounded-md px-2 py-2">
<p className="text-xs text-scale-1000">
{attribute === 'disk_io_budget' ? `Remaining IO budget:` : `${name}:`}
</p>
{dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? (
<p className="text-scale-1000 text-lg">No data yet</p>
) : (
<p className="text-xl">
{yFormatter !== undefined ? yFormatter(value) : value}
</p>
)}
<p className="text-xs text-scale-1100 mt-1">
{dataPeriod.format('DD MMM YYYY')}
</p>
</div>
)
} else return null
}}
/>
<Bar dataKey={attribute}>
{data.map((entry) => {
return (
<Cell
key={`${entry.period_start}`}
className={
yLimit !== undefined && Number(entry[attribute]) >= yLimit && hasQuota
? quotaWarningClass
: 'fill-scale-1200'
}
/>
)
})}
</Bar>
{hasQuota && yLimit && (
<ReferenceLine
y={yLimit}
label={(props) => (
<foreignObject
x={props.viewBox.x + 30}
y={props.viewBox.y - 10}
width={200}
height={24}
>
<div className="text-[0.7rem] font-bold text-scale-1200 bg-scale-100 w-fit p-1.5 py-0.5 rounded-md">
PLAN QUOTA
</div>
</foreignObject>
)}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
)
}
export default UsageBarChart
@@ -139,6 +139,8 @@ const ContentWrapper = observer(({ isLoading, children }: ContentWrapperProps) =
'/project/[ref]/settings/general',
'/project/[ref]/settings/database',
'/project/[ref]/settings/billing/subscription',
'/project/[ref]/settings/billing/usage',
'/project/[ref]/settings/billing/invoices',
'/project/[ref]/settings/billing/update',
'/project/[ref]/settings/billing/update/free',
'/project/[ref]/settings/billing/update/pro',
+17
View File
@@ -0,0 +1,17 @@
export type DataPoint = {
loopId: number | string
period_start: string
periodStartFormatted?: string
} & {
// Attribute name will be the key
[key: string]: string
}
export interface AnalyticsData {
data: DataPoint[]
format: string
total: number
totalAverage: number
yAxisLimit: number
hasNoData?: boolean
}
+189
View File
@@ -0,0 +1,189 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { useCallback } from 'react'
import { AnalyticsData } from './constants'
import { analyticsKeys } from './keys'
export type DailyStatsVariables = {
projectRef?: string
attribute:
| 'total_db_egress_bytes'
| 'total_db_size_bytes'
| 'total_egress_modified'
// Realtime
| 'total_realtime_ingress'
| 'total_realtime_egress'
| 'total_realtime_message_count'
| 'total_realtime_peak_connection'
| 'total_realtime_requests'
| 'total_realtime_get_requests'
| 'total_realtime_post_requests'
| 'total_realtime_patch_requests'
| 'total_realtime_delete_requests'
| 'total_realtime_options_requests'
// Rest
| 'total_rest_ingress'
| 'total_rest_egress'
| 'total_rest_requests'
| 'total_rest_get_requests'
| 'total_rest_post_requests'
| 'total_rest_patch_requests'
| 'total_rest_delete_requests'
| 'total_rest_options_requests'
// Auth
| 'total_auth_billing_period_mau'
| 'total_auth_billing_period_sso_mau'
| 'total_auth_ingress'
| 'total_auth_egress'
| 'total_auth_texts'
| 'total_auth_users'
| 'total_auth_emails'
| 'total_auth_requests'
| 'total_auth_get_requests'
| 'total_auth_post_requests'
| 'total_auth_patch_requests'
| 'total_auth_delete_requests'
| 'total_auth_options_requests'
// Storage
| 'total_storage_ingress'
| 'total_storage_egress'
| 'total_storage_size_bytes'
| 'total_storage_image_render_count'
| 'total_storage_requests'
| 'total_storage_get_requests'
| 'total_storage_post_requests'
| 'total_storage_patch_requests'
| 'total_storage_delete_requests'
| 'total_storage_options_requests'
// Edge functions
| 'total_func_count'
| 'total_func_invocations'
| 'total_func_exec_time_ms'
| 'total_func_ingress'
| 'total_func_egress'
// Combined
| 'total_ingress'
| 'total_egress'
| 'total_requests'
| 'total_get_requests'
| 'total_post_requests'
| 'total_patch_requests'
| 'total_delete_requests'
| 'total_options_requests'
startDate?: string
endDate?: string
interval?: string
dateFormat?: string
modifier?: (x: number) => number
}
export async function getDailyStats(
{ projectRef, attribute, startDate, endDate, interval = '1d' }: DailyStatsVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('Project ref is required')
if (!attribute) throw new Error('Attribute is required')
if (!startDate) throw new Error('Start date is required')
if (!endDate) throw new Error('Start date is required')
const data = await get(
`${API_URL}/projects/${projectRef}/daily-stats?attribute=${attribute}&startDate=${encodeURIComponent(
startDate
)}&endDate=${encodeURIComponent(endDate)}&interval=${interval}`,
{ signal }
)
if (data.error) throw data.error
return data as AnalyticsData
}
export type DailyStatsData = Awaited<ReturnType<typeof getDailyStats>>
export type DailyStatsError = unknown
export const useDailyStatsQuery = <TData = DailyStatsData>(
{
projectRef,
attribute,
startDate,
endDate,
interval = '1d',
dateFormat = 'DD MMM',
modifier,
}: DailyStatsVariables,
{ enabled = true, ...options }: UseQueryOptions<DailyStatsData, DailyStatsError, TData> = {}
) =>
useQuery<DailyStatsData, DailyStatsError, TData>(
analyticsKeys.dailyStats(projectRef, { attribute, startDate, endDate, interval }),
({ signal }) => getDailyStats({ projectRef, attribute, startDate, endDate, interval }, signal),
{
enabled:
enabled &&
typeof projectRef !== 'undefined' &&
typeof attribute !== 'undefined' &&
typeof startDate !== 'undefined' &&
typeof endDate !== 'undefined',
select(data) {
const noDataYet = Number(data.data[0].period_start) === 0
// [Joshen] Ideally handled by API, like infra-monitoring
if (noDataYet) {
const days = dayjs(endDate).diff(dayjs(startDate), 'days')
const tempArray = new Array(days).fill(0)
const mockData = tempArray.map((x, idx) => {
return {
loopId: idx,
period_start: dayjs(startDate).add(idx, 'day').format('DD MMM YYYY'),
periodStartFormatted: dayjs(startDate).add(idx, 'day').format(dateFormat),
[attribute]: 0,
}
})
return { ...data, data: mockData, hasNoData: true } as TData
} else {
return {
...data,
hasNoData: false,
data: data.data.map((x) => {
return {
...x,
[attribute]:
modifier !== undefined ? modifier(Number(x[attribute])) : Number(x[attribute]),
periodStartFormatted:
Number(x.period_start) > 0
? dayjs(x.period_start).format(dateFormat)
: x.period_start,
}
}),
} as TData
}
},
...options,
}
)
export const useDailyStatsPrefetch = ({
projectRef,
attribute,
startDate,
endDate,
interval = '1d',
}: DailyStatsVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef && attribute && startDate && endDate && interval) {
client.prefetchQuery(
analyticsKeys.dailyStats(projectRef, { attribute, startDate, endDate, interval }),
({ signal }) =>
getDailyStats({ projectRef, attribute, startDate, endDate, interval }, signal)
)
}
}, [projectRef])
}
@@ -0,0 +1,102 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { useCallback } from 'react'
import { AnalyticsData } from './constants'
import { analyticsKeys } from './keys'
export type InfraMonitoringVariables = {
projectRef?: string
attribute: 'cpu_usage' | 'disk_io_budget' | 'ram_usage'
startDate?: string
endDate?: string
interval?: string
dateFormat?: string
modifier?: (x: number) => number
}
export async function getInfraMonitoring(
{ projectRef, attribute, startDate, endDate, interval = '1d' }: InfraMonitoringVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('Project ref is required')
if (!attribute) throw new Error('Attribute is required')
if (!startDate) throw new Error('Start date is required')
if (!endDate) throw new Error('End date is required')
const data = await get(
`${API_URL}/projects/${projectRef}/infra-monitoring?attribute=${attribute}&startDate=${encodeURIComponent(
startDate
)}&endDate=${encodeURIComponent(endDate)}&interval=${interval}`,
{ signal }
)
if (data.error) throw data.error
return data as AnalyticsData
}
export type InfraMonitoringData = Awaited<ReturnType<typeof getInfraMonitoring>>
export type InfraMonitoringError = unknown
export const useInfraMonitoringQuery = <TData = InfraMonitoringData>(
{
projectRef,
attribute,
startDate,
endDate,
interval = '1d',
dateFormat = 'DD MMM',
modifier,
}: InfraMonitoringVariables,
{
enabled = true,
...options
}: UseQueryOptions<InfraMonitoringData, InfraMonitoringError, TData> = {}
) =>
useQuery<InfraMonitoringData, InfraMonitoringError, TData>(
analyticsKeys.dailyStats(projectRef, { attribute, startDate, endDate, interval }),
({ signal }) =>
getInfraMonitoring({ projectRef, attribute, startDate, endDate, interval }, signal),
{
enabled:
enabled &&
typeof projectRef !== 'undefined' &&
typeof attribute !== 'undefined' &&
typeof startDate !== 'undefined' &&
typeof endDate !== 'undefined',
select(data) {
return {
...data,
data: data.data.map((x) => {
return {
...x,
[attribute]:
modifier !== undefined ? modifier(Number(x[attribute])) : Number(x[attribute]),
periodStartFormatted: dayjs(x.period_start).format(dateFormat),
}
}),
} as TData
},
...options,
}
)
export const useInfraMonitoringPrefetch = ({
projectRef,
attribute,
startDate,
endDate,
interval = '1d',
}: InfraMonitoringVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef && attribute && startDate && endDate && interval) {
client.prefetchQuery(
analyticsKeys.dailyStats(projectRef, { attribute, startDate, endDate, interval }),
({ signal }) =>
getInfraMonitoring({ projectRef, attribute, startDate, endDate, interval }, signal)
)
}
}, [projectRef])
}
+25
View File
@@ -5,4 +5,29 @@ export const analyticsKeys = {
projectRef: string | undefined,
{ interval, functionId }: { functionId: string | undefined; interval: string | undefined }
) => ['projects', projectRef, 'functions-inv-stats', { interval, functionId }] as const,
dailyStats: (
projectRef: string | undefined,
{
attribute,
startDate,
endDate,
interval,
}: { attribute?: string; startDate?: string; endDate?: string; interval?: string }
) =>
['projects', projectRef, 'daily-stats', { attribute, startDate, endDate, interval }] as const,
infraMonitoring: (
projectRef: string | undefined,
{
attribute,
startDate,
endDate,
interval,
}: { attribute?: string; startDate?: string; endDate?: string; interval?: string }
) =>
[
'projects',
projectRef,
'infra-monitoring',
{ attribute, startDate, endDate, interval },
] as const,
}
+4
View File
@@ -0,0 +1,4 @@
export const projectKeys = {
list: () => ['projects'] as const,
detail: (projectRef: string | undefined) => ['projects', projectRef, 'detail'] as const,
}
@@ -0,0 +1,43 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { useCallback } from 'react'
import { Project } from 'types'
import { projectKeys } from './keys'
export type ProjectDetailVariables = { ref?: string }
export async function getProjectDetail({ ref }: ProjectDetailVariables, signal?: AbortSignal) {
if (!ref) throw new Error('Project ref is required')
const data = await get(`${API_URL}/projects/${ref}`, { signal })
if (data.error) throw data.error
return data as Project
}
export type ProjectDetailData = Awaited<ReturnType<typeof getProjectDetail>>
export type ProjectDetailError = unknown
export const useProjectDetailQuery = <TData = ProjectDetailData>(
{ ref }: ProjectDetailVariables,
{ enabled = true, ...options }: UseQueryOptions<ProjectDetailData, ProjectDetailError, TData> = {}
) =>
useQuery<ProjectDetailData, ProjectDetailError, TData>(
projectKeys.detail(ref),
({ signal }) => getProjectDetail({ ref }, signal),
{
enabled: enabled && typeof ref !== 'undefined',
...options,
}
)
export const useProjectDetailPrefetch = ({ ref }: ProjectDetailVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (ref) {
client.prefetchQuery(projectKeys.detail(ref), ({ signal }) =>
getProjectDetail({ ref }, signal)
)
}
}, [ref])
}
+37
View File
@@ -0,0 +1,37 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { useCallback } from 'react'
import { Project } from 'types'
import { projectKeys } from './keys'
export type ProjectsVariables = {
ref?: string
}
export async function getProjects(signal?: AbortSignal) {
const data = await get(`${API_URL}/projects`, { signal })
if (data.error) throw data.error
return data as Project[]
}
export type ProjectsData = Awaited<ReturnType<typeof getProjects>>
export type ProjectsError = unknown
export const useProjectsQuery = <TData = ProjectsData>({
enabled = true,
...options
}: UseQueryOptions<ProjectsData, ProjectsError, TData> = {}) =>
useQuery<ProjectsData, ProjectsError, TData>(
projectKeys.list(),
({ signal }) => getProjects(signal),
{ enabled: enabled, ...options }
)
export const useProjectsPrefetch = () => {
const client = useQueryClient()
return useCallback(() => {
client.prefetchQuery(projectKeys.list(), ({ signal }) => getProjects(signal))
}, [])
}
+28 -36
View File
@@ -8,37 +8,7 @@ export type ProjectUsageVariables = {
projectRef?: string
}
export interface DbSize {
usage: number | null
limit: number
cost: number
current: number
available_in_plan: boolean
}
export interface DbEgress {
usage: number
limit: number
cost: number
available_in_plan: boolean
}
export interface StorageSize {
usage: number | null
limit: number
cost: number
current: number
available_in_plan: boolean
}
export interface StorageEgress {
usage: number
limit: number
cost: number
available_in_plan: boolean
}
export interface MonthlyActiveUsers {
export interface UsageMetric {
usage: number
limit: number
cost: number
@@ -46,11 +16,17 @@ export interface MonthlyActiveUsers {
}
export type ProjectUsageResponse = {
db_size: DbSize
db_egress: DbEgress
storage_size: StorageSize
storage_egress: StorageEgress
monthly_active_users: MonthlyActiveUsers
db_size: UsageMetric
db_egress: UsageMetric
storage_size: UsageMetric
storage_egress: UsageMetric
storage_image_render_count: UsageMetric
monthly_active_users: UsageMetric
monthly_active_sso_users: UsageMetric
realtime_message_count: UsageMetric
realtime_peak_connection: UsageMetric
func_count: UsageMetric
func_invocations: UsageMetric
disk_volume_size_gb: number
}
@@ -83,6 +59,22 @@ export const useProjectUsageQuery = <TData = ProjectUsageData>(
({ signal }) => getProjectUsage({ projectRef }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
select(data) {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => {
if (typeof value === 'object') {
const formattedValue = {
...value,
usage: Number(value.usage),
}
return [key, formattedValue]
} else {
return [key, value]
}
})
)
},
...options,
}
)
+7 -5
View File
@@ -136,15 +136,17 @@ export const propsAreEqual = (prevProps: any, nextProps: any) => {
}
}
export const formatBytes = (bytes: any, decimals = 2) => {
if (bytes === 0 || bytes === undefined) return '0 bytes'
export const formatBytes = (
bytes: any,
decimals = 2,
size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
) => {
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
if (bytes === 0 || bytes === undefined) return size !== undefined ? `0 ${size}` : '0 bytes'
const i = size !== undefined ? sizes.indexOf(size) : Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
@@ -1,10 +1,10 @@
import dayjs from 'dayjs'
import { observer } from 'mobx-react-lite'
import { FC, useEffect, useState } from 'react'
import { IconArrowRight, Loading } from 'ui'
import { useEffect, useState } from 'react'
import { IconArrowRight, Loading, Toggle } from 'ui'
import { Project, NextPageWithLayout } from 'types'
import { useStore } from 'hooks'
import { NextPageWithLayout } from 'types'
import { useStore, useFlag } from 'hooks'
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
import { STRIPE_PRODUCT_IDS, TIME_PERIODS_REPORTS, TIME_PERIODS_BILLING } from 'lib/constants'
import { SettingsLayout } from 'components/layouts'
@@ -12,16 +12,33 @@ import LoadingUI from 'components/ui/Loading'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import { PAYGUsage } from 'components/interfaces/Billing'
import ProjectUsageBars from 'components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars'
import Usage from 'components/interfaces/BillingV2/Usage/Usage'
const ProjectBillingUsage: NextPageWithLayout = () => {
const { ui } = useStore()
const project = ui.selectedProject
const enableUsageV2 = useFlag('usagev2')
const [showNewUsageUI, setShowNewUsageUI] = useState(enableUsageV2)
return (
<div className="w-full h-full overflow-y-auto content">
<div className="w-full mx-auto">
<Settings project={project} />
</div>
<div className="relative">
{enableUsageV2 && (
<div className="absolute top-[1.9rem] right-16 flex items-center space-x-3">
<Toggle
size="tiny"
checked={showNewUsageUI}
onChange={() => setShowNewUsageUI(!showNewUsageUI)}
/>
<p className="text-xs text-scale-1100 -translate-y-[1px]">Preview new interface</p>
</div>
)}
{enableUsageV2 && showNewUsageUI ? (
<Usage />
) : (
<div className="w-full h-full overflow-y-auto content">
<div className="w-full mx-auto">
<Settings />
</div>
</div>
)}
</div>
)
}
@@ -32,12 +49,9 @@ ProjectBillingUsage.getLayout = (page) => (
export default observer(ProjectBillingUsage)
interface SettingsProps {
project?: Project
}
const Settings: FC<SettingsProps> = ({ project }) => {
const Settings = () => {
const { ui } = useStore()
const project = ui.selectedProject
const {
data: subscription,
+11
View File
@@ -438,3 +438,14 @@
@apply stroke-green-500;
}
}
.recharts-cartesian-axis-tick {
text {
@apply text-xs;
}
}
.recharts-reference-line {
line {
@apply stroke-scale-1000;
}
}