diff --git a/studio/components/interfaces/BillingV2/Billing.constants.ts b/studio/components/interfaces/BillingV2/Billing.constants.ts new file mode 100644 index 00000000000..0b7203cbf01 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Billing.constants.ts @@ -0,0 +1 @@ +export const USAGE_APPROACHING_THRESHOLD = 0.8 diff --git a/studio/components/interfaces/BillingV2/Usage/Activity.tsx b/studio/components/interfaces/BillingV2/Usage/Activity.tsx new file mode 100644 index 00000000000..59efc8dfff0 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/Activity.tsx @@ -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 ( + <> + + + {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 ( + + {usageMeta?.available_in_plan && ( + <> +
+
+
+

{attribute.name} quota usage

+ {usageRatio >= 1 ? ( +
+ +

Exceeded limit

+
+ ) : usageRatio >= USAGE_APPROACHING_THRESHOLD ? ( +
+ +

Approaching limit

+
+ ) : null} +
+ {isFreeTier && ( + + + + + + )} +
+ {usageMeta.limit > 0 && ( + = 1 + ? 'bg-red-900' + : usageRatio >= USAGE_APPROACHING_THRESHOLD + ? 'bg-amber-900' + : 'bg-scale-1100' + )} + value={usageMeta?.usage ?? 0} + max={usageMeta?.limit ?? 0} + /> + )} +
+
+

+ Included in {subscription?.tier.name.toLowerCase()} +

+ {usageMeta?.limit === -1 ? ( +

None

+ ) : ( +

{(usageMeta?.limit ?? 0).toLocaleString()}

+ )} +
+
+

Used

+

{(usageMeta?.usage ?? 0).toLocaleString()}

+
+ {usageMeta.limit > 0 && ( +
+

Extra volume used this month

+

+ {((usageMeta?.limit ?? 0) === -1 || usageExcess < 0 + ? 0 + : usageExcess + ).toLocaleString()} +

+
+ )} +
+
+
+

{attribute.name} over time

+ {attribute.chartDescription.split('\n').map((paragraph, idx) => ( +

+ {paragraph} +

+ ))} +
+ {chartMeta[attribute.key].isLoading ? ( +
+ + + +
+ ) : ( + value.toLocaleString()} + quotaWarningType={isFreeTier || isProTier ? 'danger' : 'warning'} + /> + )} + + )} +
+ ) + })} + + ) +} + +export default Activity diff --git a/studio/components/interfaces/BillingV2/Usage/Bandwidth.tsx b/studio/components/interfaces/BillingV2/Usage/Bandwidth.tsx new file mode 100644 index 00000000000..b38341e249c --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/Bandwidth.tsx @@ -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 ( + <> + + + {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 ( + +
+
+
+

{attribute.name} quota usage

+ {usageRatio >= 1 ? ( +
+ +

Exceeded limit

+
+ ) : usageRatio >= USAGE_APPROACHING_THRESHOLD ? ( +
+ +

Approaching limit

+
+ ) : null} +
+ {isFreeTier && ( + + + + + + )} +
+ {usageMeta?.limit > 0 && ( + = 1 + ? 'bg-red-900' + : usageRatio >= USAGE_APPROACHING_THRESHOLD + ? 'bg-amber-900' + : 'bg-scale-1100' + )} + value={usageMeta?.usage ?? 0} + max={usageMeta?.limit ?? 0} + /> + )} +
+
+

+ Included in {subscription?.tier.name.toLowerCase()} +

+

+ {usageMeta?.limit === 0 ? 'Unlimited' : formatBytes(usageMeta?.limit ?? 0)} +

+
+
+

Used

+

{formatBytes(usageMeta?.usage ?? 0)}

+
+ {usageMeta?.limit > 0 && ( +
+

Extra volume used this month

+

+ {usageExcess < 0 ? formatBytes(0) : formatBytes(usageExcess)} +

+
+ )} +
+
+
+

{attribute.name} over time

+ {attribute.chartDescription.split('\n').map((paragraph, idx) => ( +

+ {paragraph} +

+ ))} +
+ {chartMeta[attribute.key]?.isLoading ? ( +
+ + + +
+ ) : ( + 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'} + /> + )} +
+ ) + })} + + ) +} + +export default Bandwidth diff --git a/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx new file mode 100644 index 00000000000..f002a22b118 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx @@ -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 ( + <> + + {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 ( + + {attribute.key === 'disk_io_budget' && ( + <> + {currentDayIoBudget <= 0 ? ( + +

+ 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. +

+ + + + + +
+ ) : currentDayIoBudget <= 20 ? ( + +

+ 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. +

+ + + + + +
+ ) : null} +
+

What is Disk IO Bandwidth?

+

+ 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. +

+
+
+

Overview

+
+

Current compute instance

+

{currentComputeInstance?.name ?? 'Micro'}

+
+
+

Maximum IO Bandwidth (burst limit)

+

+ {currentComputeInstanceSpecs.maxBandwidth.toLocaleString()} Mbps +

+
+
+

Baseline IO Bandwidth

+

+ {currentComputeInstanceSpecs.baseBandwidth.toLocaleString()} Mbps +

+
+
+

Daily burst time limit

+

30 mins

+
+
+ + )} +
+ {attribute.key === 'disk_io_budget' ? ( +

IO Budget remaining each day

+ ) : ( +

+ Max{' '} + + {attribute.name} + {' '} + usage each day +

+ )} + {attribute.chartDescription.split('\n').map((paragraph, idx) => ( +

+ {paragraph} +

+ ))} +
+ {chartMeta[attribute.key].isLoading ? ( +
+ + + +
+ ) : ( + `${value}%`} + yLimit={100} + /> + )} +
+ ) + })} + + ) +} + +export default Infrastructure diff --git a/studio/components/interfaces/BillingV2/Usage/SectionContent.tsx b/studio/components/interfaces/BillingV2/Usage/SectionContent.tsx new file mode 100644 index 00000000000..ee8b97fea12 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/SectionContent.tsx @@ -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) => { + const { name, description, docsUrl } = section + + return ( +
+
+
+
+
+
+
+

{name}

+ {includedInPlan === false && Not included} +
+ {description.split('\n').map((value, idx) => ( +

+ {value} +

+ ))} +
+ {docsUrl !== undefined && ( +
+

More information

+ + +
+

Documentation

+ +
+
+ +
+ )} + {lastKnownValue !== undefined && ( +

Last updated at: {lastKnownValue}

+ )} +
+
+
{children}
+
+
+
+ ) +} + +export default SectionContent diff --git a/studio/components/interfaces/BillingV2/Usage/SectionHeader.tsx b/studio/components/interfaces/BillingV2/Usage/SectionHeader.tsx new file mode 100644 index 00000000000..45cc9cd30ce --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/SectionHeader.tsx @@ -0,0 +1,19 @@ +export interface SectionHeaderProps { + title: string + description: string +} + +const SectionHeader = ({ title, description }: SectionHeaderProps) => { + return ( +
+
+
+

{title}

+

{description}

+
+
+
+ ) +} + +export default SectionHeader diff --git a/studio/components/interfaces/BillingV2/Usage/SizeAndCounts.tsx b/studio/components/interfaces/BillingV2/Usage/SizeAndCounts.tsx new file mode 100644 index 00000000000..d062bf905e2 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/SizeAndCounts.tsx @@ -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 ( + <> + + + {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 ( + +
+
+
+

{attribute.name} quota usage

+ {usageRatio >= 1 ? ( +
+ +

Exceeded limit

+
+ ) : usageRatio >= USAGE_APPROACHING_THRESHOLD ? ( +
+ +

Approaching limit

+
+ ) : null} +
+ {isFreeTier && ( + + + + + + )} +
+ {usageMeta.limit > 0 && ( + = 1 + ? 'bg-red-900' + : usageRatio >= USAGE_APPROACHING_THRESHOLD + ? 'bg-amber-900' + : 'bg-scale-1100' + )} + value={usageMeta?.usage ?? 0} + max={usageMeta?.limit ?? 0} + /> + )} +
+
+

+ Included in {subscription?.tier.name.toLowerCase()} +

+

+ {attribute.unit === 'bytes' + ? formatBytes(usageMeta?.limit ?? 0) + : (usageMeta?.limit ?? 0).toLocaleString()} +

+
+
+

Used

+

+ {attribute.unit === 'bytes' + ? formatBytes(usageMeta?.usage ?? 0) + : (usageMeta?.usage ?? 0).toLocaleString()} +

+
+ {usageMeta.limit > 0 && ( +
+

Extra volume used this month

+

+ {usageExcess < 0 + ? attribute.unit === 'bytes' + ? formatBytes(0) + : 0 + : attribute.unit === 'bytes' + ? formatBytes(usageExcess) + : usageExcess.toLocaleString()} +

+
+ )} +
+
+ + {attribute.key === 'db_size' && + subscription?.tier.supabase_prod_id !== PRICING_TIER_PRODUCT_IDS.FREE && ( +
+
+

Disk size:

+

{usage?.disk_volume_size_gb} GB

+ + Auto-scaling + +
+ + + + + +
+ )} + +
+

{attribute.name} over time

+ {attribute.chartDescription.split('\n').map((paragraph, idx) => ( +

+ {paragraph} +

+ ))} +
+ {chartMeta[attribute.key].isLoading ? ( +
+ + + +
+ ) : ( + + attribute.unit === 'bytes' + ? formatBytes(value, 1, 'GB').replace(/\s/g, '') + : value.toLocaleString() + } + quotaWarningType={isFreeTier || isProTier ? 'danger' : 'warning'} + /> + )} +
+ ) + })} + + ) +} + +export default SizeAndCounts diff --git a/studio/components/interfaces/BillingV2/Usage/Usage.constants.ts b/studio/components/interfaces/BillingV2/Usage/Usage.constants.ts new file mode 100644 index 00000000000..7fadb438b6a --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/Usage.constants.ts @@ -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, + }, +} diff --git a/studio/components/interfaces/BillingV2/Usage/Usage.tsx b/studio/components/interfaces/BillingV2/Usage/Usage.tsx new file mode 100644 index 00000000000..d682a025f3e --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/Usage.tsx @@ -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(ref as string) + + const infrastructureRef = useRef(null) + const bandwidthRef = useRef(null) + const sizeAndCountsRef = useRef(null) + const activityRef = useRef(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 ( + <> +
+
+

Usage

+
+
+ {}} + > + + Current billing cycle + + + Previous billing cycle + + + {isLoadingSubscription ? ( + + ) : subscription !== undefined ? ( +
+

+ Project is on {subscription.tier.name} +

+

+ {billingCycleStart.format('DD MMM YY')} - {billingCycleEnd.format('DD MMM YY')} +

+
+ ) : null} +
+ +
+ +
+ {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 ( +
scrollTo(category.key)} + className="flex items-center opacity-50 space-x-2 py-3 hover:opacity-100 transition cursor-pointer" + > + {status === USAGE_STATUS.NORMAL ? ( + + ) : status === USAGE_STATUS.APPROACHING ? ( + + ) : status === USAGE_STATUS.EXCEEDED ? ( + + ) : null} +

{category.name}

+
+ ) + })} +
+
+
+ + {/* + [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) + */} +
+ +
+
+ +
+
+ +
+
+ +
+ + ) +} + +export default Usage diff --git a/studio/components/interfaces/BillingV2/Usage/Usage.utils.ts b/studio/components/interfaces/BillingV2/Usage/Usage.utils.ts new file mode 100644 index 00000000000..d4286819f02 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/Usage.utils.ts @@ -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` +} diff --git a/studio/components/interfaces/BillingV2/Usage/UsageBarChart.tsx b/studio/components/interfaces/BillingV2/Usage/UsageBarChart.tsx new file mode 100644 index 00000000000..2e2dbddae43 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/UsageBarChart.tsx @@ -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 ( +
+ + + + + + { + 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 ( +
+

+ {attribute === 'disk_io_budget' ? `Remaining IO budget:` : `${name}:`} +

+ {dataPeriod.startOf('day').isAfter(dayjs().startOf('day')) ? ( +

No data yet

+ ) : ( +

+ {yFormatter !== undefined ? yFormatter(value) : value} +

+ )} +

+ {dataPeriod.format('DD MMM YYYY')} +

+
+ ) + } else return null + }} + /> + + {data.map((entry) => { + return ( + = yLimit && hasQuota + ? quotaWarningClass + : 'fill-scale-1200' + } + /> + ) + })} + + {hasQuota && yLimit && ( + ( + +
+ PLAN QUOTA +
+
+ )} + /> + )} +
+
+
+ ) +} + +export default UsageBarChart diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 826b7f2c208..9df62f126e9 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -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', diff --git a/studio/data/analytics/constants.ts b/studio/data/analytics/constants.ts new file mode 100644 index 00000000000..d12ba4a1c19 --- /dev/null +++ b/studio/data/analytics/constants.ts @@ -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 +} diff --git a/studio/data/analytics/daily-stats-query.ts b/studio/data/analytics/daily-stats-query.ts new file mode 100644 index 00000000000..c25f78d85ad --- /dev/null +++ b/studio/data/analytics/daily-stats-query.ts @@ -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> +export type DailyStatsError = unknown + +export const useDailyStatsQuery = ( + { + projectRef, + attribute, + startDate, + endDate, + interval = '1d', + dateFormat = 'DD MMM', + modifier, + }: DailyStatsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + 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]) +} diff --git a/studio/data/analytics/infra-monitoring-query.ts b/studio/data/analytics/infra-monitoring-query.ts new file mode 100644 index 00000000000..028bd927c15 --- /dev/null +++ b/studio/data/analytics/infra-monitoring-query.ts @@ -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> +export type InfraMonitoringError = unknown + +export const useInfraMonitoringQuery = ( + { + projectRef, + attribute, + startDate, + endDate, + interval = '1d', + dateFormat = 'DD MMM', + modifier, + }: InfraMonitoringVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + 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]) +} diff --git a/studio/data/analytics/keys.ts b/studio/data/analytics/keys.ts index b360a8f7132..049d7d0a0a0 100644 --- a/studio/data/analytics/keys.ts +++ b/studio/data/analytics/keys.ts @@ -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, } diff --git a/studio/data/projects/keys.ts b/studio/data/projects/keys.ts new file mode 100644 index 00000000000..7a3d357e9c6 --- /dev/null +++ b/studio/data/projects/keys.ts @@ -0,0 +1,4 @@ +export const projectKeys = { + list: () => ['projects'] as const, + detail: (projectRef: string | undefined) => ['projects', projectRef, 'detail'] as const, +} diff --git a/studio/data/projects/project-detail-query.ts b/studio/data/projects/project-detail-query.ts new file mode 100644 index 00000000000..552ede409d3 --- /dev/null +++ b/studio/data/projects/project-detail-query.ts @@ -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> +export type ProjectDetailError = unknown + +export const useProjectDetailQuery = ( + { ref }: ProjectDetailVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + 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]) +} diff --git a/studio/data/projects/projects-query.ts b/studio/data/projects/projects-query.ts new file mode 100644 index 00000000000..7429a1fd7c5 --- /dev/null +++ b/studio/data/projects/projects-query.ts @@ -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> +export type ProjectsError = unknown + +export const useProjectsQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + projectKeys.list(), + ({ signal }) => getProjects(signal), + { enabled: enabled, ...options } + ) + +export const useProjectsPrefetch = () => { + const client = useQueryClient() + + return useCallback(() => { + client.prefetchQuery(projectKeys.list(), ({ signal }) => getProjects(signal)) + }, []) +} diff --git a/studio/data/usage/project-usage-query.ts b/studio/data/usage/project-usage-query.ts index dca03ce536f..1292eba7e26 100644 --- a/studio/data/usage/project-usage-query.ts +++ b/studio/data/usage/project-usage-query.ts @@ -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 = ( ({ 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, } ) diff --git a/studio/lib/helpers.ts b/studio/lib/helpers.ts index d11dd083a6e..759643225f0 100644 --- a/studio/lib/helpers.ts +++ b/studio/lib/helpers.ts @@ -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] } diff --git a/studio/pages/project/[ref]/settings/billing/usage.tsx b/studio/pages/project/[ref]/settings/billing/usage.tsx index 676a784d041..5d41670e8e0 100644 --- a/studio/pages/project/[ref]/settings/billing/usage.tsx +++ b/studio/pages/project/[ref]/settings/billing/usage.tsx @@ -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 ( -
-
- -
+
+ {enableUsageV2 && ( +
+ setShowNewUsageUI(!showNewUsageUI)} + /> +

Preview new interface

+
+ )} + {enableUsageV2 && showNewUsageUI ? ( + + ) : ( +
+
+ +
+
+ )}
) } @@ -32,12 +49,9 @@ ProjectBillingUsage.getLayout = (page) => ( export default observer(ProjectBillingUsage) -interface SettingsProps { - project?: Project -} - -const Settings: FC = ({ project }) => { +const Settings = () => { const { ui } = useStore() + const project = ui.selectedProject const { data: subscription, diff --git a/studio/styles/ui.scss b/studio/styles/ui.scss index 12e09fa2017..e080e04ecde 100644 --- a/studio/styles/ui.scss +++ b/studio/styles/ui.scss @@ -438,3 +438,14 @@ @apply stroke-green-500; } } + +.recharts-cartesian-axis-tick { + text { + @apply text-xs; + } +} +.recharts-reference-line { + line { + @apply stroke-scale-1000; + } +} \ No newline at end of file