mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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))
|
||||
}, [])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -438,3 +438,14 @@
|
||||
@apply stroke-green-500;
|
||||
}
|
||||
}
|
||||
|
||||
.recharts-cartesian-axis-tick {
|
||||
text {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
.recharts-reference-line {
|
||||
line {
|
||||
@apply stroke-scale-1000;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user