diff --git a/apps/docs/content/guides/database/extensions/wrappers/overview.mdx b/apps/docs/content/guides/database/extensions/wrappers/overview.mdx index 210d86d9bced3..a524a0d16b7ed 100644 --- a/apps/docs/content/guides/database/extensions/wrappers/overview.mdx +++ b/apps/docs/content/guides/database/extensions/wrappers/overview.mdx @@ -92,7 +92,7 @@ This approach has several benefits: 1. **On-demand:** analytical data is immediately available within your application with no additional infrastructure. 1. **Always in sync:** since the data is queried directly from the remote server, it's always up-to-date. 1. **Integrated:** large datasets are available within your application, and can be joined with your operational/transactional data. -1. **Save on bandwidth:** only extract/load what you need. +1. **Save on egress:** only extract/load what you need. ### Batch ETL with Wrappers diff --git a/apps/docs/content/guides/platform/billing-faq.mdx b/apps/docs/content/guides/platform/billing-faq.mdx index 719cf942f4b4d..fa0dcde3872d0 100644 --- a/apps/docs/content/guides/platform/billing-faq.mdx +++ b/apps/docs/content/guides/platform/billing-faq.mdx @@ -64,9 +64,11 @@ If you upgrade your project to a larger instance for 10 hours and then downgrade Read more about [Compute usage](/docs/guides/platform/manage-your-usage/compute). -#### What is unified egress and how is it billed? +#### What is egress and how is it billed? -Unified egress refers to the total egress quota available to each organization. This quota can be utilized for various purposes such as Storage, Realtime, Auth, Functions, Supavisor, Log Drains and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly. +Egress refers to the total bandwidth (network traffic) quota available to each organization. This quota can be utilized for various purposes such as Storage, Realtime, Auth, Functions, Supavisor, Log Drains and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly. + +We differentiate between cached (served via our CDN from cache hits) and uncached egress and give quotas for each type and have varying pricing (cached egress is cheaper). Read more about [Egress usage](/docs/guides/platform/manage-your-usage/egress). @@ -128,7 +130,7 @@ The Fair Use Policy is generally applied to all projects of the restricted organ To remove restrictions, you will need to address the issue that caused the restriction. This could be reducing your usage, paying overdue invoices, updating your payment method, or any other issue that caused the restriction. Once the issue is resolved, the restriction will be lifted. -Restrictions due to usage limits are lifted with the next billing cycle as your quota refills at the beginning of each cycle. You can see when your current billing cycle ends on the [billing page](https://supabase.com/dashboard/org/_/billing) under "Upcoming Invoice". If your organization is on the Free Plan, you can also lift restrictions immediately by [upgrading](https://supabase.com/dashboard/org/_/billing?panel=subscriptionPlan) to Pro. +Restrictions due to usage limits are lifted with the next billing cycle as your quota refills at the beginning of each cycle. You can see when your current billing cycle ends on the [billing page](https://supabase.com/dashboard/org/_/billing) under "Upcoming Invoice". You can also lift restrictions immediately by [upgrading](https://supabase.com/dashboard/org/_/billing?panel=subscriptionPlan) to Pro (if on Free Plan) or by [disabling spend cap](https://supabase.com/dashboard/org/_/billing?panel=costControl) (if on Pro Plan with spend cap enabled). ## Reports and invoices diff --git a/apps/docs/content/guides/platform/manage-your-usage/egress.mdx b/apps/docs/content/guides/platform/manage-your-usage/egress.mdx index 71a893036af18..b5819a762343e 100644 --- a/apps/docs/content/guides/platform/manage-your-usage/egress.mdx +++ b/apps/docs/content/guides/platform/manage-your-usage/egress.mdx @@ -55,25 +55,29 @@ Data pushed to the connected log drain. **Example:** You set up a log drain, each log sent to the log drain is considered egress. You can toggle the GZIP option to reduce egress, in case your provider supports it. +### Cached Egress + +Cached and uncached egress have independent quotas and independent pricing. Cached egress is egress that is served from our CDN via cache hits. Cached egress is typically incurred for storage through our [Smart CDN](/docs/guides/storage/cdn/smart-cdn). + ## How charges are calculated Egress is charged by gigabyte. Charges apply only for usage exceeding your subscription plan's quota. This quota is called the Unified Egress Quota because it can be used across all services (Database, Auth, Storage etc.). ### Usage on your invoice -Usage is shown as "Egress GB" on your invoice. +Usage is shown as "Egress GB" and "Cached Egress GB" on your invoice. ## Pricing - per GB per month. You are only charged for usage exceeding your subscription plan's -quota. + per GB per month for uncached egress, per GB per month +for cached egress. You are only charged for usage exceeding your subscription plan's quota. -| Plan | Unified Egress Quota | Over-Usage per month | -| ---------- | -------------------- | ----------------------------- | -| Free | 5 GB | - | -| Pro | 250 GB | per GB | -| Team | 250 GB | per GB | -| Enterprise | Custom | Custom | +| Plan | Egress Quota (Uncached / Cached) | Over-Usage per month (Uncached / Cached) | +| ---------- | -------------------------------- | ------------------------------------------------------------- | +| Free | 5 GB / 5 GB | - | +| Pro | 250 GB / 250 GB | per GB / per GB | +| Team | 250 GB / 250 GB | per GB / per GB | +| Enterprise | Custom | Custom | ## Billing examples @@ -86,22 +90,24 @@ The organization's Egress usage is within the quota, so no charges for Egress ap | Pro Plan | 1 | | | Compute Hours Micro | 744 hours | | | Egress | 200 GB | | +| Cached Egress | 230 GB | | | **Subtotal** | | **** | | Compute Credits | | - | | **Total** | | **** | ### Exceeding quota -The organization's Egress usage exceeds the quota by 50 GB, incurring charges for this additional usage. +The organization's Egress usage exceeds the uncached egress quota by 50 GB and the cached egress quota by 550 GB, incurring charges for this additional usage. | Line Item | Units | Costs | | ------------------- | --------- | -------------------------- | | Pro Plan | 1 | | | Compute Hours Micro | 744 hours | | | Egress | 300 GB | | -| **Subtotal** | | **** | +| Cached Egress | 800 GB | | +| **Subtotal** | | **** | | Compute Credits | | - | -| **Total** | | **** | +| **Total** | | **** | ## View usage @@ -118,7 +124,7 @@ You can view Egress usage on the [organization's usage page](https://supabase.co zoomable /> -In the Total Egress section, you can see the usage for the selected time period. Hover over a specific date to view a breakdown by service. +In the Total Egress section, you can see the usage for the selected time period. Hover over a specific date to view a breakdown by service. Note that this includes the cached egress. Unified Egress +Separately, you can see the cached egress right below: + +Unified Egress + ### Custom report 1. On the [reports page](https://supabase.com/dashboard/project/_/reports), click **New custom report** in the left navigation menu @@ -144,7 +160,7 @@ In the Total Egress section, you can see the usage for the selected time period. ## Debug usage -To better understand your Egress usage, identify what’s driving the most traffic. Check the most frequent database queries, or analyze the most requested API paths to pinpoint high-bandwidth endpoints. +To better understand your Egress usage, identify what’s driving the most traffic. Check the most frequent database queries, or analyze the most requested API paths to pinpoint high-egress endpoints. ### Frequent database queries diff --git a/apps/docs/content/guides/storage/production/scaling.mdx b/apps/docs/content/guides/storage/production/scaling.mdx index c5f38e72eae1b..031ae6a62f97b 100644 --- a/apps/docs/content/guides/storage/production/scaling.mdx +++ b/apps/docs/content/guides/storage/production/scaling.mdx @@ -24,6 +24,10 @@ Using the browser cache can effectively lower your egress since the asset remain You have the option to set a maximum upload size for your bucket. Doing this can prevent users from uploading and then downloading excessively large files. You can control the maximum file size by configuring this option at the [bucket level](/docs/guides/storage/buckets/creating-buckets). +#### Smart CDN + +By leveraging our [Smart CDN](/docs/guides/storage/cdn/smart-cdn), you can achieve a higher cache hit rate and therefore lower your egress cached, as we charge less for cached egress (see [egress pricing](/docs/guides/platform/manage-your-usage/egress#pricing)). + ## Optimize listing objects Once you have a substantial number of objects, you might observe that the `supabase.storage.list()` method starts to slow down. This occurs because the endpoint is quite generic and attempts to retrieve both folders and objects in a single query. While this approach is very useful for building features like the Storage viewer on the Supabase dashboard, it can impact performance with a large number of objects. diff --git a/apps/docs/content/guides/storage/serving/bandwidth.mdx b/apps/docs/content/guides/storage/serving/bandwidth.mdx index 740931c242df7..021810276c495 100644 --- a/apps/docs/content/guides/storage/serving/bandwidth.mdx +++ b/apps/docs/content/guides/storage/serving/bandwidth.mdx @@ -8,44 +8,52 @@ sidebar_label: 'Bandwidth & Storage Egress' ## Bandwidth & Storage egress -Free Plan Organizations in Supabase have a limit of 5 GB of bandwidth. This limit is calculated by the sum of all the data transferred from the Supabase servers to the client. This includes all the data transferred from the database, storage, and functions. +Free Plan Organizations in Supabase have a limit of 10 GB of bandwidth (5 GB cached + 5 GB uncached). This limit is calculated by the sum of all the data transferred from the Supabase servers to the client. This includes all the data transferred from the database, storage, and functions. -### Checking Storage egress requests in Logs Explorer: +### Checking Storage egress requests in Logs Explorer We have a template query that you can use to get the number of requests for each object in [Logs Explorer](/dashboard/project/_/logs/explorer/templates). ```sql select - r.method as http_verb, - r.path as filepath, + request.method as http_verb, + request.path as filepath, + (responseHeaders.cf_cache_status = 'HIT') as cached, count(*) as num_requests from edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as r - cross join unnest(r.headers) as h -where (path like '%storage/v1/object/%' or path like '%storage/v1/render/%') and r.method = 'GET' -group by r.path, r.method + cross join unnest(metadata) as metadata + cross join unnest(metadata.request) as request + cross join unnest(metadata.response) as response + cross join unnest(response.headers) as responseHeaders +where + (path like '%storage/v1/object/%' or path like '%storage/v1/render/%') + and request.method = 'GET' +group by 1, 2, 3 order by num_requests desc limit 100; ``` Example of the output: -``` +```json [ - {"filepath":"/storage/v1/object/sign/large%20bucket/20230902_200037.gif", - "http_verb":"GET", - "num_requests":100 - }, - {"filepath":"/storage/v1/object/public/demob/Sports/volleyball.png", - "http_verb":"GET", - "num_requests":168 - } + { + "filepath": "/storage/v1/object/sign/large%20bucket/20230902_200037.gif", + "http_verb": "GET", + "cached": true, + "num_requests": 100 + }, + { + "filepath": "/storage/v1/object/public/demob/Sports/volleyball.png", + "http_verb": "GET", + "cached": false, + "num_requests": 168 + } ] ``` -### Calculating egress: +### Calculating egress If you already know the size of those files, you can calculate the egress by multiplying the number of requests by the size of the file. You can also get the size of the file with the following cURL: @@ -67,6 +75,6 @@ Total Egress = 395.76MB You can see that these values can get quite large, so it's important to keep track of the egress and optimize the files. -### Optimizing egress: +### Optimizing egress -If you are on the Pro Plan, you can use the [Supabase Image Transformations](/docs/guides/storage/image-transformations) to optimize the images and reduce the egress. +See our [scaling tips for egress](/docs/guides/storage/production/scaling#egress). diff --git a/apps/docs/content/guides/storage/serving/image-transformations.mdx b/apps/docs/content/guides/storage/serving/image-transformations.mdx index 52a2ad73d2caf..952d6a79b11b0 100644 --- a/apps/docs/content/guides/storage/serving/image-transformations.mdx +++ b/apps/docs/content/guides/storage/serving/image-transformations.mdx @@ -259,7 +259,7 @@ response = supabase.storage.from_('bucket').download( When using the image transformation API, Storage will automatically find the best format supported by the client and return that to the client, without any code change. For instance, if you use Chrome when viewing a JPEG image and using transformation options, you'll see that images are automatically optimized as `webp` images. -As a result, this will lower the bandwidth that you send to your users and your application will load much faster. +As a result, this will lower the egress that you send to your users and your application will load much faster. diff --git a/apps/docs/content/guides/telemetry/reports.mdx b/apps/docs/content/guides/telemetry/reports.mdx index 34fbd117671a2..49ab17a59aa76 100644 --- a/apps/docs/content/guides/telemetry/reports.mdx +++ b/apps/docs/content/guides/telemetry/reports.mdx @@ -270,7 +270,7 @@ The Storage report provides visibility into how your Supabase Storage is being u | --------------- | ------------------------------------------ | ------------------------------------------------------ | | Total Requests | Overall request volume to Storage | Traffic patterns and usage trends | | Response Speed | Average response time for storage requests | Performance bottlenecks and optimization opportunities | -| Network Traffic | Ingress and egress bandwidth usage | Data transfer costs and CDN effectiveness | +| Network Traffic | Ingress and egress usage | Data transfer costs and CDN effectiveness | | Request Caching | Cache hit rates and miss patterns | CDN performance and cost optimization | | Top Routes | Most frequently accessed storage paths | Popular content and usage patterns | @@ -305,5 +305,5 @@ The API Gateway report analyzes traffic patterns and performance characteristics | Total Requests | Overall API request volume | Traffic patterns and growth trends | | Response Errors | Error rates with 4XX and 5XX status codes | API reliability and user experience issues | | Response Speed | Average API response times | Performance bottlenecks and optimization targets | -| Network Traffic | Request and response bandwidth usage | Data transfer patterns and cost implications | +| Network Traffic | Request and response egress usage | Data transfer patterns and cost implications | | Top Routes | Most frequently accessed API endpoints | Usage patterns and optimization priorities | diff --git a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx index 78cd4f9dde736..974ce7498ac4d 100644 --- a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx +++ b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx @@ -31,4 +31,10 @@ While pointing out the exact cause for egress may not be straightforward, there - Reduce the number of queries/calls by optimising client code or use caches to reduce the number of requests/queries being done: https://github.com/psteinroe/supabase-cache-helpers/ - In case of update/insert queries, if you don’t need the entire row to be returned, configure your ORM/queries to not return the entire row - In case of running manual backups through Supavisor, remove unneeded tables and/or reduce the frequency -- For Storage, if you start using the [Smart CDN](https://supabase.com/docs/guides/storage/cdn/smart-cdn) Storage Egress usage can be managed. You can also use the [Supabase Image Transformations](https://supabase.com/docs/guides/storage/image-transformations) to optimize the images and reduce the egress. +- For Storage, if you start using the [Smart CDN](https://supabase.com/docs/guides/storage/cdn/smart-cdn) Storage Egress usage can be reduced. You can also use the [Supabase Image Transformations](https://supabase.com/docs/guides/storage/image-transformations) to optimize the images and reduce the egress. + +**Cached vs uncached egress** + +We differentiate between cached and uncached egress. Cached egress refers to egress that is served via our CDN and hits the cache. Uncached egress, on the other hand, refers to egress that is not served from the cache and requires a fresh request to the origin server. + +Your plan includes a quota for both cached and uncached egress and these are independent. Cached egress is also cheaper in case you exceed your quota. diff --git a/apps/docs/features/docs/Reference.navigation.client.tsx b/apps/docs/features/docs/Reference.navigation.client.tsx index b714555561a34..485f6542c784d 100644 --- a/apps/docs/features/docs/Reference.navigation.client.tsx +++ b/apps/docs/features/docs/Reference.navigation.client.tsx @@ -214,7 +214,7 @@ export function RefLink({ { +export const EnterpriseCard = ({ plan, isCurrentPlan }: EnterpriseCardProps) => { const { data: selectedOrganization } = useSelectedOrganizationQuery() const orgSlug = selectedOrganization?.slug - const features = pickFeatures(plan, billingPartner) + const features = plan.features const currentPlan = selectedOrganization?.plan.name const { mutate: sendEvent } = useSendEventMutation() diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 4059c82a68251..f73603a57b5da 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -21,7 +21,7 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { formatCurrency } from 'lib/helpers' -import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans' +import { plans as subscriptionsPlans } from 'shared-data/plans' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Button, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' @@ -164,18 +164,11 @@ const PlanUpdateSidePanel = () => { const isDowngradeOption = getPlanChangeType(subscription?.plan.id, plan?.planId) === 'downgrade' const isCurrentPlan = planMeta?.id === subscription?.plan?.id - const features = pickFeatures(plan, billingPartner) - const footer = pickFooter(plan, billingPartner) + const features = plan.features + const footer = plan.footer if (plan.id === 'tier_enterprise') { - return ( - - ) + return } return ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 3a5ff8dca18bc..46a5c8d02236f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -187,11 +187,11 @@ export const SubscriptionPlanUpdateDialog = ({ }) } - const features = subscriptionPlanMeta?.features?.[0]?.features || [] + const features = subscriptionPlanMeta?.features || [] const topFeatures = features // Get current plan features for downgrade comparison - const currentPlanFeatures = currentPlanMeta?.features?.[0]?.features || [] + const currentPlanFeatures = currentPlanMeta?.features || [] // Features that will be lost when downgrading const featuresToLose = diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts b/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts index 63c76f35d4d6f..9294e92daa14e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts @@ -3,6 +3,7 @@ import { PricingMetric } from 'data/analytics/org-daily-stats-query' const pricingMetricBytes = [ PricingMetric.DATABASE_SIZE, PricingMetric.EGRESS, + PricingMetric.CACHED_EGRESS, PricingMetric.STORAGE_SIZE, ] diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceAutoRenewalWarning.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceAutoRenewalWarning.tsx new file mode 100644 index 0000000000000..e8056bd2bd466 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceAutoRenewalWarning.tsx @@ -0,0 +1,37 @@ +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui' +import dayjs from 'dayjs' +import Link from 'next/link' + +interface Props { + awsContractEndDate: string + awsContractSettingsUrl: string +} + +const AwsMarketplaceAutoRenewalWarning = ({ + awsContractEndDate, + awsContractSettingsUrl, +}: Props) => { + return ( +
+ + + “Auto Renewal” is turned OFF for your AWS Marketplace subscription + + +
+ As a result, your Supabase organization will be downgraded to the Free Plan on{' '} + {dayjs(awsContractEndDate).format('MMMM DD')}. If you have more than 2 projects running, + all your projects will be paused. To ensure uninterrupted service, enable “Auto Renewal” + in your {''} + + AWS Marketplace subscription settings + + . +
+
+
+
+ ) +} + +export default AwsMarketplaceAutoRenewalWarning diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceCreateNewOrg.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceCreateNewOrg.tsx new file mode 100644 index 0000000000000..e9c9a2b7a9f6f --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceCreateNewOrg.tsx @@ -0,0 +1,94 @@ +import { useAwsManagedOrganizationCreateMutation } from '../../../../data/organizations/organization-create-mutation' +import { toast } from 'sonner' +import { SubmitHandler } from 'react-hook-form' +import NewAwsMarketplaceOrgForm, { + CREATE_AWS_MANAGED_ORG_FORM_ID, + NewMarketplaceOrgForm, +} from './NewAwsMarketplaceOrgForm' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from '../../../layouts/Scaffold' +import Link from 'next/link' +import { Button } from 'ui' +import { useRouter } from 'next/router' +import AwsMarketplaceAutoRenewalWarning from './AwsMarketplaceAutoRenewalWarning' +import { CloudMarketplaceOnboardingInfo } from './cloud-marketplace-query' + +interface Props { + onboardingInfo?: CloudMarketplaceOnboardingInfo | undefined +} + +const AwsMarketplaceCreateNewOrg = ({ onboardingInfo }: Props) => { + const router = useRouter() + const { + query: { buyer_id: buyerId }, + } = router + + const { mutate: createOrganization, isLoading: isCreatingOrganization } = + useAwsManagedOrganizationCreateMutation({ + onSuccess: (org) => { + //TODO(thomas): send tracking event? + router.push(`/org/${org.slug}`) + }, + onError: (res) => { + toast.error(res.message, { + duration: 7_000, + }) + }, + }) + + const onSubmit: SubmitHandler = async (values) => { + createOrganization({ ...values, buyerId: buyerId as string }) + } + + return ( + <> + {onboardingInfo && !onboardingInfo.aws_contract_auto_renewal && ( + + )} + + +

+ You’ve subscribed to the Supabase {onboardingInfo?.plan_name_selected_on_marketplace}{' '} + Plan via the AWS Marketplace. As a final step, you need to create a Supabase + organization. That organization will be managed and billed through AWS Marketplace. +

+

+ You can read more on billing through AWS in our {''} + {/*TODO(thomas): Update docs link once the new docs exist*/} + + Billing Docs. + +

+
+ +
+ + +
+ +
+
+
+
+ + ) +} + +export default AwsMarketplaceCreateNewOrg diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceLinkExistingOrg.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceLinkExistingOrg.tsx new file mode 100644 index 0000000000000..c00b2b3fa0eb4 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceLinkExistingOrg.tsx @@ -0,0 +1,342 @@ +import { + Button, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Form_Shadcn_, + FormField_Shadcn_, + Skeleton, +} from 'ui' +import { RadioGroupCard, RadioGroupCardItem } from '@ui/components/radio-group-card' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { cn } from '@ui/lib/utils' +import { ActionCard } from '../../../ui/ActionCard' +import { Boxes, ChevronRight } from 'lucide-react' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from '../../../layouts/Scaffold' +import { ButtonTooltip } from '../../../ui/ButtonTooltip' +import { z } from 'zod' +import { SubmitHandler, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useMemo, useState } from 'react' +import { Organization } from '../../../../types' +import { useProjectsQuery } from '../../../../data/projects/projects-query' +import { useOrganizationLinkAwsMarketplaceMutation } from '../../../../data/organizations/organization-link-aws-marketplace-mutation' +import { toast } from 'sonner' +import AwsMarketplaceOnboardingSuccessModal from './AwsMarketplaceOnboardingSuccessModal' +import NewAwsMarketplaceOrgModal from './NewAwsMarketplaceOrgModal' +import { useRouter } from 'next/router' +import AwsMarketplaceAutoRenewalWarning from './AwsMarketplaceAutoRenewalWarning' +import { CloudMarketplaceOnboardingInfo } from './cloud-marketplace-query' +import Link from 'next/link' + +interface Props { + organizations?: Organization[] | undefined + onboardingInfo?: CloudMarketplaceOnboardingInfo | undefined + isLoadingOnboardingInfo: boolean +} + +const FormSchema = z.object({ + orgSlug: z.string(), +}) + +export type LinkExistingOrgForm = z.infer + +const AwsMarketplaceLinkExistingOrg = ({ + organizations, + onboardingInfo, + isLoadingOnboardingInfo, +}: Props) => { + const router = useRouter() + const { + query: { buyer_id: buyerId }, + } = router + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + orgSlug: undefined, + }, + mode: 'onBlur', + reValidateMode: 'onChange', + }) + + const isDirty = !!Object.keys(form.formState.dirtyFields).length + + // Sort organizations by name ascending + const sortedOrganizations = useMemo(() => { + return organizations?.slice().sort((a, b) => a.name.localeCompare(b.name)) + }, [organizations]) + + const { orgsLinkable, orgsNotLinkable } = useMemo(() => { + const orgQualifiesForLinking = (org: Organization) => { + const validationResult = onboardingInfo?.organization_linking_eligibility.find( + (result) => result.slug === org.slug + ) + + return validationResult?.is_eligible ?? false + } + + const linkable: Organization[] = [] + const notLinkable: Organization[] = [] + sortedOrganizations?.forEach((org) => { + if (orgQualifiesForLinking(org)) { + linkable.push(org) + } else { + notLinkable.push(org) + } + }) + return { orgsLinkable: linkable, orgsNotLinkable: notLinkable } + }, [sortedOrganizations, onboardingInfo?.organization_linking_eligibility]) + + const { data: projects = [] } = useProjectsQuery() + + const [isNotLinkableOrgListOpen, setIsNotLinkableOrgListOpen] = useState(false) + const [orgLinkedSuccessfully, setOrgLinkedSuccessfully] = useState(false) + const [showOrgCreationDialog, setShowOrgCreationDialog] = useState(false) + const [orgToRedirectTo, setOrgToRedirectTo] = useState('') + + const { mutate: linkOrganization, isLoading: isLinkingOrganization } = + useOrganizationLinkAwsMarketplaceMutation({ + onSuccess: (_) => { + //TODO(thomas): send tracking event? + setOrgLinkedSuccessfully(true) + setOrgToRedirectTo(form.getValues('orgSlug')) + }, + onError: (res) => { + toast.error(res.message, { + duration: 7_000, + }) + }, + }) + + const onSubmit: SubmitHandler = async (values) => { + linkOrganization({ slug: values.orgSlug, buyerId: buyerId as string }) + } + + return ( + <> + {onboardingInfo && !onboardingInfo.aws_contract_auto_renewal && ( + + )} + + + <> +

+ You’ve subscribed to the Supabase {onboardingInfo?.plan_name_selected_on_marketplace}{' '} + Plan via the AWS Marketplace. As a final step, you need to link a Supabase + organization to that subscription. Select the organization you want to be managed and + billed through AWS. +

+ +

+ You can read more on billing through AWS in our {''} + {/*TODO(thomas): Update docs link once the new docs exist*/} + + Billing Docs. + +

+ +

+ Want to start fresh? Create a + new organization and it will be linked automatically. +

+ + +
+ + + +
+ ( + { + form.setValue('orgSlug', value, { + shouldDirty: true, + shouldValidate: false, + }) + }} + > + +
+ {isLoadingOnboardingInfo ? ( + Array(3) + .fill(0) + .map((_, i) => ( + + )) + ) : ( + <> +

+ Organizations that can be linked +

+ {orgsLinkable.length === 0 ? ( +

+ None of your organizations can be linked to your AWS Marketplace + subscription at the moment. +

+ ) : ( + <> + {orgsLinkable.map((org) => { + const numProjects = projects.filter( + (p) => p.organization_slug === org.slug + ).length + return ( + + } + title={org.name} + description={`${org.plan.name} Plan • ${numProjects > 0 ? `${numProjects} Project${numProjects > 1 ? 's' : ''}` : '0 Projects'}`} + /> + } + /> + ) + })} + + )} + + )} +
+
+
+ )} + /> + +
+ + {orgsNotLinkable.length > 0 && !isLoadingOnboardingInfo && ( + setIsNotLinkableOrgListOpen((prev) => !prev)} + > + +

+ Organizations that can't be linked +

+ +
+ +

+ The following organizations can’t be linked to your AWS Marketplace subscription + at the moment. This may be due to missing permissions, outstanding invoices, or an + existing marketplace link. If you'd like to link one of these organizations, + please review the organization settings. You need to be Owner or Administrator of + the organization to link it. +

+
+ {orgsNotLinkable.map((org) => { + const numProjects = projects.filter( + (p) => p.organization_slug === org.slug + ).length + return ( + } + title={org.name} + description={`${org.plan.name} Plan • ${numProjects > 0 ? `${numProjects} Project${numProjects > 1 ? 's' : ''}` : '0 Projects'}`} + /> + ) + })} +
+
+
+ )} + +
+ { + await onSubmit(form.getValues()) + }} + loading={isLinkingOrganization} + disabled={!isDirty || isLinkingOrganization || isLoadingOnboardingInfo} + tooltip={{ + content: { + side: 'top', + text: !isDirty ? 'No organization selected' : undefined, + }, + }} + > + Link organization + +
+
+
+ + { + setOrgLinkedSuccessfully(false) + router.push(`/org/${orgToRedirectTo}`) + }} + /> + + setShowOrgCreationDialog(false)} + buyerId={buyerId as string} + onSuccess={(newlyCreatedOrgSlug) => { + setShowOrgCreationDialog(false) + setOrgToRedirectTo(newlyCreatedOrgSlug) + setOrgLinkedSuccessfully(true) + }} + /> + + ) +} + +export default AwsMarketplaceLinkExistingOrg diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboardingPlaceholder.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboardingPlaceholder.tsx new file mode 100644 index 0000000000000..07f9c13abe427 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboardingPlaceholder.tsx @@ -0,0 +1,29 @@ +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from '../../../layouts/Scaffold' +import { Skeleton } from '@ui/components/shadcn/ui/skeleton' + +const AwsMarketplaceOnboardingPlaceholder = () => { + return ( + + + {Array(1) + .fill(0) + .map((_, i) => ( + + ))} + + + {Array(3) + .fill(0) + .map((_, i) => ( + + ))} + + + ) +} + +export default AwsMarketplaceOnboardingPlaceholder diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboardingSuccessModal.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboardingSuccessModal.tsx new file mode 100644 index 0000000000000..1402382228b41 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboardingSuccessModal.tsx @@ -0,0 +1,42 @@ +import { Dialog, DialogContent, DialogFooter, DialogSection } from '@ui/components/shadcn/ui/dialog' +import { Button } from 'ui' + +interface Props { + visible: boolean + onClose: () => void +} + +const AwsMarketplaceOnboardingSuccessModal = ({ visible, onClose }: Props) => { + return ( + { + if (!open) onClose() + }} + > + event.preventDefault()} + size="xlarge" + hideClose={true} + onEscapeKeyDown={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+

AWS Marketplace Setup completed

+

+ The organization is now managed and billed through AWS Marketplace. +

+
+
+ + + +
+
+ ) +} + +export default AwsMarketplaceOnboardingSuccessModal diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx new file mode 100644 index 0000000000000..cc27b6389c3a8 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgForm.tsx @@ -0,0 +1,159 @@ +import { z } from 'zod' +import { + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, + Label_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' + +const ORG_KIND_TYPES = { + PERSONAL: 'Personal', + EDUCATIONAL: 'Educational', + STARTUP: 'Startup', + AGENCY: 'Agency', + COMPANY: 'Company', + UNDISCLOSED: 'N/A', +} + +const ORG_KIND_DEFAULT = 'PERSONAL' + +const ORG_SIZE_TYPES = { + '1': '1 - 10', + '10': '10 - 49', + '50': '50 - 99', + '100': '100 - 299', + '300': 'More than 300', +} + +interface Props { + onSubmit: (values: NewMarketplaceOrgForm) => void +} + +export const CREATE_AWS_MANAGED_ORG_FORM_ID = 'create-aws-managed-org-form' + +const FormSchema = z.object({ + name: z.string().trim().min(1, 'Please provide an organization name'), + kind: z.string(), + size: z.string().optional(), +}) + +export type NewMarketplaceOrgForm = z.infer + +const NewAwsMarketplaceOrgForm = ({ onSubmit }: Props) => { + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: '', + kind: ORG_KIND_DEFAULT, + }, + }) + + const kind = form.watch('kind') + + return ( + +
+
+ ( + + + <> + +
+ + What's the name of your company or team? + +
+ +
+
+ )} + /> + ( + + + <> + + + + + + {Object.entries(ORG_KIND_TYPES).map(([k, v]) => ( + + {v} + + ))} + + +
+ + What would best describe your organization? + +
+ +
+
+ )} + /> + {kind == 'COMPANY' && ( + ( + + + <> + + + + + + {Object.entries(ORG_SIZE_TYPES).map(([k, v]) => ( + + {v} + + ))} + + +
+ + How many people are in your company? + +
+ +
+
+ )} + /> + )} +
+
+
+ ) +} + +export default NewAwsMarketplaceOrgForm diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgModal.tsx b/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgModal.tsx new file mode 100644 index 0000000000000..13a31c4be6054 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/NewAwsMarketplaceOrgModal.tsx @@ -0,0 +1,84 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, +} from '@ui/components/shadcn/ui/dialog' +import { Button } from 'ui' +import NewAwsMarketplaceOrgForm, { + CREATE_AWS_MANAGED_ORG_FORM_ID, + NewMarketplaceOrgForm, +} from './NewAwsMarketplaceOrgForm' +import { useAwsManagedOrganizationCreateMutation } from '../../../../data/organizations/organization-create-mutation' +import { toast } from 'sonner' +import { SubmitHandler } from 'react-hook-form' + +interface Props { + buyerId: string + visible: boolean + onSuccess: (newlyCreatedOrgSlug: string) => void + onClose: () => void +} + +const NewAwsMarketplaceOrgModal = ({ buyerId, visible, onSuccess, onClose }: Props) => { + const { mutate: createOrganization, isLoading: isCreatingOrganization } = + useAwsManagedOrganizationCreateMutation({ + onSuccess: (org) => { + //TODO(thomas): send tracking event? + onSuccess(org.slug) + }, + onError: (res) => { + toast.error(res.message, { + duration: 7_000, + }) + }, + }) + + const onSubmit: SubmitHandler = async (values) => { + createOrganization({ ...values, buyerId }) + } + + return ( + { + if (!open) onClose() + }} + > + event.preventDefault()} + size="xlarge" + onEscapeKeyDown={(e) => (isCreatingOrganization ? e.preventDefault() : onClose())} + onPointerDownOutside={(e) => (isCreatingOrganization ? e.preventDefault() : onClose())} + className="p-2" + > + + Create a new organization + + A new organization will be created and linked to your AWS Marketplace subscription + + + + + + + + + + + + ) +} + +export default NewAwsMarketplaceOrgModal diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/cloud-marketplace-query.ts b/apps/studio/components/interfaces/Organization/CloudMarketplace/cloud-marketplace-query.ts new file mode 100644 index 0000000000000..0b9d924aa4c7e --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/cloud-marketplace-query.ts @@ -0,0 +1,50 @@ +import { get, handleError } from '../../../../data/fetchers' +import type { ResponseError } from '../../../../types' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { useProfile } from '../../../../lib/profile' +import { cloudMarketplaceKeys } from './keys' + +export type CloudMarketplaceOnboardingInfoVariables = { + buyerId: string +} + +export async function getCloudMarketplaceOnboardingInfo( + { buyerId }: CloudMarketplaceOnboardingInfoVariables, + signal?: AbortSignal +) { + const { data, error } = await get( + '/platform/cloud-marketplace/buyers/{buyer_id}/onboarding-info', + { + params: { path: { buyer_id: buyerId } }, + signal, + } + ) + + if (error) handleError(error) + + return data +} + +export type CloudMarketplaceOnboardingInfo = Awaited< + ReturnType +> +export type CloudMarketplaceOnboardingInfoError = ResponseError + +export const useCloudMarketplaceOnboardingInfoQuery = ( + { buyerId }: CloudMarketplaceOnboardingInfoVariables, + { + enabled = true, + ...options + }: UseQueryOptions< + CloudMarketplaceOnboardingInfo, + CloudMarketplaceOnboardingInfoError, + TData + > = {} +) => { + const { profile } = useProfile() + return useQuery( + cloudMarketplaceKeys.onboardingInfo(buyerId), + ({ signal }) => getCloudMarketplaceOnboardingInfo({ buyerId }, signal), + { enabled: enabled && profile !== undefined, ...options, staleTime: 30 * 60 * 1000 } + ) +} diff --git a/apps/studio/components/interfaces/Organization/CloudMarketplace/keys.ts b/apps/studio/components/interfaces/Organization/CloudMarketplace/keys.ts new file mode 100644 index 0000000000000..790b1e1332914 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/CloudMarketplace/keys.ts @@ -0,0 +1,3 @@ +export const cloudMarketplaceKeys = { + onboardingInfo: (buyerId: string) => ['cloud-marketplace', 'onboarding-info', buyerId], +} diff --git a/apps/studio/components/interfaces/Organization/Usage/Bandwidth.tsx b/apps/studio/components/interfaces/Organization/Usage/Egress.tsx similarity index 71% rename from apps/studio/components/interfaces/Organization/Usage/Bandwidth.tsx rename to apps/studio/components/interfaces/Organization/Usage/Egress.tsx index f6bbf70669bcd..0ce4d8e2d9e00 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Bandwidth.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Egress.tsx @@ -3,7 +3,7 @@ import { PricingMetric, useOrgDailyStatsQuery } from 'data/analytics/org-daily-s import type { OrgSubscription } from 'data/subscriptions/types' import UsageSection from './UsageSection/UsageSection' -export interface BandwidthProps { +export interface EgressProps { orgSlug: string projectRef?: string startDate: string | undefined @@ -12,14 +12,14 @@ export interface BandwidthProps { currentBillingCycleSelected: boolean } -const Bandwidth = ({ +const Egress = ({ orgSlug, projectRef, subscription, startDate, endDate, currentBillingCycleSelected, -}: BandwidthProps) => { +}: EgressProps) => { const { data: egressData, isLoading: isLoadingDbEgressData } = useOrgDailyStatsQuery({ orgSlug, projectRef, @@ -29,6 +29,15 @@ const Bandwidth = ({ endDate, }) + const { data: cachedEgressData, isLoading: isLoadingCachedEgress } = useOrgDailyStatsQuery({ + orgSlug, + projectRef, + metric: PricingMetric.CACHED_EGRESS, + interval: '1d', + startDate, + endDate, + }) + const chartMeta: { [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean } } = { @@ -37,13 +46,18 @@ const Bandwidth = ({ margin: 16, isLoading: isLoadingDbEgressData, }, + [PricingMetric.CACHED_EGRESS]: { + data: cachedEgressData?.data ?? [], + margin: 16, + isLoading: isLoadingCachedEgress, + }, } return ( JSX.Element | null } -export type CategoryMetaKey = 'bandwidth' | 'sizeCount' | 'activity' | 'compute' +export type CategoryMetaKey = 'egress' | 'sizeCount' | 'activity' | 'compute' export interface CategoryMeta { key: CategoryMetaKey @@ -72,249 +72,285 @@ export interface CategoryMeta { export const USAGE_CATEGORIES: (subscription?: OrgSubscription) => CategoryMeta[] = ( subscription -) => [ - { - key: 'bandwidth', - name: 'Bandwidth', - description: 'Amount of data transmitted over all network connections', - attributes: [ - { - anchor: 'egress', - key: PricingMetric.EGRESS, - attributes: [ - { key: EgressType.AUTH, name: 'Auth Egress', color: 'yellow' }, - { key: EgressType.DATABASE, name: 'Database Egress', color: 'green' }, - { key: EgressType.STORAGE, name: 'Storage Egress', color: 'blue' }, - { key: EgressType.REALTIME, name: 'Realtime Egress', color: 'orange' }, - { key: EgressType.FUNCTIONS, name: 'Functions Egress', color: 'purple' }, - { key: EgressType.SUPAVISOR, name: 'Shared Pooler Egress', color: 'red' }, - { key: EgressType.LOGDRAIN, name: 'Logdrain Egress', color: 'teal' }, - ], - name: 'Total Egress', - unit: 'bytes', - description: - 'Contains any outgoing traffic including Database, Storage, Realtime, Auth, API, Edge Functions, Pooler and Log Drains.\nBilling is based on the total sum of egress in GB throughout your billing period.', - chartDescription: 'The data refreshes every 24 hours.', - }, - ], - }, - { - key: 'sizeCount', - name: 'Database & Storage Size', - description: 'Amount of resources your project is consuming', - attributes: [ - subscription?.plan.id === 'free' - ? { - anchor: 'dbSize', - key: PricingMetric.DATABASE_SIZE, - attributes: [{ key: PricingMetric.DATABASE_SIZE.toLowerCase(), color: 'white' }], - name: 'Database size', - chartPrefix: 'Average', - unit: 'bytes', - description: - 'Database size refers to the actual amount of space used by all your database objects, as reported by Postgres.', - links: [ - { - name: 'Documentation', - url: 'https://supabase.com/docs/guides/platform/database-size', - }, - ], - chartDescription: 'The data refreshes every 24 hours.', - additionalInfo: (usage?: OrgUsageResponse) => { - const usageMeta = usage?.usages.find((x) => x.metric === PricingMetric.DATABASE_SIZE) - const usageRatio = - typeof usageMeta !== 'number' - ? (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0) - : 0 - const hasLimit = usageMeta && (usageMeta?.pricing_free_units ?? 0) > 0 +) => { + const egressAttributes: CategoryAttribute[] = [ + { + anchor: 'egress', + key: PricingMetric.EGRESS, + attributes: [ + { key: EgressType.AUTH, name: 'Auth Egress', color: 'yellow' }, + { key: EgressType.DATABASE, name: 'Database Egress', color: 'green' }, + { key: EgressType.STORAGE, name: 'Storage Egress', color: 'blue' }, + { key: EgressType.REALTIME, name: 'Realtime Egress', color: 'orange' }, + { key: EgressType.FUNCTIONS, name: 'Functions Egress', color: 'purple' }, + { key: EgressType.SUPAVISOR, name: 'Shared Pooler Egress', color: 'red' }, + { key: EgressType.LOGDRAIN, name: 'Logdrain Egress', color: 'teal' }, + ], + name: 'Egress', + unit: 'bytes', + description: + subscription?.cached_egress_enabled === true + ? 'Contains any outgoing traffic including Database, Storage, Realtime, Auth, API, Edge Functions, Pooler and Log Drains.\nBilling is based on the total sum of uncached egress in GB throughout your billing period.\nEgress via cache hits is billed separately.' + : 'Contains any outgoing traffic including Database, Storage, Realtime, Auth, API, Edge Functions, Pooler and Log Drains.\nBilling is based on the total sum of uncached egress in GB throughout your billing period.', + chartDescription: + 'The breakdown of different egress types is inclusive of cached egress, even though it is billed separately. The data refreshes every 24 hours.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/platform/manage-your-usage/egress', + }, + ], + }, + ] + + if (subscription?.cached_egress_enabled) { + egressAttributes.push({ + anchor: 'cachedEgress', + key: PricingMetric.CACHED_EGRESS, + attributes: [{ key: PricingMetric.CACHED_EGRESS.toLowerCase(), color: 'white' }], + name: 'Cached Egress', + unit: 'bytes', + description: + 'Contains any outgoing traffic that is served from a cache hit. Includes API, Storage and Edge Functions.\nBilling is based on the total sum of cached egress in GB throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/platform/manage-your-usage/egress', + }, + ], + }) + } - const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD - const isExceededLimit = hasLimit && usageRatio >= 1 - const isCapped = usageMeta?.capped + return [ + { + key: 'egress', + name: 'Egress', + description: 'Amount of data transmitted over all network connections', + attributes: egressAttributes, + }, + { + key: 'sizeCount', + name: 'Database & Storage Size', + description: 'Amount of resources your project is consuming', + attributes: [ + subscription?.plan.id === 'free' + ? { + anchor: 'dbSize', + key: PricingMetric.DATABASE_SIZE, + attributes: [{ key: PricingMetric.DATABASE_SIZE.toLowerCase(), color: 'white' }], + name: 'Database size', + chartPrefix: 'Average', + unit: 'bytes', + description: + 'Database size refers to the actual amount of space used by all your database objects, as reported by Postgres.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/platform/database-size', + }, + ], + chartDescription: 'The data refreshes every 24 hours.', + additionalInfo: (usage?: OrgUsageResponse) => { + const usageMeta = usage?.usages.find( + (x) => x.metric === PricingMetric.DATABASE_SIZE + ) + const usageRatio = + typeof usageMeta !== 'number' + ? (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0) + : 0 + const hasLimit = usageMeta && (usageMeta?.pricing_free_units ?? 0) > 0 - const onFreePlan = subscription?.plan?.name === 'Free' + const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD + const isExceededLimit = hasLimit && usageRatio >= 1 + const isCapped = usageMeta?.capped - return ( -
- {(isApproachingLimit || isExceededLimit) && isCapped && ( - -
-
- When you reach your database size limit, your project can go into - read-only mode.{' '} - {onFreePlan - ? 'Please upgrade your Plan.' - : "Disable your spend cap to scale seamlessly, and pay for over-usage beyond your Plan's quota."} + const onFreePlan = subscription?.plan?.name === 'Free' + + return ( +
+ {(isApproachingLimit || isExceededLimit) && isCapped && ( + +
+
+ When you reach your database size limit, your project can go into + read-only mode.{' '} + {onFreePlan + ? 'Please upgrade your Plan.' + : "Disable your spend cap to scale seamlessly, and pay for over-usage beyond your Plan's quota."} +
-
- - )} -
- ) - }, - } - : { - anchor: 'diskSize', - key: 'diskSize', - attributes: [], - name: 'Disk size', - chartPrefix: 'Average', - unit: 'bytes', - description: - "Each Supabase project comes with a dedicated disk. Each project gets 8 GB of disk for free. Billing is based on the provisioned disk size. Disk automatically scales up when you get close to it's size.\nEach hour your project is using more than 8 GB of GP3 disk, it incurs the overages in GB-Hrs, i.e. a 16 GB disk incurs 8 GB-Hrs every hour. Extra disk size costs $0.125/GB/month ($0.000171/GB-Hr).", - links: [ - { - name: 'Documentation', - url: 'https://supabase.com/docs/guides/platform/manage-your-usage/disk-size', + + )} +
+ ) }, - { - name: 'Disk Management', - url: 'https://supabase.com/docs/guides/platform/database-size#disk-management', - }, - ], - chartDescription: '', - }, - { - anchor: 'storageSize', - key: PricingMetric.STORAGE_SIZE, - attributes: [{ key: PricingMetric.STORAGE_SIZE.toLowerCase(), color: 'white' }], - name: 'Storage Size', - chartPrefix: 'Average', - unit: 'bytes', - description: - 'Sum of all objects in your storage buckets.\nBilling is prorated down to the hour and will be displayed GB-Hrs.', - chartDescription: 'The data refreshes every 24 hours.', - links: [ - { - name: 'Storage', - url: 'https://supabase.com/docs/guides/storage', - }, - ], - }, - ], - }, - { - key: 'activity', - name: 'Activity', - description: 'Usage statistics that reflect the activity of your project', - attributes: [ - { - anchor: 'mau', - key: PricingMetric.MONTHLY_ACTIVE_USERS, - attributes: [{ key: PricingMetric.MONTHLY_ACTIVE_USERS.toLowerCase(), color: 'white' }], - name: 'Monthly Active Users', - chartPrefix: 'Cumulative', - chartSuffix: 'in billing period', - unit: 'absolute', - description: - 'Users who log in or refresh their token count towards MAU.\nBilling is based on the sum of distinct users requesting your API throughout the billing period. Resets every billing cycle.', - chartDescription: - 'The data is refreshed over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period and will reset with your billing period.', - links: [ - { - name: 'Auth', - url: 'https://supabase.com/docs/guides/auth', - }, - ], - }, - { - anchor: 'mauSso', - key: PricingMetric.MONTHLY_ACTIVE_SSO_USERS, - attributes: [{ key: PricingMetric.MONTHLY_ACTIVE_SSO_USERS.toLowerCase(), color: 'white' }], - name: 'Monthly Active SSO Users', - chartPrefix: 'Cumulative', - chartSuffix: 'in billing period', - unit: 'absolute', - description: - 'SSO users who log in or refresh their token count towards SSO MAU.\nBilling is based on the sum of distinct Single Sign-On users requesting your API throughout the billing period. Resets every billing cycle.', - chartDescription: - 'The data refreshes over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period and will reset with your billing period.', - links: [ - { - name: 'SSO with SAML 2.0', - url: 'https://supabase.com/docs/guides/auth/sso/auth-sso-saml', - }, - ], - }, - { - anchor: 'storageImageTransformations', - key: PricingMetric.STORAGE_IMAGES_TRANSFORMED, - attributes: [ - { key: PricingMetric.STORAGE_IMAGES_TRANSFORMED.toLowerCase(), color: 'white' }, - ], - name: 'Storage Image Transformations', - chartPrefix: 'Cumulative', - chartSuffix: 'in billing period', - unit: 'absolute', - description: - 'We count all images that were transformed in the billing period, ignoring any transformations.\nUsage example: You transform one image with four different size transformations and another image with just a single transformation. It counts as two, as only two images were transformed.\nBilling is based on the count of (origin) images that used transformations throughout the billing period. Resets every billing cycle.', - chartDescription: - 'The data refreshes every 24 hours.\nThe data points are relative to the beginning of your billing period and will reset with your billing period.', - links: [ - { - name: 'Documentation', - url: 'https://supabase.com/docs/guides/storage/image-transformations', - }, - ], - }, - { - anchor: 'funcInvocations', - key: PricingMetric.FUNCTION_INVOCATIONS, - attributes: [{ key: PricingMetric.FUNCTION_INVOCATIONS.toLowerCase(), color: 'white' }], - name: 'Edge Function Invocations', - unit: 'absolute', - description: - 'Every serverless function invocation independent of response status is counted.\nBilling is based on the sum of all invocations throughout your billing period.', - chartDescription: 'The data refreshes every 24 hours.', - links: [ - { - name: 'Edge Functions', - url: 'https://supabase.com/docs/guides/functions', - }, - ], - }, - { - anchor: 'realtimeMessageCount', - key: PricingMetric.REALTIME_MESSAGE_COUNT, - attributes: [{ key: PricingMetric.REALTIME_MESSAGE_COUNT.toLowerCase(), color: 'white' }], - name: 'Realtime Messages', - unit: 'absolute', - description: - "Count of messages going through Realtime. Includes database changes, broadcast and presence. \nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.", - chartDescription: 'The data refreshes every 24 hours.', - links: [ - { - name: 'Realtime Quotas', - url: 'https://supabase.com/docs/guides/realtime/quotas', - }, - ], - }, - { - anchor: 'realtimePeakConnections', - key: PricingMetric.REALTIME_PEAK_CONNECTIONS, - attributes: [ - { key: PricingMetric.REALTIME_PEAK_CONNECTIONS.toLowerCase(), color: 'white' }, - ], - name: 'Realtime Concurrent Peak Connections', - chartPrefix: 'Max', - unit: 'absolute', - description: - 'Total number of successful connections. Connections attempts are not counted towards usage.\nBilling is based on the maximum amount of concurrent peak connections throughout your billing period.', - chartDescription: 'The data refreshes every 24 hours.', - links: [ - { - name: 'Realtime Quotas', - url: 'https://supabase.com/docs/guides/realtime/quotas', - }, - ], - }, - ], - }, -] + } + : { + anchor: 'diskSize', + key: 'diskSize', + attributes: [], + name: 'Disk size', + chartPrefix: 'Average', + unit: 'bytes', + description: + "Each Supabase project comes with a dedicated disk. Each project gets 8 GB of disk for free. Billing is based on the provisioned disk size. Disk automatically scales up when you get close to it's size.\nEach hour your project is using more than 8 GB of GP3 disk, it incurs the overages in GB-Hrs, i.e. a 16 GB disk incurs 8 GB-Hrs every hour. Extra disk size costs $0.125/GB/month ($0.000171/GB-Hr).", + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/platform/manage-your-usage/disk-size', + }, + { + name: 'Disk Management', + url: 'https://supabase.com/docs/guides/platform/database-size#disk-management', + }, + ], + chartDescription: '', + }, + { + anchor: 'storageSize', + key: PricingMetric.STORAGE_SIZE, + attributes: [{ key: PricingMetric.STORAGE_SIZE.toLowerCase(), color: 'white' }], + name: 'Storage Size', + chartPrefix: 'Average', + unit: 'bytes', + description: + 'Sum of all objects in your storage buckets.\nBilling is prorated down to the hour and will be displayed GB-Hrs.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Storage', + url: 'https://supabase.com/docs/guides/storage', + }, + ], + }, + ], + }, + { + key: 'activity', + name: 'Activity', + description: 'Usage statistics that reflect the activity of your project', + attributes: [ + { + anchor: 'mau', + key: PricingMetric.MONTHLY_ACTIVE_USERS, + attributes: [{ key: PricingMetric.MONTHLY_ACTIVE_USERS.toLowerCase(), color: 'white' }], + name: 'Monthly Active Users', + chartPrefix: 'Cumulative', + chartSuffix: 'in billing period', + unit: 'absolute', + description: + 'Users who log in or refresh their token count towards MAU.\nBilling is based on the sum of distinct users requesting your API throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data is refreshed over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period and will reset with your billing period.', + links: [ + { + name: 'Auth', + url: 'https://supabase.com/docs/guides/auth', + }, + ], + }, + { + anchor: 'mauSso', + key: PricingMetric.MONTHLY_ACTIVE_SSO_USERS, + attributes: [ + { key: PricingMetric.MONTHLY_ACTIVE_SSO_USERS.toLowerCase(), color: 'white' }, + ], + name: 'Monthly Active SSO Users', + chartPrefix: 'Cumulative', + chartSuffix: 'in billing period', + unit: 'absolute', + description: + 'SSO users who log in or refresh their token count towards SSO MAU.\nBilling is based on the sum of distinct Single Sign-On users requesting your API throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data refreshes over a period of 24 hours and resets at the beginning of every billing period.\nThe data points are relative to the beginning of your billing period and will reset with your billing period.', + links: [ + { + name: 'SSO with SAML 2.0', + url: 'https://supabase.com/docs/guides/auth/sso/auth-sso-saml', + }, + ], + }, + { + anchor: 'storageImageTransformations', + key: PricingMetric.STORAGE_IMAGES_TRANSFORMED, + attributes: [ + { key: PricingMetric.STORAGE_IMAGES_TRANSFORMED.toLowerCase(), color: 'white' }, + ], + name: 'Storage Image Transformations', + chartPrefix: 'Cumulative', + chartSuffix: 'in billing period', + unit: 'absolute', + description: + 'We count all images that were transformed in the billing period, ignoring any transformations.\nUsage example: You transform one image with four different size transformations and another image with just a single transformation. It counts as two, as only two images were transformed.\nBilling is based on the count of (origin) images that used transformations throughout the billing period. Resets every billing cycle.', + chartDescription: + 'The data refreshes every 24 hours.\nThe data points are relative to the beginning of your billing period and will reset with your billing period.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/storage/image-transformations', + }, + ], + }, + { + anchor: 'funcInvocations', + key: PricingMetric.FUNCTION_INVOCATIONS, + attributes: [{ key: PricingMetric.FUNCTION_INVOCATIONS.toLowerCase(), color: 'white' }], + name: 'Edge Function Invocations', + unit: 'absolute', + description: + 'Every serverless function invocation independent of response status is counted.\nBilling is based on the sum of all invocations throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Edge Functions', + url: 'https://supabase.com/docs/guides/functions', + }, + ], + }, + { + anchor: 'realtimeMessageCount', + key: PricingMetric.REALTIME_MESSAGE_COUNT, + attributes: [{ key: PricingMetric.REALTIME_MESSAGE_COUNT.toLowerCase(), color: 'white' }], + name: 'Realtime Messages', + unit: 'absolute', + description: + "Count of messages going through Realtime. Includes database changes, broadcast and presence. \nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.", + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Realtime Quotas', + url: 'https://supabase.com/docs/guides/realtime/quotas', + }, + ], + }, + { + anchor: 'realtimePeakConnections', + key: PricingMetric.REALTIME_PEAK_CONNECTIONS, + attributes: [ + { key: PricingMetric.REALTIME_PEAK_CONNECTIONS.toLowerCase(), color: 'white' }, + ], + name: 'Realtime Concurrent Peak Connections', + chartPrefix: 'Max', + unit: 'absolute', + description: + 'Total number of successful connections. Connections attempts are not counted towards usage.\nBilling is based on the maximum amount of concurrent peak connections throughout your billing period.', + chartDescription: 'The data refreshes every 24 hours.', + links: [ + { + name: 'Realtime Quotas', + url: 'https://supabase.com/docs/guides/realtime/quotas', + }, + ], + }, + ], + }, + ] +} diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index 93796f2623efc..80a68493edf73 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -22,7 +22,7 @@ import { cn, Listbox } from 'ui' import { Admonition } from 'ui-patterns' import { Restriction } from '../BillingSettings/Restriction' import Activity from './Activity' -import Bandwidth from './Bandwidth' +import Egress from './Egress' import Compute from './Compute' import SizeAndCounts from './SizeAndCounts' import TotalUsage from './TotalUsage' @@ -234,7 +234,7 @@ const Usage = () => { /> )} - ) => { + const { resolvedTheme } = useTheme() + return ( + <> + + AWS Marketplace Setup | Supabase + +
+
+
+
+
+
+ Supabase + Supabase Logo +
+
+
+
+
+ +
+ {children} +
+
+ + ) +} + +export default withAuth(LinkAwsMarketplaceLayout) diff --git a/apps/studio/data/analytics/org-daily-stats-query.ts b/apps/studio/data/analytics/org-daily-stats-query.ts index 56e50f998e866..6a6e24f1ade0a 100644 --- a/apps/studio/data/analytics/org-daily-stats-query.ts +++ b/apps/studio/data/analytics/org-daily-stats-query.ts @@ -19,6 +19,7 @@ export enum EgressType { // [Joshen] Get this from common package instead of API and dashboard having one copy each export enum PricingMetric { EGRESS = 'EGRESS', + CACHED_EGRESS = 'CACHED_EGRESS', DATABASE_SIZE = 'DATABASE_SIZE', STORAGE_SIZE = 'STORAGE_SIZE', DISK_SIZE_GB_HOURS_GP3 = 'DISK_SIZE_GB_HOURS_GP3', diff --git a/apps/studio/data/organizations/organization-create-mutation.ts b/apps/studio/data/organizations/organization-create-mutation.ts index 5baffba7b9824..93024acbb3be4 100644 --- a/apps/studio/data/organizations/organization-create-mutation.ts +++ b/apps/studio/data/organizations/organization-create-mutation.ts @@ -93,3 +93,82 @@ export const useOrganizationCreateMutation = ({ } ) } + +export type AwsManagedOrganizationCreateVariables = { + name: string + kind?: string + size?: string + buyerId: string +} + +export async function createAwsManagedOrganization({ + name, + kind, + size, + buyerId, +}: AwsManagedOrganizationCreateVariables) { + const { data, error } = await post('/platform/organizations/cloud-marketplace', { + body: { + name, + kind, + size, + buyer_id: buyerId, + }, + }) + + if (error) handleError(error) + return data +} + +type AwsManagedOrganizationCreateData = Awaited> + +export const useAwsManagedOrganizationCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + AwsManagedOrganizationCreateData, + ResponseError, + AwsManagedOrganizationCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + AwsManagedOrganizationCreateData, + ResponseError, + AwsManagedOrganizationCreateVariables + >((vars) => createAwsManagedOrganization(vars), { + async onSuccess(data, variables, context) { + if (data) { + // [Joshen] We're manually updating the query client here as the org's subscription is + // created async, and the invalidation will happen too quick where the GET organizations + // endpoint will error out with a 500 since the subscription isn't created yet. + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: any) => { + if (!prev) return prev + return [...prev, castOrganizationResponseToOrganization(data)] + } + ) + + await queryClient.invalidateQueries(permissionKeys.list()) + } + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create organization: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/organizations/organization-link-aws-marketplace-mutation.ts b/apps/studio/data/organizations/organization-link-aws-marketplace-mutation.ts new file mode 100644 index 0000000000000..a8ac033a76499 --- /dev/null +++ b/apps/studio/data/organizations/organization-link-aws-marketplace-mutation.ts @@ -0,0 +1,51 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, put } from '../fetchers' +import type { ResponseError } from '../../types' + +export type OrganizationLinkAwsMarketplaceVariables = { + buyerId: string + slug: string +} + +export async function linkOrganization({ buyerId, slug }: OrganizationLinkAwsMarketplaceVariables) { + const { data, error } = await put(`/platform/organizations/{slug}/cloud-marketplace/link`, { + params: { path: { slug } }, + body: { + buyer_id: buyerId, + }, + }) + + if (error) handleError(error) + + return data +} + +type LinkOrganizationData = Awaited> + +export const useOrganizationLinkAwsMarketplaceMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation( + (vars) => linkOrganization(vars), + { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to link organization to AWS Marketplace: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/lib/constants/metrics.tsx b/apps/studio/lib/constants/metrics.tsx index 4ed2ed1a67cdb..5f17b6b805332 100644 --- a/apps/studio/lib/constants/metrics.tsx +++ b/apps/studio/lib/constants/metrics.tsx @@ -287,6 +287,14 @@ export const METRICS: Metric[] = [ provider: 'daily-stats', category: METRIC_CATEGORIES.API, }, + + { + key: 'total_cached_egress', + label: 'All Cached Egress', + provider: 'daily-stats', + category: METRIC_CATEGORIES.API_STORAGE, + }, + { key: 'total_storage_patch_requests', label: 'Storage PATCH Requests', diff --git a/apps/studio/pages/api/platform/organizations/[slug]/billing/subscription.ts b/apps/studio/pages/api/platform/organizations/[slug]/billing/subscription.ts index dfc1d786321eb..e5b946a8286fc 100644 --- a/apps/studio/pages/api/platform/organizations/[slug]/billing/subscription.ts +++ b/apps/studio/pages/api/platform/organizations/[slug]/billing/subscription.ts @@ -38,6 +38,7 @@ const handleGet = async (req: NextApiRequest, res: NextApiResponse billing_partner: 'fly', scheduled_plan_change: null, customer_balance: 0, + cached_egress_enabled: false, } return res.status(200).json(response) diff --git a/apps/studio/pages/aws-marketplace-onboarding.tsx b/apps/studio/pages/aws-marketplace-onboarding.tsx new file mode 100644 index 0000000000000..0faea387ebf4a --- /dev/null +++ b/apps/studio/pages/aws-marketplace-onboarding.tsx @@ -0,0 +1,52 @@ +import { NextPageWithLayout } from '../types' +import { + ScaffoldContainer, + ScaffoldDivider, + ScaffoldHeader, + ScaffoldTitle, +} from '../components/layouts/Scaffold' +import LinkAwsMarketplaceLayout from '../components/layouts/LinkAwsMarketplaceLayout' +import { useOrganizationsQuery } from '../data/organizations/organizations-query' +import AwsMarketplaceLinkExistingOrg from '../components/interfaces/Organization/CloudMarketplace/AwsMarketplaceLinkExistingOrg' +import AwsMarketplaceCreateNewOrg from '../components/interfaces/Organization/CloudMarketplace/AwsMarketplaceCreateNewOrg' +import { useCloudMarketplaceOnboardingInfoQuery } from '../components/interfaces/Organization/CloudMarketplace/cloud-marketplace-query' +import { useRouter } from 'next/router' +import AwsMarketplaceOnboardingPlaceholder from '../components/interfaces/Organization/CloudMarketplace/AwsMarketplaceOnboardingPlaceholder' + +const AwsMarketplaceOnboarding: NextPageWithLayout = () => { + const { + query: { buyer_id: buyerId }, + } = useRouter() + + const { data: organizations, isFetched: isOrganizationsFetched } = useOrganizationsQuery() + const { data: onboardingInfo, isLoading: isLoadingOnboardingInfo } = + useCloudMarketplaceOnboardingInfoQuery({ + buyerId: buyerId as string, + }) + + return ( + + + AWS Marketplace Setup + + + {!isOrganizationsFetched ? ( + + ) : organizations?.length ? ( + + ) : ( + + )} + + ) +} + +AwsMarketplaceOnboarding.getLayout = (page) => ( + {page} +) + +export default AwsMarketplaceOnboarding diff --git a/apps/www/components/Pricing/PricingPlans.tsx b/apps/www/components/Pricing/PricingPlans.tsx index fade95855d958..fc5c42fb5a20e 100644 --- a/apps/www/components/Pricing/PricingPlans.tsx +++ b/apps/www/components/Pricing/PricingPlans.tsx @@ -1,7 +1,7 @@ import Link from 'next/link' import { Check } from 'lucide-react' -import { pickFeatures, pickFooter, plans } from 'shared-data/plans' +import { plans } from 'shared-data/plans' import { Button, cn } from 'ui' import { Organization } from '~/data/organizations' import { useSendTelemetryEvent } from '~/lib/telemetry' @@ -23,8 +23,8 @@ const PricingPlans = ({ organizations, hasExistingOrganizations }: PricingPlansP const isProPlan = plan.name === 'Pro' const isTeamPlan = plan.name === 'Team' const isUpgradablePlan = isProPlan || isTeamPlan - const features = pickFeatures(plan) - const footer = pickFooter(plan) + const features = plan.features + const footer = plan.footer const sendPricingEvent = () => { sendTelemetryEvent({ diff --git a/apps/www/components/Pricing/PricingTableRow.tsx b/apps/www/components/Pricing/PricingTableRow.tsx index 4ea9e4f01ded6..9e92602131832 100644 --- a/apps/www/components/Pricing/PricingTableRow.tsx +++ b/apps/www/components/Pricing/PricingTableRow.tsx @@ -35,9 +35,11 @@ export const pricingTooltips: PricingTooltips = { 'database.pausing': { main: 'Projects that have no activity or API requests will be paused. They can be reactivated via the dashboard.', }, - - 'database.bandwidth': { - main: 'Billing is based on the total sum of all outgoing traffic (includes Database, Storage, Realtime, Auth, API, Edge Functions, Supavisor, Log Drains) in GB throughout your billing period.', + 'database.egress': { + main: 'Billing is based on the total sum of all outgoing traffic (includes Database, Storage, Realtime, Auth, API, Edge Functions, Supavisor, Log Drains) in GB throughout your billing period. Excludes cache hits.', + }, + 'database.cachedEgress': { + main: 'Billing is based on the total sum of any outgoing traffic (includes Database, Storage, API, Edge Functions) in GB throughout your billing period that is served from our CDN cache.', }, 'auth.totalUsers': { main: 'The maximum number of users your project can have', @@ -84,7 +86,7 @@ export const pricingTooltips: PricingTooltips = { main: "Count of messages going through Realtime. Includes database changes, broadcast and presence. \nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.", }, 'security.logDrain': { - main: 'Only events processed and sent to destinations are counted. Bandwidth required to export logs count towards usage.\nEgress through Log Drains is rolled up into the unified egress and benefits from the unified egress quota.', + main: 'Only events processed and sent to destinations are counted. Egress required to export logs count towards usage.\nEgress through Log Drains is rolled up into the unified egress and benefits from the unified egress quota.', }, 'security.hipaa': { main: 'Available as a paid add-on on Team Plan and above.', diff --git a/apps/www/data/features.tsx b/apps/www/data/features.tsx index f2907db1572bb..7ddd5894727fb 100644 --- a/apps/www/data/features.tsx +++ b/apps/www/data/features.tsx @@ -1189,7 +1189,7 @@ Supabase's Smart CDN automatically synchronizes asset metadata to the edge, ensu - Content freshness: Users always receive the most recent version of assets. - Reduced origin load: Minimize requests to the origin server by optimizing edge caching. - Improved user experience: Deliver fast-loading, up-to-date content globally. -- Cost optimization: Reduce bandwidth costs by serving more content from the edge. +- Cost optimization: Reduce egress costs by serving more content from the edge. ## The Smart CDN feature is valuable for: - Dynamic websites with frequently updated content @@ -1224,14 +1224,14 @@ Supabase’s Image Transformations feature enables developers to dynamically man 1. Dynamic resizing: Adjust image dimensions using width and height parameters to suit various display requirements. 2. Quality control: Set image quality on a scale from 20 to 100 to balance visual fidelity and file size. 3. Resize modes: Choose from ‘cover’, ‘contain’, or ‘fill’ to control how images fit within specified dimensions. -4. Automatic format optimization: Automatically convert images to WebP format for supported browsers, enhancing load times and reducing bandwidth usage. +4. Automatic format optimization: Automatically convert images to WebP format for supported browsers, enhancing load times and reducing egress usage. 5. Flexible implementation: Utilize with public URLs, signed URLs, or direct downloads to fit various access control needs ([Server-side Auth](/features/server-side-auth)). 6. [Next.js integration](/nextjs): Leverage a custom loader for optimized image handling in Next.js applications. 7. Self-hosting option: Deploy your own image transformation service using Imgproxy for greater control and customization. ## Benefits: -- Performance optimization: Reduce bandwidth usage and improve load times with optimized images. +- Performance optimization: Reduce egress usage and improve load times with optimized images. - Storage efficiency: Store a single high-quality version and generate variants as needed. - Responsive design support: Serve appropriately sized images for different devices and layouts. - Simplified workflow: Automate image processing tasks, reducing the need for manual intervention and third-party tools. @@ -1240,7 +1240,7 @@ Supabase’s Image Transformations feature enables developers to dynamically man - Responsive web applications: Deliver images optimized for various screen sizes and resolutions. - Ecommerce platforms: Showcase product images in multiple sizes without storing redundant files. - Content management systems (CMS): Adapt images for different layouts and templates dynamically. -- Mobile applications: Optimize images for devices with varying bandwidth and display capabilities. +- Mobile applications: Optimize images for devices with varying egress and display capabilities. - High-volume image handling: Efficiently manage and serve large quantities of images in diverse contexts with [resumable uploads](/features/resumable-uploads). Supabase's Image Transformations feature enables you to efficiently manage and serve optimized images, improving your application's performance and user experience while saving time and resources. @@ -1274,11 +1274,11 @@ Supabase provides a custom loader for Next.js, allowing seamless integration of ### Are there any limitations on image size or dimensions? -While Supabase does not impose strict limits on image sizes, it’s recommended to optimize images for web use to ensure faster load times and better performance. Large images may consume more bandwidth and affect loading speeds. +While Supabase does not impose strict limits on image sizes, it’s recommended to optimize images for web use to ensure faster load times and better performance. Large images may consume more egress and affect loading speeds. ### How does automatic format optimization work? -Automatic format optimization detects the capabilities of the user’s browser and serves the most efficient image format supported, such as WebP. This enhances loading times and reduces bandwidth usage without compromising image quality. +Automatic format optimization detects the capabilities of the user’s browser and serves the most efficient image format supported, such as WebP. This enhances loading times and reduces egress usage without compromising image quality. `, icon: Image, diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 810894d44726c..2dbe7af567647 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -1773,6 +1773,7 @@ export interface components { external_twitter_client_id: string | null external_twitter_enabled: boolean | null external_twitter_secret: string | null + external_web3_ethereum_enabled: boolean | null external_web3_solana_enabled: boolean | null external_workos_client_id: string | null external_workos_enabled: boolean | null @@ -2925,6 +2926,7 @@ export interface components { external_twitter_client_id?: string | null external_twitter_enabled?: boolean | null external_twitter_secret?: string | null + external_web3_ethereum_enabled?: boolean | null external_web3_solana_enabled?: boolean | null external_workos_client_id?: string | null external_workos_enabled?: boolean | null diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index b84082af171d7..f574a4d98f5b3 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -227,6 +227,23 @@ export interface paths { patch?: never trace?: never } + '/platform/cloud-marketplace/buyers/{buyer_id}/onboarding-info': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get info needed for AWS Marketplace onboarding */ + get: operations['ClazarController_getCloudMarketplaceOnboardingInfo'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/database/{ref}/backups': { parameters: { query?: never @@ -1640,23 +1657,6 @@ export interface paths { patch?: never trace?: never } - '/platform/organizations/cloud-marketplace/check-eligibility': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Check whether given organizations are eligible for AWS billing */ - get: operations['OrganizationsController_checkCloudMarketplaceEligibility'] - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } '/platform/organizations/confirm-subscription': { parameters: { query?: never @@ -4438,11 +4438,6 @@ export interface components { ChangeMFAEnforcementStateRequest: { enforced: boolean } - CheckCloudMarketplaceEligibilityResponse: { - is_eligible: boolean - reasons: string[] - slug: string - }[] CloneBackupsResponse: { backups: { id: number @@ -4471,6 +4466,22 @@ export interface components { /** @default 0 */ recoveryTimeTarget?: number } + CloudMarketplaceOnboardingInfoResponse: { + aws_contract_auto_renewal: boolean + aws_contract_end_date: string + aws_contract_settings_url: string + aws_contract_start_date: string + organization_linking_eligibility: { + is_eligible: boolean + reasons: ( + | 'ALREADY_BILLED_BY_PARTNER' + | 'ALREADY_BILLED_BY_PARTNER_AWS' + | 'OVERDUE_INVOICES' + )[] + slug: string + }[] + plan_name_selected_on_marketplace: string + } ConfirmCreateSubscriptionChangeBody: { kind?: string name: string @@ -5800,6 +5811,7 @@ export interface components { /** @enum {string} */ billing_partner?: 'fly' | 'aws' | 'vercel_marketplace' billing_via_partner: boolean + cached_egress_enabled: boolean current_period_end: number current_period_start: number customer_balance: number @@ -6078,6 +6090,7 @@ export interface components { EXTERNAL_TWITTER_CLIENT_ID: string EXTERNAL_TWITTER_ENABLED: boolean EXTERNAL_TWITTER_SECRET: string + EXTERNAL_WEB3_ETHEREUM_ENABLED: boolean EXTERNAL_WEB3_SOLANA_ENABLED: boolean EXTERNAL_WORKOS_CLIENT_ID: string EXTERNAL_WORKOS_ENABLED: boolean @@ -6709,6 +6722,7 @@ export interface components { /** @enum {string} */ metric: | 'EGRESS' + | 'CACHED_EGRESS' | 'DATABASE_SIZE' | 'STORAGE_SIZE' | 'MONTHLY_ACTIVE_USERS' @@ -8276,6 +8290,7 @@ export interface components { /** @enum {string} */ usage_metric?: | 'EGRESS' + | 'CACHED_EGRESS' | 'DATABASE_SIZE' | 'STORAGE_SIZE' | 'MONTHLY_ACTIVE_USERS' @@ -8539,6 +8554,7 @@ export interface components { EXTERNAL_TWITTER_CLIENT_ID?: string | null EXTERNAL_TWITTER_ENABLED?: boolean | null EXTERNAL_TWITTER_SECRET?: string | null + EXTERNAL_WEB3_ETHEREUM_ENABLED?: boolean | null EXTERNAL_WEB3_SOLANA_ENABLED?: boolean | null EXTERNAL_WORKOS_CLIENT_ID?: string | null EXTERNAL_WORKOS_ENABLED?: boolean | null @@ -9772,6 +9788,34 @@ export interface operations { } } } + ClazarController_getCloudMarketplaceOnboardingInfo: { + parameters: { + query?: never + header?: never + path: { + buyer_id: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CloudMarketplaceOnboardingInfoResponse'] + } + } + /** @description Failed to get info for AWS Marketplace onboarding */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } BackupsController_getBackups: { parameters: { query?: never @@ -11854,6 +11898,7 @@ export interface operations { interval?: string metric: | 'EGRESS' + | 'CACHED_EGRESS' | 'DATABASE_SIZE' | 'STORAGE_SIZE' | 'MONTHLY_ACTIVE_USERS' @@ -13256,7 +13301,7 @@ export interface operations { [name: string]: unknown } content: { - 'application/json': components['schemas']['CreateOrganizationResponse'] + 'application/json': components['schemas']['OrganizationResponse'] } } /** @description Failed to create organization billed by AWS Marketplace */ @@ -13268,32 +13313,6 @@ export interface operations { } } } - OrganizationsController_checkCloudMarketplaceEligibility: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['CheckCloudMarketplaceEligibilityResponse'] - } - } - /** @description Failed to check whether organizations are eligible for AWS billing */ - 500: { - headers: { - [name: string]: unknown - } - content?: never - } - } - } OrganizationsController_confirmSubscription: { parameters: { query?: never @@ -17731,6 +17750,7 @@ export interface operations { | 'total_realtime_egress' | 'total_ingress' | 'total_egress' + | 'total_cached_egress' | 'total_requests' | 'total_get_requests' | 'total_patch_requests' diff --git a/packages/shared-data/plans.ts b/packages/shared-data/plans.ts index d0b7d9d61ec4d..475160225c563 100644 --- a/packages/shared-data/plans.ts +++ b/packages/shared-data/plans.ts @@ -13,8 +13,8 @@ export interface PricingInformation { warningTooltip?: string description: string preface: string - features: { partners: string[]; features: (string | string[])[] }[] - footer?: { partners: string[]; footer: string }[] + features: (string | string[])[] + footer?: string cta: string } @@ -31,38 +31,15 @@ export const plans: PricingInformation[] = [ description: 'Perfect for passion projects & simple websites.', preface: 'Get started with:', features: [ - { - partners: [], - features: [ - 'Unlimited API requests', - '50,000 monthly active users', - ['500 MB database size', 'Shared CPU • 500 MB RAM'], - '5 GB bandwidth', - '1 GB file storage', - 'Community support', - ], - }, - { - partners: ['fly'], - features: [ - 'Unlimited API requests', - '50,000 monthly active users', - ['500 MB database size', 'Shared CPU • 500 MB RAM'], - '5 GB bandwidth', - 'Community support', - ], - }, - ], - footer: [ - { - partners: [], - footer: 'Free projects are paused after 1 week of inactivity. Limit of 2 active projects.', - }, - { - partners: ['fly'], - footer: 'Free projects are paused after 1 week of inactivity. Limit of 1 active project.', - }, + 'Unlimited API requests', + '50,000 monthly active users', + ['500 MB database size', 'Shared CPU • 500 MB RAM'], + ['5 GB egress'], + ['5 GB cached egress'], + '1 GB file storage', + 'Community support', ], + footer: 'Free projects are paused after 1 week of inactivity. Limit of 2 active projects.', cta: 'Start for Free', }, { @@ -77,28 +54,14 @@ export const plans: PricingInformation[] = [ priceMonthly: 25, description: 'For production applications with the power to scale.', features: [ - { - partners: [], - features: [ - ['100,000 monthly active users', 'then $0.00325 per MAU'], - ['8 GB disk size per project', 'then $0.125 per GB'], - ['250 GB bandwidth', 'then $0.09 per GB'], - ['100 GB file storage', 'then $0.021 per GB'], - 'Email support', - 'Daily backups stored for 7 days', - '7-day log retention', - ], - }, - { - partners: ['fly'], - features: [ - ['8 GB disk size per project', 'then $0.125 per GB'], - ['250 GB bandwidth', 'then $0.09 per GB'], - 'Email support', - 'Daily backups stored for 7 days', - '7-day log retention', - ], - }, + ['100,000 monthly active users', 'then $0.00325 per MAU'], + ['8 GB disk size per project', 'then $0.125 per GB'], + ['250 GB egress', 'then $0.09 per GB'], + ['250 GB cached egress', 'then $0.03 per GB'], + ['100 GB file storage', 'then $0.021 per GB'], + 'Email support', + 'Daily backups stored for 7 days', + '7-day log retention', ], preface: 'Everything in the Free Plan, plus:', cta: 'Get Started', @@ -115,18 +78,13 @@ export const plans: PricingInformation[] = [ priceMonthly: 599, description: 'Add features such as SSO, control over backups, and industry certifications.', features: [ - { - partners: [], - features: [ - 'SOC2', - 'Project-scoped and read-only access', - 'HIPAA available as paid add-on', - 'SSO for Supabase Dashboard', - 'Priority email support & SLAs', - 'Daily backups stored for 14 days', - '28-day log retention', - ], - }, + 'SOC2', + 'Project-scoped and read-only access', + 'HIPAA available as paid add-on', + 'SSO for Supabase Dashboard', + 'Priority email support & SLAs', + 'Daily backups stored for 14 days', + '28-day log retention', ], preface: 'Everything in the Pro Plan, plus:', cta: 'Get Started', @@ -138,17 +96,12 @@ export const plans: PricingInformation[] = [ href: 'https://forms.supabase.com/enterprise', description: 'For large-scale applications running Internet scale workloads.', features: [ - { - partners: [], - features: [ - 'Designated Support manager', - 'Uptime SLAs', - 'BYO Cloud supported', - '24×7×365 premium enterprise support', - 'Private Slack channel', - 'Custom Security Questionnaires', - ], - }, + 'Designated Support manager', + 'Uptime SLAs', + 'BYO Cloud supported', + '24×7×365 premium enterprise support', + 'Private Slack channel', + 'Custom Security Questionnaires', ], priceLabel: '', priceMonthly: 'Custom', @@ -156,17 +109,3 @@ export const plans: PricingInformation[] = [ cta: 'Contact Us', }, ] as const - -export function pickFeatures(plan: PricingInformation, billingPartner: string = '') { - return ( - plan.features.find((f) => f.partners.includes(billingPartner))?.features || - plan.features.find((f) => f.partners.length === 0)!.features - ) -} - -export function pickFooter(plan: PricingInformation, billingPartner: string = '') { - return ( - plan.footer?.find((f) => f.partners.includes(billingPartner))?.footer || - plan.footer?.find((f) => f.partners.length === 0)!.footer - ) -} diff --git a/packages/shared-data/pricing.ts b/packages/shared-data/pricing.ts index 8f5603aa739ce..c7507541c637f 100644 --- a/packages/shared-data/pricing.ts +++ b/packages/shared-data/pricing.ts @@ -36,7 +36,8 @@ export type FeatureKey = | 'database.pitr' | 'database.pausing' | 'database.branching' - | 'database.bandwidth' + | 'database.egress' + | 'database.cachedEgress' | 'auth.totalUsers' | 'auth.maus' | 'auth.userDataOwnership' @@ -182,8 +183,8 @@ export const pricing: Pricing = { usage_based: true, }, { - key: 'database.bandwidth', - title: 'Bandwidth', + key: 'database.egress', + title: 'Egress', plans: { free: '5 GB included', pro: ['250 GB included', 'then $0.09 per GB'], @@ -192,6 +193,17 @@ export const pricing: Pricing = { }, usage_based: true, }, + { + key: 'database.cachedEgress', + title: 'Cached Egress', + plans: { + free: '5 GB included', + pro: ['250 GB included', 'then $0.03 per GB'], + team: ['250 GB included', 'then $0.03 per GB'], + enterprise: 'Custom', + }, + usage_based: true, + }, ], }, auth: { @@ -589,11 +601,7 @@ export const pricing: Pricing = { plans: { free: false, pro: false, - team: [ - '$60 per drain per month', - '+ $0.20 per million events', - '+ $0.09 per GB bandwidth', - ], + team: ['$60 per drain per month', '+ $0.20 per million events', '+ $0.09 per GB egress'], enterprise: 'Custom', }, usage_based: true,